IERG 4851 · Week 5

In-Class Lab: Format String

Six puzzles on the lecture target — then we solve every one together.

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.
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
#DirectoryPuzzleLevel
101-walk-the-stackFind which positional argument is your input buffer.Warm-up
202-read-memoryRead memory at an address you choose.Easy
303-write-idChange id with %n.Medium
404-halfword-writeChange id to 0x66887799 with two %hn writes.Medium
505-get-shellSet id and hijack puts to get a shell.Required
606-bonus-64bitDo 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

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

  1. What are the first few values? You never passed them — where do they come from?
  2. Why does %08x make 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.

  1. %s printed exactly one byte, 0x01, then stopped. id is 1. Write out its four bytes in memory, then say which byte stopped %s and why it cannot print anything after that.
  2. Your format string ends at <END>. So who printed the id is 1 and done lines after it? Open fmt.c and name the two lines that run after printf(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

  1. Why must the address appear before the %n that uses it?
  2. 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

  1. Why does the order of the two writes matter?
  2. What would %hhn change? How many writes would 0x66887799 then 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
AddressValue to write
&id1234
&puts_got + 2high half of get_shell
&puts_gotlow 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

  1. Why is the GOT writable here, and what would change on a full-RELRO or PIE binary?
  2. 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

  1. Why is the 64-bit offset larger than the 32-bit one?
  2. Could you have written fewer than four halves for the GOT entry? Why not?