Lukas' Notes

security memory c

Definition

Format String Vulnerability

A Format String Vulnerability occurs when user-controlled data is passed as the format string to a printf-family function — printf(buff) instead of printf("%s", buff) — so the specifiers the attacker writes are interpreted against the stack and argument registers, with no requirement that they match the program’s declared arguments. Reading specifiers (%x, %s, %lx, …) become an arbitrary stack read; the %n specifier flips the contract from reading to writing — it stores the running printed-character count at the address held in the next argument slot, giving an arbitrary write sized by padding and truncated by length modifiers.

Why Missing Arguments Are Walked Anyway

Intuition

printf has no argument count. The C varargs ABI passes where the argument list begins; it does not pass how many arguments there are. printf walks the list by counting % specifiers as it parses the format string itself. When the format string is a constant chosen by the programmer, the count lines up with the call’s actual arguments. When the user supplies the format string, the specifiers they write consume stack slots and registers as if they were the missing arguments — and there is no marker telling printf where the program’s real arguments ended. Each % walks one slot past that boundary into adjacent stack.

Reading Memory

Stack slots are read by indexing them with %n$x

The x86-64 calling convention passes the first six integer/pointer arguments in RDIR9; RDI is the format string itself, so the next five “arguments” printf would walk are RSI, RDX, RCX, R8, R9, and argument 6 onward lives on the stack. Direct parameter access %n$x indexes these slots out of order, so an attacker reading the format string can pick exactly which slot to expose.

With the first five register slots known and argument index 6 onward living on the stack, the format string can read any stack slot — including the saved return pointer and the caller’s locals — by indexing high enough.

Reaching secret and a user-supplied pointer

char *secret = "s3cr37";
void func_a() { char buff[20]; fgets(buff, 20, stdin); printf(buff); return; }
int main() { /* secret lives somewhere reachable from the stack */ }
  • "%6$llx\n" prints the buffer’s own bytes (a786c6c243625 ≡ the encoded "%6$llx\n") — confirms argument 6 is the buffer;
  • "%12$llx\n" reads a long from the 12th argument position and prints it as hex — arbitrary stack read;
  • "%12$s\n" reinterprets the 12th argument position as a pointer and dereferences it — if the attacker has arranged for that slot to hold &secret (via a scanf("%lx", &user_c) that primed a variable the buffer reaches), the print yields s3cr37.

The same primitive reads one value or dereferences one address, chosen by specifier.

Writing Memory

%n stores the running byte count at the next slot's address

The %n specifier writes the number of characters printed so far to the address passed as the next argument. printf("AAAA%n\n", &a) leaves a = 4. Combining this with padding and direct parameter access gives arbitrary writes: padding controls the count, direct access picks which stack slot holds the target address.

int secret = 0x1234;
void func_a() { char buff[30]; fgets(buff, 30, stdin); printf(buff); return; }
int main() { int v1, v2; scanf("%d %d", &v1, &v2); func_a(); if (secret == 0x1337) printf("WIN"); }

with v1 = &secret+1, v2 = &secret (both attacker-supplied via the earlier scanf), the payload "%19hhx%12$hhn%36hhx%13$hhn" rewrites secret from 0x1234 to 0x1337:

  1. %19hhx prints 0x1 (the value at arg1) padded to 19 chars → 19 chars printed so far;
  2. %12$hhn writes the truncated LSB (0x13) to the address at argument 12, i.e. v1 = &secret+1;
  3. %36hhx prints the next argument padded to 36 chars total (19 + 17 more) → 36 chars printed so far;
  4. %13$hhn writes the truncated LSB (0x37) to the address at argument 13, i.e. v2 = &secret.

After the call *(&secret+1) = 0x13 and *(&secret) = 0x37; reading secret as a little-endian int gives 0x1337 — the WIN branch fires.

Truncating the Byte Count with Length Modifiers

Writing a byte smaller than the current count

%n writes the current printed-character count, so once the format string has already printed many characters (say, from earlier padding) the running count exceeds the byte the attacker wants to write. The hh modifier truncates the store to a single byte — the LSB of the running count — so the attacker can print a large count and keep only its lower byte.

To write 0x1 when the running count is already, say, 13 (the leading "13 chars long" prefix in the slide’s strcat example): print 0x101 characters in total and use %hhn, so the store writes the LSB of 0x1010x1. Concretely:

int flag = 0xff;
int main() {
  char buffer[0x100];
  char str[0x200] = "13 chars long\0";
  fgets(buffer, 0x100, stdin);
  strcat(str, buffer);
  printf(str);
  if (flag == 0x1) printf("WIN\n");
}

with attacker payload "%244x%<offset>$hhn<padding>&flag" — the leading 13 chars long already counts as 13 printed characters; printing an additional 244 characters (via %244x) brings the total to 257 = 0x101; the trailing hhn stores the LSB 0x01 into &flag.

  • padding is required to align &flag to 8 bytes so printf can reference it as a whole argument slot;
  • <offset> is the parameter distance from the top of the stack to the slot holding &flag — measured the same way as the read example.

Mitigation

Always pass a constant format string

The structural fix is to never let user input be the format string. printf("%s", buff) declares one specifier and gives it one argument; printf(buff) hands the user the specifier list. Make the format argument a compile-time constant whenever it can be.

  • compile with -Wformat-security to flag printf(buff)-style calls;
  • compile with -D_FORTIFY_SOURCE for runtime checks on format-string misuse in supported libc builds;
  • in any source-review checklist, treat a non-constant first argument to a printf-family function as a defect by default.

See the corresponding insight for why printf(buff) and printf("%s", buff) are different operations, not stylistic variants.