0. How this lab runs
This lab is a set of six small puzzles on the same local target, in the same order as the lecture. Work through them at your own pace, then we spend the last part of the class walking through all six on the projector.
Predict before you run. Each task below asks you to write down a prediction first. Keep it — the discussion compares what you expected with what happened, and that gap is where the learning is.
- Work phase: solve tasks 1-5 in order. They build on each other (you reuse the offset from task 1 every time). Task 6 is optional.
- Debrief phase: we solve and explain every task, including the ones people found hard. Bring your notes and your terminal history.
- Nothing is "done and forgotten": the goal of the debrief is to move from "it printed something" to "I can explain why the counter, the offset, and the write order all matter".
- Reading the output: the task scripts print a
---- program output ----divider around anything the target program itself printed, so you never mix up the script's own lines with the program's.
Submission and grading: this in-class lab is practice only. It is not graded and does not count toward your course grade. Submit one concise practice record to Blackboard by the deadline announced in class; it is used for feedback and pacing.
Evidence and integrity: include your own terminal or GDB screenshots showing the offset, the addresses, your payload, and the final output. Do not submit text copied directly from AI tools, classmates, or online writeups: copied answers without your own evidence are not useful, and they will not survive the debrief.
Where to work: log in to the course lab machine with your assigned student account, then work under
~/ierg4851/week5/in-class-lab. If you do not know your lab-machine username or password, ask the TA or instructor during class.cd ~/ierg4851/week5/in-class-lab find . -maxdepth 2 -type f | sort
| # | Directory | Puzzle | Level |
|---|---|---|---|
| 1 | 01-walk-the-stack | Find which positional argument is your input buffer. | Warm-up |
| 2 | 02-read-memory | Read memory at an address you choose. | Easy |
| 3 | 03-write-id | Change id with %n. | Medium |
| 4 | 04-halfword-write | Change id to 0x66887799 with two %hn writes. | Medium |
| 5 | 05-get-shell | Set id and hijack puts to get a shell. | Required |
| 6 | 06-bonus-64bit | Do task 5 again on a 64-bit build. | Optional |
The target: every task directory is self-contained and builds its own copy of the course-owned
fmt.c. Tasks 1-5 build a 32-bit fmt_32; task 6 builds a 64-bit fmt_64. They are non-PIE, no RELRO, no stack canary, and they print the address of id and of get_shell so you can focus on the format-string mechanics.Practice Record Contents
- For each task: the offset or address involved, your payload, and the output that proves it worked.
- Your own terminal/GDB screenshots as evidence.
- A short written answer to each thinking question, in your own words.
1. Walk the Stack
Lecture connection:
printf was given only a format string, no arguments. It still reads "arguments" — straight off the stack, past the format-string pointer.The stack you are about to walk:
HIGH addresses +-------------------------------------------+ | main()'s frame | | saved return address of echo() | | saved ebp of echo() | +-------------------------------------------+ | arg 7 ... | | arg 6 | | arg 5 your input ... | buffer[0..255] | arg 4 <-- buffer pointer points here | +-------------------------------------------+ | arg 3 stale | | arg 2 stale | | arg 1 stale <-- va_list starts here | +-------------------------------------------+ | printf()'s saved return address | +-------------------------------------------+ LOW addresses
printf(buffer) is given no arguments, so it reads slot 1, slot 2, slot 3 … straight off the stack. Slots 1-3 are leftovers from echo()'s own earlier calls, so your own bytes only start at arg 4 — confirm it yourself in the run below.
Predict: slots 1-3 hold values you never passed. Where do they come from — and why must your bytes start at 4?
cd ~/ierg4851/week5/in-class-lab/01-walk-the-stack make clean all ./fmt_32 # at the Input: prompt, type: AAAA.%08x.%08x.%08x.%08x.%08x.%08x.%08x.%08x
Each %08x prints the next value printf reads, left to right. One of them is 41414141 — your AAAA. Its position (1st, 2nd, 3rd, ...) is your offset.
Checkpoint: you know your offset, and you have seen that the first few values are numbers you never passed.
Thinking Questions
- What are the first few values? You never passed them — where do they come from?
- Why does
%08xmake the output easier to read than%x?
2. Read Memory at an Address You Choose
Lecture connection:
%<n>$x prints the word — the argument value itself. To read the memory an address points to you need %<n>$s, which treats the argument as a char *. That single step is what turns "I can see a number on the stack" into an arbitrary read.Predict: with the address of
id at the start of your buffer, %<offset>$x and %<offset>$s print very different things. What will each show?cd ~/ierg4851/week5/in-class-lab/02-read-memory make clean all nano leak_template.py # set OFFSET, then point the read at a target python3 leak_template.py
New syntax:
%<N>$s is the positional form of task 1's N-th %x — it jumps straight to that slot, and it dereferences.If it looks like nothing happens: the template's payload line uses
OFFSET, and the script prints the payload it sends. If OFFSET is still None the script stops with a message; and an empty payload would make printf receive an empty format string, so nothing prints. Always make sure a payload is actually being sent before concluding that a change had no effect.Step 1 — read
id. id is 1, i.e. 01 00 00 00 in memory, and %s stops at the first 00 — so the read gives a single odd 0x01 byte. That is the answer; the shortness is the lesson.<BEG>/<END> are plain literals in your format string that bracket exactly what %s produced; the script prints it back as your %4$s read: b'\x01'. The \nid is 1 and done lines after <END> are printed by the program itself, not by your %s. If the read is empty (b''), the first byte at that address is 0x00 — check that target is the address you meant.Step 2 — read a string out of the binary.
/bin/sh is inside the file (from system("/bin/sh")) but the program never prints it. Find its address and read that:
python3 -c 'from pwn import *; e=ELF("./fmt_32"); print(hex(next(e.search(b"/bin/sh"))))'Checkpoint: the program prints
/bin/sh at the end — something it never does on its own. You just read memory the program did not mean to give you.Submit for this task: the offset, the target address, the output, and what
%x printed instead of %s.Thinking Questions
Answer these from the output you already have — no extra tools needed.
%sprinted exactly one byte,0x01, then stopped.idis1. Write out its four bytes in memory, then say which byte stopped%sand why it cannot print anything after that.- Your format string ends at
<END>. So who printed theid is 1anddonelines after it? Openfmt.cand name the two lines that run afterprintf(buffer).
3. Write with %n: set id to 42
Lecture connection:
%n writes the number of characters printed so far into the pointed-to address. That turns a read primitive into a write primitive.Predict: how many characters are printed at the moment
%n runs, if your payload is an address followed by some padding?cd ~/ierg4851/week5/in-class-lab/03-write-id make clean all nano write_template.py python3 write_template.py
Checkpoint: the program's own line reads
id is 42.The trap: the 4 address bytes are printed too, so they count toward the number.
%38c after a 4-byte address reaches 42 — not a bare %42c.Submit for this task: your payload and the exact count at the moment
%n runs.Thinking Questions
- Why must the address appear before the
%nthat uses it? - What happens if you print more characters than you intended?
4. Two Half-Word Writes: id = 0x66887799
Lecture connection: writing
0x66887799 with one %n means printing over 1.7 billion characters. %hn writes only the low 2 bytes of the count, so the number goes in as two halves.Predict: the counter only goes up. Which half do you write first so it never has to wrap past
0xffff?cd ~/ierg4851/week5/in-class-lab/04-halfword-write make clean all nano halfword_template.py python3 halfword_template.py
&id takes the low half 0x7799; &id + 2 takes the high half 0x6688 (little-endian). Pad the counter to each target value in turn.
Checkpoint: the program prints
id is 1720219545 (0x66887799).Submit for this task: your payload, where each
%hn lands, and why the order matters.Thinking Questions
- Why does the order of the two writes matter?
- What would
%hhnchange? How many writes would0x66887799then need?
5. Capstone: Get a Shell
Lecture connection: combine the two primitives.
get_shell() runs the shell only when id == 1234. Right after the vulnerable printf, the program calls puts("done") — overwrite the puts GOT entry with get_shell and that call becomes your shell.Predict: three 2-byte writes share one growing counter. In what order must they be made?
cd ~/ierg4851/week5/in-class-lab/05-get-shell make clean all make checksec nano exploit_template.py python3 exploit_template.py # in the shell: echo IERG4851_WEEK5_FORMAT_STRING; id
| Address | Value to write |
|---|---|
&id | 1234 |
&puts_got + 2 | high half of get_shell |
&puts_got | low half of get_shell |
Checkpoint: a shell, and
echo IERG4851_WEEK5_FORMAT_STRING; id prints the marker and your user.After it works: try
fmtstr_payload(OFFSET, {addr: value}, write_size="short") and explain why the automatic payload can be longer than your hand version — and why that matters when the input is capped at 255 bytes.Submit for this task: your payload and its length, the order of the three writes, and the checksec line that shows the GOT is writable.
Thinking Questions
- Why is the GOT writable here, and what would change on a full-RELRO or PIE binary?
- Why does the payload have to stay under 255 bytes?
6. Optional Challenge: 64-bit Format String
The same capstone as task 5 — id == 1234 and puts@GOT redirected to get_shell() — but built as a 64-bit binary. Two things change, and they are the point of the task.
Twist 1 — the offset is different. On x86-64 the first arguments of a variadic call come from registers (
rsi, rdx, rcx, r8, r9), not the stack. Your buffer lives on the stack, so it appears at a higher argument number than on 32-bit. Find it the same way as task 1, but with 8-byte reads: %016lx.Twist 2 — a pointer is 8 bytes.
%hn writes 2 bytes, so overwriting a 64-bit GOT entry takes four writes. The counter still only grows, so you must order every write — the four pointer halves and id — from smallest value to largest.Twist 3 — you cannot put the addresses first. On 32-bit you put the addresses at the start of the buffer. On 64-bit that silently breaks: a 64-bit address like
0x…004033b8 contains 0x00 bytes, and printf stops reading the format string at the first 0x00. So here the % directives go first and the addresses go at the end. The template's build() does that bookkeeping; your job is the offset and the list of writes.Predict: how many
%hn writes do you need in total, and where does id = 1234 sit in that ordering?cd ~/ierg4851/week5/in-class-lab/06-bonus-64bit make clean all make checksec ./fmt_64 nano exploit_template.py # note: p64, not p32 python3 exploit_template.py # in the shell: echo IERG4851_WEEK5_FORMAT_STRING_64; id
Checkpoint: a shell, and
echo IERG4851_WEEK5_FORMAT_STRING_64; id prints the marker and your user.Submit for this challenge: the 64-bit offset you found, your payload, how many
%hn writes you used and in what order, and a screenshot.Thinking Questions
- Why is the 64-bit offset larger than the 32-bit one?
- Could you have written fewer than four halves for the GOT entry? Why not?