Step 177. CTF Taste Test 2: Pwn Challenge (pwnable.kr) — First Encounter with a Binary
Level 3 — Real-World CTF and Advanced Attack Skills | Difficulty ★★★★☆ | Estimated time: 6 hours
Prerequisites: Steps 62–65 (buffer overflow and memory experiments), Step 176 (CTF formats). You can read basic C syntax.
⚠️ 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. The measured environment is Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64.
- Caution: this environment does not connect to the external platform (pwnable.kr). Platform connection scenes are shown as "Screen example," and the two programs forming the backbone of the challenges are recreated from source and compiled and solved hands-on in WSL.
Pwn is the CTF category that attacks memory vulnerabilities in executables (binaries). You’re given an executable and a server to connect to; analyze the file, find the gap, and a successful attack on the server yields the flag. If yesterday’s web was "the category of burrowing into documents," Pwn is "the category of burrowing into the machine itself." Today you learn this category’s rules with two introductory challenges.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the structure of a Pwn challenge (analysis file + target server)
- Know the approach for reading "the gap the author intended" out of C source
- Satisfy conditions by exploiting the relationship between file descriptors (fd) and stdin
- Convert integers to bytes in little-endian order to craft exact-length input
- Deliver precise input to a binary with Python
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Reading C + WSL Ubuntu bash + Python (measured: Ubuntu 24.04, gcc 13.3.0, gdb 15.1) |
| Today’s commands | gcc -o program source.c, gdb -batch -ex "disassemble main", struct.pack("<5i", ...) |
| Concepts needed | File descriptors, read/strcmp, little-endian, pointer casting, integer-overflow sense |
| Today’s artifact | Solution records for two challenges + one line each on "the gap the author intended" |
2-1. The Structure of a Pwn Challenge — A File and a Server
Where a web challenge gives you "one address," a Pwn challenge usually gives you three things (Screen example — the shape of a challenge page on pwnable.kr):
# Screen example — the typical layout of a platform challenge page
fd
Mommy! I think I know what a file descriptor is!!
ssh fd@pwnable.kr -p2222 (pw:guest)
The file (source or binary) is for analysis, and the server is the attack target. You read the source locally, find the gap, then connect to the server and read the flag with the same logic. Today, instead of a server, we compile the same program in WSL and verify that logic hands-on.
2-2. File Descriptors — Doors You Open by Number
The handle a Linux program uses to work with files, keyboards, and networks is the file descriptor (fd) — just an integer number. And the first three are agreed upon:
| Number | Name | Connected to |
|---|---|---|
| 0 | stdin | Keyboard (standard input) |
| 1 | stdout | Screen (standard output) |
| 2 | stderr | Screen (standard error) |
read(0, buf, 32) means "read 32 bytes from door number 0 (stdin)." This agreement is the key to today’s first challenge.
2-3. Little-Endian — Why Integers Get Stored Backwards
x86-64 stores integers in memory in little-endian order — lowest byte first. The integer 0x12345678 sits in memory as 78 56 34 12. That rule you met in Steps 62–65 when handling addresses and values decides, in today’s second challenge, "in what byte order must the input be built."
2-4. Reading the Author’s Gap — The Grammar of Pwn
The order for reading the source of an introductory Pwn challenge is fixed. ① Where does input come in (argv? stdin?) → ② What condition gets checked → ③ Can you back-calculate the input that satisfies the condition? Attack is not violence but reverse calculation — both of today’s challenges are "read the condition and compute the input" problems.
3. Follow Along
3-1. Preparing the Lab — Recreating Two Challenges Locally
pwnable.kr’s introductory challenges fd and collision are classics with published source. Let’s recreate them in WSL’s /tmp and solve them ourselves (actual server connection is shown only as a Screen example).
Input (fd.c — a recreation of the fd challenge)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char buf[32];
int main(int argc, char* argv[], char* envp[]){
if(argc<2){ printf("pass argv[1] a numbern"); return 0; }
int fd = atoi( argv[1] ) - 0x1234;
int len = 0;
len = read(fd, buf, 32);
if(!strcmp("LETMEWINn", buf)){
printf("good job :)n");
system("/bin/cat flag");
exit(0);
}
printf("learn about Linux file IOn");
return 0;
}
Input (col.c — a recreation of the collision challenge)
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i; int res=0;
for(i=0; i<5; i++){ res += ip[i]; }
return res;
}
int main(int argc, char* argv[]){
if(argc<2){ printf("usage : %s [passcode]n", argv[0]); return 0; }
if(strlen(argv[1]) != 20){ printf("passcode length should be 20 bytesn"); return 0; }
if(hashcode == check_password( argv[1] )){ system("/bin/cat flag"); return 0; }
else printf("wrong passcode.n");
return 0;
}
Compile and prepare
mkdir -p /tmp/s177 && cd /tmp/s177
echo "FLAG{fd_4nd_c0ll1s10n_cl34r3d}" > flag
gcc -o fd fd.c
gcc -o col col.c
fd.c:12:11: warning: implicit declaration of function 'read'; did you mean 'fread'?
col.c:23:9: warning: implicit declaration of function 'system'
(Measured 2026-09-09. The warnings come from missing header declarations in the original source and don’t hinder today’s purpose. The real platform’s binaries were built from source like this too.)
Prediction: look at the condition in fd.c. If the value
readfillsbufwith equals"LETMEWINn", the flag comes out. If we get to pick thefdnumber, which number means "read from the keyboard"? Check the table in 2-2, make your prediction, and move on.
3-2. Challenge 1: fd — The File Descriptor Agreement
First, let’s see what failure looks like:
./fd
pass argv[1] a number
echo hello | ./fd 1
learn about Linux file IO
(Measured 2026-09-09.)
How to read it: the condition in the source is int fd = atoi(argv[1]) - 0x1234;. The fd becomes our number minus 0x1234. Passing 1 makes fd 1 - 0x1234 = negative — no such door exists, so read fails and buf, still empty, loses the comparison.
Now back-calculate. What we want: read reads from the keyboard (stdin, fd 0). The needed condition: atoi(argv[1]) - 0x1234 == 0. So the number to pass is 0x1234 = decimal 4660.
echo LETMEWIN | ./fd 4660
FLAG{fd_4nd_c0ll1s10n_cl34r3d}
good job :)
(Measured 2026-09-09. The output order looks swapped from the source’s printf order because of buffering — see Wall 3.)
How to read the output: echo LETMEWIN sent "LETMEWINn" to standard input, and read(0, buf, 32) — now with fd 0 — read it and passed the strcmp. We didn’t touch memory at all — we just knew the agreement (fd 0 = stdin) and back-calculated the condition.
The author’s intended gap, in one line: "The user picks the fd number, and number 0 is always stdin."
3-3. Challenge 2: collision — The Sum of Five Integers
Let’s read col.c’s conditions:
- Input must be exactly 20 bytes (
strlen(argv[1]) != 20gets rejected) - Those 20 bytes get cut into five 4-byte ints (
(int*)p) and summed - If the sum equals
0x21DD09EC, the flag comes out
First, confirm failure:
./col
usage : ./col [passcode]
./col AAAAAAAAAAAAAAAAAAAA
wrong passcode.
(Measured 2026-09-09. Twenty A’s — the length is right, but the sum of five 0x41414141s differs from the target.)
Back-calculation time. The target sum is 0x21DD09EC = decimal 568134124. Divide it by five:
568134124 ÷ 5 = 113626824 ... remainder 4
→ four of them are 113626824 (0x06C5CEC8), the last one is 113626828 (0x06C5CECC)
→ check: 113626824×4 + 113626828 = 568134124 ✓
Now turn these five integers into a little-endian byte sequence and pass it as argv. Since it contains bytes you can’t type by hand (control characters), we build it in Python:
import struct, subprocess
target = 0x21DD09EC
q, r = divmod(target, 5)
nums = [q, q, q, q, q + r]
payload = struct.pack("<5i", *nums) # little-endian, 5 ints = 20 bytes
print(f"payload {len(payload)} bytes: {payload!r}")
out = subprocess.run([b"./col", payload], capture_output=True)
print(out.stdout.decode(), end="")
payload 20 bytes: b'xc8xcexc5x06xc8xcexc5x06xc8xcexc5x06xc8xcexc5x06xccxcexc5x06'
FLAG{fd_4nd_c0ll1s10n_cl34r3d}
(Measured 2026-09-09.)
How to read the new tool: in struct.pack("<5i", ...), < is little-endian and 5i is five ints. You can see 0x06C5CEC8 going in flipped as the byte sequence c8 ce c5 06 — this is little-endian in the flesh. And we passed argv as bytes, like subprocess.run([b"./col", payload]). Pass it as a string and encoding sneaks in and breaks the length — a failure we actually hit in this chapter, covered in Wall 2.
The author’s intended gap, in one line: "Cast a string pointer to an int array, and 20 input bytes become a sum of five integers — a hash where only the sum matters gives you collisions for free."
3-4. Peeking Inside with gdb — Practice for When There’s No Source
Real Pwn challenges often give you only the binary, no source. The first tool for that situation is gdb. Let’s dissect the fd binary we just made, as if we had no source:
gdb -batch -ex "disassemble main" ./fd | head -18
Dump of assembler code for function main:
0x00000000000011e9 <+0>: endbr64
0x00000000000011ed <+4>: push %rbp
0x00000000000011ee <+5>: mov %rsp,%rbp
0x00000000000011f1 <+8>: sub $0x30,%rsp
...
0x0000000000001200 <+23>: cmpl $0x1,-0x14(%rbp)
0x0000000000001204 <+27>: jg 0x121f <main+54>
...
(Measured 2026-09-09, gdb 15.1.)
How to read the output: cmpl $0x1, ... at <+23> compares "is the argument count greater than 1" — that’s the source’s if(argc<2). Even without source, you can see where the condition lives. Assembly gets proper treatment starting in Step 182, so for today, confirming "a binary can be opened too" is enough. By the way, the checksec tool that shows a binary’s protections is the field standard, but it’s not installed in this environment — Screen example only:
# Screen example — what checksec shows
RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE
Each item answers "which defense layers does this binary have." Step 62’s canary corresponds to the Stack line — the presence or absence of defenses decides attack difficulty.
3-5. What It Looks Like on the Real Platform — Screen Example
Applying the logic you practiced locally to the real pwnable.kr flows like this (Screen example — this environment did not connect):
# Screen example — a real platform connection scenario
$ ssh fd@pwnable.kr -p2222
fd@pwnable.kr's password: guest
fd@ubuntu:~$ ls
fd fd.c flag
fd@ubuntu:~$ cat fd.c # the source is on the server too — analysis happens here
fd@ubuntu:~$ echo LETMEWIN | ./fd 4660
good job :)
Mommy! the file descriptor is dangerous...
Only two things differ from the local recreation — the flag’s contents, and the fact that the server’s fd binary has SUID set, so it reads the flag file (which we have no permission to read) on our behalf (this is how Step 106’s SUID gets used here).
4. Missions & Exercises
Mission — A "The Author’s Gap" Note
On top of the two challenges you solved today, build and solve a third mini-challenge of your own:
- Modify col.c into col2.c, changing the target sum from
0x21DD09ECto a value of your choice (e.g.,0x2A2A2A2A) - Compile it, compute 20 bytes matching the new target with Python, and solve it
- For each of the three challenges (fd, col, col2), write down in your notes: "input path / checked condition / back-calculation method / the author’s intended gap in one line"
Exercises
Exercise 1. In the fd challenge, what happens if you pass ./fd 4661? Which fd gets selected, and why does it fail?
Exercise 2. In collision, ./col AAAAAAAAAAAAAAAAAAAA has the right length of 20 — why is it wrong? Compute the sum of the five A-blocks in hexadecimal.
Exercise 3. Explain, using little-endian, why the result bytes of struct.pack("<i", 0x06C5CEC8) come out in the order c8 ce c5 06.
Exercise 4. On the real pwnable.kr you can’t read the flag file directly with cat, yet a successful ./fd 4660 reads it. What permission mechanism makes this possible?
5. Model Answers & Completion Criteria
Mission Model Answer
If you changed col2.c’s target to 0x2A2A2A2A (= 707406378):
707406378 ÷ 5 = 141481275 ... remainder 3
nums = [141481275, 141481275, 141481275, 141481275, 141481278]
payload = struct.pack("<5i", *nums) → 20 bytes
check: 141481275×4 + 141481278 = 707406378 ✓
An example of the notes:
[fd] input: argv → atoi - 0x1234 → read(fd) → strcmp condition
back-calc: for fd=0 (stdin), input = 0x1234 = 4660
gap: the user picks the fd number, and 0 is always stdin
[col] input: argv (20B) → sum of 5 ints → compare with 0x21DD09EC
back-calc: quotient of target ÷ 5 four times + (quotient+remainder) once, packed little-endian
gap: string→int cast, weak validation that only checks the sum
[col2] same structure — only the target changed: confirms the back-calc procedure is reusable
How to verify: ① did col2 print the flag with your self-made payload? ② does the "gap in one line" in your notes describe a hole in the condition, not a technique name?
Exercise Answers
Answer 1. 4661 - 0x1234(4660) = 1, so fd 1 — stdout — gets selected. stdout is a "door for writing," so read fails, and buf, still an empty string, loses the comparison, printing learn about Linux file IO. The review point of the fd challenge is "each number is connected to something different."
Answer 2. A is 0x41, so 20 bytes read as five ints of 0x41414141. The sum is 0x41414141 × 5 = 0x145050505, but as a 32-bit int the overflowing upper digit gets cut, leaving 0x45050505. Either way you compute it, it differs from the target 0x21DD09EC, so wrong passcode. it is. The length condition is only half of the passing conditions.
Answer 3. Because little-endian places an integer’s lowest byte first in memory. Listing 0x06C5CEC8 from the lowest byte up gives C8, CE, C5, 06 — so the byte sequence is c8 ce c5 06. Since x86-64 works this way, when our byte sequence gets read back as an int inside the program, the original number is restored.
Answer 4. SUID (Set User ID, Step 106). The fd binary has the SUID bit set so it runs with its owner’s privileges, borrowing the flag file owner’s authority the moment it executes — the program reads the flag we have no read permission for, on our behalf. Every Toddler’s Bottle challenge on pwnable.kr stands on this mechanism. It’s also a real-world case of why SUID is powerful and why it’s an audit target (Steps 174, 175).
Completion Criteria Checklist
- [ ] I can explain the structure of a Pwn challenge (analysis file + target server)
- [ ] I know what file descriptors 0/1/2 are each connected to
- [ ] I recreated the fd challenge locally and solved it with 4660
- [ ] I built a collision payload via the target ÷ 5 back-calculation and solved it
- [ ] I can explain that
<instruct.pack("<5i", ...)means little-endian - [ ] I located the condition spot in a binary with gdb
disassemble main - [ ] Mission: I built and solved a modified col2 challenge and wrote up gap notes for all three
6. Common Pitfalls & Fixes
Wall 1. I don’t get where 4660 came from
Symptom: the relationship between 0x1234 and 4660 doesn’t click.
Cause: a hex→decimal conversion issue. 0x1234 = 1×4096 + 2×256 + 3×16 + 4 = 4660.
Fix: ask Python — python3 -c "print(0x1234)" → 4660. The reverse is hex(4660) → '0x1234'. Base conversion isn’t something to memorize — it’s something to hand to a tool.
Wall 2. The payload is 20 bytes, but I get passcode length should be 20 bytes
Symptom: you clearly made 20 bytes, yet the length check rejects it. A failure actually hit while measuring this chapter.
Cause: if you put the payload into argv as a string, Python encodes it to UTF-8 and bytes like xc8 swell to 2 bytes, turning 20 bytes into 25.
Fix: pass argv itself as bytes — a byte list down to the program name, like subprocess.run([b"./col", payload], ...). Handing it over from the shell with $(python3 -c ...) carries the same encoding trap, so in Pwn the default pattern is letting Python handle execution itself.
Wall 3. The flag prints before good job :)
Symptom: in the source printf("good job :)") comes first, but the actual output puts the flag on top (measured 2026-09-09).
Cause: stdout connected to a pipe is buffered. While the printf output sits in the buffer, the output of system("cat flag") leaves through the pipe first.
Fix: nothing to fix — this is normal behavior. Just remember, when debugging, that "output order = code order" may not hold.
Wall 4. The int sum comes out as a weird value
Symptom: the sum of your five computed numbers comes out smaller than the target.
Cause: col.c’s res is a 32-bit int. If the sum exceeds 0xFFFFFFFF, the upper digits get cut (integer overflow). The challenge’s target stays within range so it’s fine, but you hit this when modifying to other values.
Fix: when picking a target for the mission, choose one at or below 0x7FFFFFFF. Real-world challenges that deliberately aim for overflow exist too, so just remember "summation happens within 32 bits."
Wall 5. gdb output looks like an alien language
Symptom: you look at the disassembly and understand not a single line.
Cause: that’s normal. Assembly gets systematic treatment in Step 182.
Fix: today, practice spotting just three words — cmp (compare), jmp/jXX (jump), call (call). Once these three catch your eye, you can find "where the condition is." The rest you learn when the time comes.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Pwn | The category of attacking a binary’s memory/logic vulnerabilities to grab a flag |
| File descriptor | An integer number pointing at a file or I/O — 0 stdin, 1 stdout, 2 stderr |
| Little-endian | x86’s rule of storing integers lowest byte first |
| Pointer casting | Reading the same bytes as a different type — a 20B string = five ints |
| Back-calculation solving | Reading the condition and computing backwards "the input that satisfies it" |
| SUID and flags | A structure where a SUID binary reads the unreadable flag for you |
Today’s Commands
| Command | What it does |
|---|---|
gcc -o fd fd.c |
Compile C source into an executable |
echo LETMEWIN | ./fd 4660 |
Feed the answer string to stdin via a pipe |
struct.pack("<5i", a,b,c,d,e) |
Pack five ints into 20 little-endian bytes |
subprocess.run([b"./col", payload]) |
Precise execution with byte argv (avoids the encoding trap) |
gdb -batch -ex "disassemble main" ./fd |
Open main’s assembly without source |
An Instinct More Important Than Commands
In today’s two challenges, not a single byte of memory was smashed. You just knew the number agreement (fd 0) and the byte order (little-endian), and back-calculated the conditions. That Pwn’s entrance is not "attacking" but "accurate reading and calculation" — that is both this category’s first impression and a posture that lasts a lifetime.
From the author’s side, conversely, the two challenges are specimens of "code that skimped on validation." fd let the user pick the door number, and col used weak validation where any matching sum passes. Time to add a "Pwn" row to Step 175’s response table — attack: condition back-calculation, defense: restrict input sources and validate strongly. The table keeps growing in Level 3 too.
Once every box is checked, Step 177 is complete.