The C printf family is variadic — it does not know how many arguments were actually passed. It trusts the format string to tell it. For every %x it sees, it reads one word from where the next argument should be. On most ABIs, that means walking up the stack (or argument registers), reading whatever happens to be there as if the caller had passed it.
If the format string is attacker-controlled, the attacker chooses how many stack words to walk and how to interpret each one. %x %x %x %x dumps four words of stack. %s treats the next word as a pointer and dereferences it — an arbitrary read of any address whose value happens to sit at the right offset. Combined with positional specifiers like %7$s, the attacker can pick the offset deliberately.
The truly catastrophic specifier is %n: instead of reading, it writes the number of bytes printed so far to the address sitting at the next argument slot. Pair an %n with field-width padding (%1000c%n prints 1000 spaces, then writes the integer 1000) and an attacker can construct an arbitrary value and write it to any address they can place on the stack. That is the recipe for overwriting return addresses, GOT entries, or function pointers — and from there, executing shellcode.
The fix is one rule: never pass untrusted data as a format string. Always write printf("%s", user_input), never printf(user_input). Modern compilers help: GCC and Clang issue -Wformat-security warnings, and _FORTIFY_SOURCE rejects writable format strings at runtime. Most contemporary languages eliminate the class entirely by separating format and data at the type level (Python f-strings, Rust's format! macro, Java's String.format with a literal first argument).