Description
The secure echo service welcomes you politely, but what if you don't stay polite? Can you make it reveal the hidden flag? Download the program file and source code.
Setup
Download vuln and its source code.
Read the source code to understand how input is handled.
cat vuln.cchmod +x vulnSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Find the buffer overflow in the sourceObservationI noticed that the source code shows a 32-byte buffer but passes 128 to read(), and this mismatch between declared size and read limit is the textbook sign of a stack buffer overflow that lets us overwrite the return address.Read vuln.c. The buffer is declared as 32 bytes, but read() is called with a size of 128. This mismatch lets you write past the end of the buffer and overwrite the saved return address on the stack.bashcat vuln.cWhat didn't work first
Tried: Run the binary and try sending a very long string manually via the terminal to see if it crashes.
Typing input interactively won't let you embed null bytes or control exact byte counts, so you can't craft a precise payload. The overflow needs an exact number of bytes (40 padding + 8-byte address), and the address bytes are non-printable; you need a script to write the binary payload.
Tried: Assume the padding is 32 bytes (the declared buffer size) and skip the saved RBP slot.
32 bytes of padding only fills the buffer itself. The saved RBP sits in the 8 bytes immediately above the buffer before the return address, so using 32 bytes of padding overwrites part of the saved RBP and leaves the return address untouched. You need 32 + 8 = 40 bytes of padding to reach the return address.
Learn more
A stack buffer overflow occurs when more bytes are written into a stack-allocated buffer than it can hold. The extra bytes overwrite adjacent stack data, including the saved base pointer and the saved return address. When the function returns, it jumps to whatever address is now in the return address slot.
In x86-64, when a function is called, the return address is pushed onto the stack first, then the old frame pointer, then local variables are allocated below that. So the layout from the buffer start is: buffer (32 bytes), then padding to the old saved RBP (8 bytes), then the return address (8 bytes). Writing 40 bytes of padding followed by the address of
winoverwrites the return address to redirect execution there.Step 2
Find the address of win()ObservationI noticed the source contains a win() function that opens and prints the flag but is never invoked in normal control flow, which meant I needed its exact binary address to use as the overwrite target in the payload.Locate the win() function in Ghidra or with objdump. It reads and prints the flag file.bashobjdump -d vuln | grep '<win>'pythonpython3 -c "from pwn import *; e=ELF('./vuln'); print(hex(e.sym['win']))"Expected output
0000000000401256 <win>: 0x401256
What didn't work first
Tried: Open the binary in a hex editor and search for the string 'win' to find the function address.
A hex editor shows raw bytes and will find the ASCII bytes of 'win' inside the symbol table or string sections, but those offsets are not the executable address. The function's actual start address comes from the symbol table entry as interpreted by the ELF loader, not from a raw byte offset into the file. objdump and pwntools ELF both parse the symbol table correctly.
Tried: Use nm vuln instead of objdump to get the win address.
nm works on unstripped binaries and will print the win symbol address, so it is actually a valid alternative here. The common mistake is running nm on a stripped binary where the symbol table is removed, in which case nm returns nothing and objdump -d with grep on the disassembly listing is needed instead.
Learn more
The binary contains a
winfunction that is never called in normal program flow. This function reads and prints the flag file. By overwriting the return address with the address ofwin, the program jumps to it when the vulnerable function returns.In Ghidra, open the binary, let it analyze, then look at the main program in the decompiler. You can see the buffer, the read call, and locate the win function address in the symbol tree.
Step 3
Build and send the exploit payloadObservationI noticed the x86-64 stack layout places the 32-byte buffer below the 8-byte saved RBP and then the return address, so 40 bytes of padding followed by the little-endian win() address would land precisely on the return address slot when sent over the network connection.The buffer is 32 bytes below RBP. Above the buffer is 8 bytes of old RBP, then the return address. So 40 bytes of padding followed by the win address overwrites the return address. Send the payload via netcat.bash# Build the payload: 40 bytes of 'A' padding, then the 8-byte win address in little-endian.bash# Replace 0x401256 with the real win address from objdump. struct.pack('<Q', ...) emits 8 bytes for x86-64.pythonpython3 -c "import sys, struct; sys.stdout.buffer.write(b'A'*40 + struct.pack('<Q', 0x401256))" | nc <HOST> <PORT_FROM_INSTANCE>bash# Or use pwntools:pythonpython3 << 'EOF' from pwn import * e = ELF("./vuln") win_addr = e.sym["win"] payload = b"A" * 40 payload += p64(win_addr) r = remote("<HOST>", <PORT_FROM_INSTANCE>) r.sendafter(b"Welcome", payload) print(r.recvall(timeout=3)) EOFWhat didn't work first
Tried: Pipe the payload directly with printf or echo instead of python3, for example: printf 'AAAA...\x56\x12\x40' | nc host port.
Shell printf interprets escape sequences inconsistently across shells and truncates on null bytes (\x00), which appear in most 64-bit addresses padded to 8 bytes. The address 0x401256 packed as a 64-bit little-endian value is V@ , which contains five null bytes that printf silently drops, sending a short payload that misses the return address slot. python3 with sys.stdout.buffer.write writes raw bytes without any null-stripping.
Tried: Use the win address from the local binary on the remote server without checking whether ASLR is enabled.
If the remote binary is PIE (Position Independent Executable), its base address is randomized each run and the win address in the local objdump output is a relative offset, not the real runtime address. For non-PIE binaries (which this challenge uses, as indicated by the fixed 0x401256 address), the address is the same on the server. Checking checksec ./vuln beforehand confirms whether PIE is on.
Learn more
The payload structure is: 40 bytes of padding (to fill the 32-byte buffer plus the 8-byte saved RBP), followed by the 8-byte little-endian address of
win. When the function executes itsretinstruction, it pops this address off the stack and jumps there.p64(addr)in pwntools packs a 64-bit integer into 8 bytes in little-endian order, which is the format x86-64 expects for return addresses. The Intel architecture is little-endian, meaning the least significant byte comes first in memory.See Buffer Overflow Binary Exploitation and Pwntools for CTF for the broader workflow.
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{3ch0_3sc4p3_1_...}
Stack buffer overflow: buffer is 32 bytes but read() accepts 128. Write 40 bytes of padding then the address of win() to redirect execution and read the flag.
Key takeaway
How to prevent this
How to prevent this
The only root cause is reading more bytes than the buffer can hold. Bound the read to the buffer size.
- Use bounded reads:
read(0, buf, sizeof(buf))orfgets(buf, sizeof(buf), stdin). Never pass a hardcoded size larger than the actual allocation. - Compile with
-fstack-protector-strong. A stack canary placed between the buffer and the return address detects overflows before the function returns and aborts the process. - Don't ship a
win()function that reads /flag in production binaries. CTF challenges include it for teaching; real code should never have an unreachable "open the vault" function.