Historicky mám zkušenosti s psaním 32-bitových shellcodů pro Windows [1][2][3]. Přechod na x64 vypadá na první pohled jako kosmetická změna (například širší registry, jiná calling convention). Ve skutečnosti jde o zcela odlišný mentální model.

Cílem této série je pokusit se zjednodušenou, ale ucelenou formou popsat základní způsob vývoje Win64 shellcodů. Neklade si za cíl vytvořit detailní dokumentaci problematiky psaní Win x64 shellcodů, ale poskytnout zájemcům přístupný studijní materiál, který jim může posloužit jako odrazový můstek do této oblasti.

Začneme tím, co většina tutoriálů opomíjí, ať již cíleně nebo nevědomě (proč věci, které fungovaly na x86 na x64 nefungují a jak se s tím vypořádat).


Pokud jsi někdy viděl jednoduchý shellcode pro Linux x86, pravděpodobně obsahoval přímé hodnoty jako mov eax, 0xb nebo absolutní adresy zapsané přímo do registrů. Na Windows x64 tohle nefunguje – a tento článek vysvětluje proč.

Předpoklady

Série předpokládá, že má čtenář základní znalost x86/x64 assembleru (registry, calling convention a nejznámější instrukce) a má představu o tom, co je proces nebo jak funguje stack.


Co je Position-Independent Code

Pokud mluvíme o shellcodech, musíme se seznámit s pojmem Position-Independent Code. Shellcode je spustitelný kód, který musíme doručit a spustit v cílovém procesu, aniž bychom dopředu věděli, na jaké adrese bude umístěn. Nemůžeme předpokládat, že bude shellcode uložen na adrese 0x00401000 ani na žádné jiné. Jinými slovy, musíme počítat, že se náš shellcode zcela určitě objeví na jiné adrese, než jsme původně očekávali. Proto náš shellcode musí být schopen fungovat na libovolné adrese v paměti.

Kód, který funguje správně bez ohledu na to, kde v paměti se nachází, se nazývá Position-Independent Code (PIC).

Ve Windows x64 je vše ještě o něco složitější, protože potřebujeme volat Windows API funkce, a jejich adresy jsou pro nás předem neznámé. Linuxové shellcody to mají v tomto ohledu jednodušší.


Proč neznáme adresy Windows API funkcí

Na starších verzích Windows byl život autorů shellcode mnohem jednodušší. Protože se DLL knihovny načítaly vždy na stejnou adresu, nebylo nic jednoduššího, než vyhledat pro danou verzi Windows umístění dané Windows API funkce (například WinExec[6] nebo CreateProcess[7]) v paměti a zapsat její adresu přímo do shellcodu. To nám zaručovalo, že pro danou verzi Windows bude daný shellcode vždy funkční. Společnost Microsoft si těchto nedostatků byla vědoma a přidala několik mitigací, které tento proces výrazně zkomplikovaly, případně zcela znemožnily.

ASLR – Address Space Layout Randomization

Od Windows Vista využívá společnost Microsoft takzvaný ASLR[8]. ASLR změnil základní fungování procesu. DLL knihovny se již nenačítají na předem známé adresy, ale jejich umístění v paměti je náhodné. To znamená, že při každém spuštění systému jsou klíčové moduly (například ntdll.dll, kernel32.dll nebo kernelbase.dll) načteny na náhodnou bázovou adresu.

První spuštění:
  kernel32.dll  ->  0x7FFB`A3C0`0000

Druhé spuštění:
  kernel32.dll  ->  0x7FFB`C1D0`0000

Třetí spuštění:
  kernel32.dll  ->  0x7FFB`8F40`0000

Z toho vyplývá, že adresa kernel32.dll, stejně jako adresa exportované funkce WinExec, se mění s každým restartem. Shellcode s hardcoded adresou by fungoval nanejvýš jednou, na konkrétním systému v konkrétní session.

DLL rebasing

Situace je ještě komplikovanější. I v rámci jedné session může být tentýž modul v různých procesech načten na různou adresu, pokud dojde ke kolizi s jiným modulem. V takovém případě nastupuje DLL rebasing. Jde o proces, při kterém dojde k úpravě preferované bázové adresy DLL knihovny. Tato adresa je uložena v hlavičkách každé DLL a určuje, kam má být knihovna načtena. Pokud je tato adresa již obsazena jiným modulem, provede se rebasing. ASLR tento efekt pouze zesiluje.


Jak tedy shellcode zjistí adresy funkcí?

I přes všechny výše uvedené překážky existuje způsob, jak tyto dynamické adresy získat v rámci běžícího procesu. Řešení je postaveno na využití struktur, které Windows udržuje přímo v paměti každého procesu, a to bez využití jakýchkoliv externích závislostí.

Klíčové jsou dvě struktury:

TEB (Thread Environment Block)[4] - struktura je udržovaná pro každé vlákno v procesu a je přístupná přes segment registr GS na x64. Její součástí je ukazatel na strukturu PEB.

PEB (Process Environment Block)[5] - struktura je udržovaná pro každý proces. Obsahuje mimo jiné seznam všech načtených modulů, včetně jejich aktuálních bázových adres.

Jednoduše řečeno: I přes ASLR má shellcode za běhu přístup k aktuálním adresám všech načtených modulů. Stačí pouze vědět, kde tyto informace cíleně hledat.

GS:[0x60]  ->  PEB
                └── Ldr
                     └── InMemoryOrderModuleList
                           ├── ntdll.dll        (aktuální bázová adresa)
                           ├── kernel32.dll     (aktuální bázová adresa)
                           └── ...

Shrnutí

  • Shellcode musí být position-independent - nemůže předpokládat, kde v paměti bude umístěn.
  • Windows API funkce mají díky ASLR náhodné adresy při každém spuštění systému nebo procesu.
  • Staticky zapsané adresy v shellcodu jsou proto nefunkční přístup.
  • Řešením je runtime resolution přes struktury TEB a PEB, které jsou dostupné v rámci každého procesu.

Co přijde v příštím dílu

Podíváme se na strukturu PEB a na to, jak Windows udržuje seznam načtených modulů. Projdeme cestu od GS:[0x60] až k bázové adrese kernel32.dll – krok za krokem, se skutečnými offsety.


Odkazy

Shellcody autora

Windows struktury

Windows API funkce

Bezpečnostní mitigace