My Format String Exploit Works, But Where's the Output?
Your format string exploit works locally but fails on the target? Dive into the common reasons like ASLR, stack offsets, and libc differences, and learn how to debug.
Alex Mercer
A seasoned security researcher specializing in reverse engineering and modern binary exploitation techniques.
You've done it. After hours of staring at a disassembler, you’ve crafted the perfect format string payload. You run it against the vulnerable binary on your local machine, and... a shell pops. The sweet, sweet sight of `$` or `#` greets you. But then comes the moment of truth. You point your exploit at the remote server, hit enter, and get... nothing. A crash, a closed connection, deafening silence. What gives?
If this sounds familiar, you're not alone. This is a classic rite of passage in exploit development. The gap between a local exploit and a remote one is where theoretical knowledge meets harsh reality. Let's dive into why your exploit works on your machine but fails in the wild, and how to fix it.
When Local Success Doesn't Translate
The controlled environment of your personal virtual machine is a comfortable sandbox, but it rarely mirrors a production target. The phrase "it works on my machine" is a cliché for a reason. Remote servers often have different library versions, operating systems, security patches, and ways of launching the vulnerable program. Each of these differences can subtly (or catastrophically) alter the program's memory layout, rendering your carefully calculated offsets and addresses useless.
The key to success is to stop thinking of your exploit as a static, one-shot payload and start thinking of it as a dynamic, two-stage process: first, you gather intelligence, then you attack.
Dissecting the Differences: Local vs. Remote
Before you can fix the problem, you need to understand the variables. Your local setup and the remote target are two completely different beasts. Here’s a comparison of the factors that most commonly break format string exploits:
Factor | Your Local Environment | Remote Target | Why It Matters for Your Exploit |
---|---|---|---|
libc Version | Known (e.g., 2.31 on Ubuntu 20.04) | Unknown, but likely different | Offsets for functions like system() and ROP gadgets are specific to the libc version. A mismatch means your addresses are wrong. |
Compiler & Flags | Usually a recent GCC, default flags | Unknown version, potentially hardened flags | Different compilers or optimization flags can reorder functions and change how the stack is set up, invalidating your offsets. |
Environment Variables | Dozens of variables from your shell | Minimal set, defined by the server (e.g., sshd , xinetd ) | Environment variables are pushed onto the stack before main() is called. A different set of variables will change the stack layout and all your offsets. |
Program Path (argv[0] ) | Often short, like ./vuln | Could be a full path like /usr/local/bin/app | Like environment variables, argv is on the stack. A longer path shifts everything down. |
Security Mitigations | Maybe partially enabled (ASLR on) | Almost always more hardened (ASLR, PIE, Canary, Full RELRO) | These are designed specifically to break exploits like yours. Hardcoded addresses are a no-go. |
Stack Layout & Offsets: The #1 Culprit
The most common reason for remote failure is a change in the stack layout. Your format string payload, which might look something like "AAA%7$n"
, relies on the argument %7$n
pointing to a specific location on the stack (in this case, the 7th argument). If the stack changes, that 7th argument is no longer your intended target.
The GDB and Environment Variable Trap
Many beginners find their initial offsets by running the program in GDB. This is a trap. GDB injects its own environment variables and alters the stack to facilitate debugging. An offset that works inside GDB will almost certainly fail when the program is run normally, and it will *definitely* fail on a remote server.
Likewise, running ./vuln
from your interactive shell pushes a ton of variables like $HOME
, $PATH
, $TERM
, etc., onto the stack. A program launched by a daemon like inetd
or a simple `nc` listener has a much sparser environment. This difference in stack depth is often the primary reason your offsets are wrong.
Mitigation Mayhem: ASLR, Canaries, and More
Modern systems are not sitting ducks. They employ several layers of defense that you must actively defeat.
ASLR (Address Space Layout Randomization)
ASLR randomizes the base addresses of the stack, heap, and shared libraries (like libc) every time the program is run. If you found the address of `system()` on your local machine to be `0x7f1234567890`, that address is meaningless on the remote target. Hardcoding addresses is futile when ASLR is on (and it almost always is).
The Fix: You must leak an address first. A common technique is to use the format string vulnerability itself (e.g., with %p
) to leak an address from a known location, like the Global Offset Table (GOT). Once you have one valid address from the remote libc, you can calculate the base address of libc and from there, the address of any function you need, like `system()`.
Stack Canaries
Compilers often place a secret random value, called a canary, on the stack just before the saved return address. Before a function returns, it checks if this value is still intact. If you perform a buffer overflow or a format string write that overwrites the canary, the program will detect the corruption and abort immediately with a `*** stack smashing detected ***` error.
The Fix: If you can't avoid overwriting the canary, you might need to leak it first. Since the canary is on the stack, you can often find its offset and print its value using the format string, then include that exact value in your final payload to pass the check.
PIE (Position Independent Executable)
PIE is like ASLR, but for the application's own code. With PIE enabled, even the base address of the main executable binary is randomized. This means you can't hardcode addresses to gadgets or functions within the binary itself.
The GOT/PLT Puzzle with RELRO
A classic format string technique is overwriting an entry in the Global Offset Table (GOT). For example, you could overwrite the GOT entry for printf
with the address of system
. The next time the program calls printf("some string")
, it will actually execute system("some string")
.
However, this is often thwarted by a mitigation called RELRO (Relocation Read-Only).
- Partial RELRO: The default. The GOT is writable, and this attack works.
- Full RELRO: When compiled with Full RELRO, the GOT is marked as read-only before your code can run. Any attempt to write to it with
%n
will cause a segmentation fault.
If you suspect Full RELRO is enabled, the GOT overwrite technique is off the table. You'll need to find another place to write, like a saved return address on the stack or another function pointer.
Building a Reliable, Remote Exploit
So, how do you put this all together? You build a dynamic, multi-stage exploit. Your goal is no longer to just send a single magic payload.
Step 1: Information Leakage
Craft an initial format string payload designed to leak critical information. Send format string specifiers like %p
or %s
to read data from the stack and other memory regions. You're hunting for:
- A libc address: Find an address on the stack that points into libc. Leaking a GOT entry is a reliable way to do this.
- A stack address: This helps you calculate the exact location of the saved return address you want to overwrite.
- The stack canary: If you need to overwrite it, you must know its value.
Step 2: Calculation
Once your script receives the leaked addresses, it does the math. Using a library like `pwntools` with a local copy of the target's libc (or an online database), you can do this:
libc_base = leaked_printf_addr - libc.symbols['printf']
system_addr = libc_base + libc.symbols['system']
Step 3: The Final Payload
Now that you have the correct, non-randomized addresses for the remote target, you can construct your final payload. This payload will use the format string's write primitive (%n
, %hn
, %hhn
) to overwrite your target—be it a return address on the stack or a function pointer—with the address of system
or a ROP chain.
Here's what that logic looks like in a `pwntools` script:
# Stage 1: Leak a libc address from the GOT
leak_payload = b"%13$p" # Assume 13th arg points to a GOT entry
p.sendline(leak_payload)
p.recvuntil(b"0x")
leaked_addr = int(p.recvline().strip(), 16)
# Stage 2: Calculate addresses
libc.address = leaked_addr - offset_to_leaked_func
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b"/bin/sh"))
# Stage 3: Build and send the final exploit
# This could be a ROP chain or a GOT overwrite payload
final_payload = build_final_payload(system_addr, bin_sh_addr)
p.sendline(final_payload)
p.interactive() # Enjoy your shell!
Key Takeaways: Your Debugging Checklist
The next time your exploit fails on a remote target, don't just give up. Systematically work through the possibilities. This is the core skill of exploit development.
- Stop using hardcoded addresses. Assume ASLR is always on. Your exploit must leak, calculate, and then pwn.
- Verify your stack offsets. Don't trust GDB. Write a simple script to send payloads like
AAAA%1$p.%2$p.%3$p...
to the remote target to see your input's real offset on the stack. - Identify the mitigations. If you have the binary, use `checksec`. If not, infer them from the program's behavior. Does it crash on a GOT write? (Full RELRO). Does it crash on a simple overflow? (Canary).
- Find the correct libc. If you can leak enough function addresses, you can use online tools like libc.blukat.me to identify the exact remote libc version.
- Think in stages. Separate your exploit into a leak stage and a write stage. This modular approach is far more reliable and easier to debug.
Mastering this process will elevate your skills from simply solving CTF challenges to understanding how to build robust exploits that work in the real world.