Skip to content
• 15 min read

SafeStack Bypass Explained: The Vault Is Solid, the Ledger Isn't

SafeStack splits your stack so a buffer overflow can only smash the half nobody misses. Solid plan, except the pointer that defines where the smashable half lives is stored in TLS, and TLS was sitting right next to the overflow. This is the story of how one bad memory layout took down a shadow stack, a canary, and a homemade CFI check in a single chain.

#Binary Exploitation #Memory Safety #SafeStack #Mitigation Bypass #Exploit Development
SafeStack Bypass Explained: The Vault Is Solid, the Ledger Isn't hero illustration
Listen to Research
AI Narration
0:00 / --:--

SafeStack Bypass Explained: The Vault Is Solid, the Ledger Isn’t

Picture a bank that takes security seriously. All the money goes into a proper vault, and all the messy, spill-prone paperwork happens on a cheap table out in the lobby. Sensible. Now the detail that ruins everything: the ledger that says exactly where that lobby table stands is kept in a filing cabinet bolted to the wall directly behind the table. Push enough paperwork across the table and you’re writing into the cabinet. Rewrite the entry in the ledger, and tomorrow the staff will cheerfully set the “lobby table” up inside the vault and do their messy work on top of the money. Nobody breaks into the vault. The vault never even gets opened.

That, in one image, is the SafeStack bypass. You already know the classic stack overflow: pour bytes into a buffer past its end, keep pouring until you’re writing over a return address, and the program becomes yours. Stack canaries were invented to notice exactly that, and NX and ASLR raised the price of everything that comes after. SafeStack took the structural route instead: if overflows are inevitable, move the valuables out of the overflow’s flight path and let the smashable stuff live somewhere harmless. It’s a genuinely good idea, and under the right conditions it works. The conditions are the problem.

In this piece: how SafeStack splits one stack into two, why that split quietly depends on a memory layout nobody verified, a worked example where a homemade shadow stack, a canary, and a hand-rolled CFI check all fall over in sequence, and the fix that actually matters (hint: it’s the boring one).

What Is SafeStack, Anyway?

On a normal Linux x86-64 program, each thread gets one stack that holds everything at once: your local arrays, your saved registers, your spilled pointers, and your return addresses. An overflow doesn’t need to understand any of this. It just writes upward in memory until it lands on a return address, and from that point the attacker is the one steering.

The classic defenses are all reactions to that picture. Canaries put a sentinel value on the stack and check it on the way out. NX stops injected code from executing. ASLR hides the targets. All three raise the cost of an attack, and all three have well-worn bypass lanes.

SafeStack, which grew out of the Code-Pointer Integrity research line and ships with LLVM/Clang behind -fsanitize=safe-stack, takes a structural swing instead. At compile time, every stack object gets classified:

  • Safe: return addresses, saved registers, spilled values the control flow depends on. These live on the safe stack.
  • Unsafe: arrays, buffers, anything whose address escapes, in short anything an overflow can smear. These move to a separate region called the unsafe stack.

Functions compiled this way keep their control data on the safe stack and address their scratch data relative to __safestack_unsafe_stack_ptr, a thread-local variable. And there’s the load-bearing decision: that pointer lives in TLS, the per-thread data block the CPU reaches through the fs segment register on x86-64.

The reasoning was sound. An overflow on the unsafe stack shouldn’t be able to reach a whole other region like TLS, because memory regions have gaps between them, like rooms have walls.

The threat model says “separate”. The memory map, in the layout we’re about to meet, says “adjacent”. Those two words carry this entire article.

Why Should You Care?

  • The protection is real and in production. It’s one clang flag away, so hardened services use it, usually stacked on top of PIE, Full RELRO, NX, and a canary. When you do run into it, it genuinely hurts: the return address is simply not where your overflow is, and the usual chains die on contact.
  • The failure is total. This is not a partial bypass or an info leak. If you can overwrite the unsafe stack pointer, every subsequent unsafe-stack write the program performs lands wherever you pointed it. A “contained” overflow becomes a write-where-you-like primitive, and from there it’s a short walk to a shell.
  • The bug is in the layout, not the code. The application we’ll walk through is compiled correctly, with the mitigation enabled. There’s no CVE-style flaw in its source. The gap opens between two memory regions that were never supposed to touch.
  • Every downstream defense inherits the fall. Shadow stack? Both sides of its comparison end up attacker-writable. Canary? Same. Custom CFI? Its “trusted” base register is a saved register you now control. Once the isolation assumption dies, everything built on top of it dies with it.

The Scenario: A Note Daemon That Trusted Its Compiler

Here’s the synthetic target, a small Linux daemon we’ll call notesvc. It listens on a socket, shows a text menu, and does three things worth caring about: a state-preview option that prints some of its own bookkeeping (including, helpfully, a live pointer), a raw processing pass over user input, and a note-saving feature. It’s compiled with clang -O2 -fsanitize=safe-stack, and it’s a good citizen of hardening everywhere else: PIE, Full RELRO, NX, stack canary. The author, feeling ambitious, also added two homemade defenses: a shadow stack (a copy of each return address in a BSS array, compared on return) and a forward-edge CFI check on indirect calls.

// notesvc.c: the feature that matters
void save_note(void) {
    char note[48];                 // lands on the UNSAFE stack under SafeStack
    puts("Provide some input for the buffer.");
    gets(note);                    // unbounded read, and the author knows it
}

Forty-eight bytes of buffer, one unbounded read, and a compiler promise that the damage stays contained to the unimportant half of memory.

Finding the Crack

The recon reasoning, in the order it actually happens:

  1. Inventory. checksec shows the usual walls plus unusual furniture: a symbol named __safestack_unsafe_stack_ptr (SafeStack is on), a BSS array written at call sites (shadow stack), and a small compare-and-trap sequence before indirect calls (homemade CFI).
  2. The bug. gets into a 48-byte buffer. On a normal binary you’d stop here and start writing ROP. Under SafeStack, the buffer lives on the unsafe stack, so overflowing it just smears other unsafe data. The return address of save_note is somewhere else entirely, and the first oversized attempt dies without ever reaching a return.
  3. The layout question. Where exactly is the unsafe stack, and what lives around it? In a debugger: it’s an anonymous mmap. Directly above it, with no guard page in between: the static TLS block, thread pointer and all. The distance from the note buffer to __safestack_unsafe_stack_ptr is a fixed number of bytes, constant across runs, because ASLR slides whole regions around but doesn’t rearrange the furniture inside them. And that state-preview menu option prints a live bookkeeping pointer, which is the sort of hospitality that turns an exploit from theoretical to scheduled: one printed number, and the program base, the thread’s stack geometry, and every fixed offset in between falls out with subtraction.
  4. The conclusion. One unbounded read, plus one adjacent region, plus no guard page, means the “separate” in SafeStack’s threat model was never true here. The overflow can reach the pointer that defines the unsafe stack.

The Trap: Overflowing a Live Process Is Surgery

Here’s where the first naive payload faceplants. Fill the space up to TLS with junk and keep going, and the process dies in the middle of the gets call, deep inside glibc, before any of your clever corruption matters.

The reason is a gem of engineering reality: gets bottoms out in read, and glibc’s machinery for cancellable syscalls dereferences the thread descriptor (the TCB, reachable at fs:0x10) to do its bookkeeping. The thread pointer you just smeared with junk is now a non-canonical garbage value, and the very next bookkeeping access is a one-way trip to SIGSEGV. Fill it with zeros instead of junk and it dies differently, at a near-null address. Different tombstone, same funeral.

So the payload has to be a surgeon, not a bulldozer. Between the buffer and the target pointer lies territory the runtime is actively walking through:

  • The thread self-pointer gets repaired to point at plain writable stack memory, so the cancellation bookkeeping survives the rest of the read.
  • The dynamic-thread-vector pointer and glibc’s internal TLS slots get neutralized or restored. In this build their real values were fixed offsets from the program image, computable from that single leak, but the blunt instrument of zeroing them also worked, since nothing on the hot path needed them yet.
  • The stack guard slot gets a value we choose on purpose, which matters in a moment.

With the furniture repaired, gets finishes, the prompt prints, the program keeps breathing, and every corrupted field is now a field we chose. That’s the whole difference between a crash and an exploit.

The Pivot: Moving the Table Into the Vault

The star write of the overflow is __safestack_unsafe_stack_ptr itself, which gets pointed at the real stack, a few dozen bytes below the live frames. From this moment, every unsafe-stack access by this thread lands on the real stack instead. The staff have been told the lobby table now stands in the vault, and they see no reason to argue with the ledger.

What makes this devastating is what programs do next: they keep doing unsafe-stack work. Our daemon’s next feature reads 40 raw bytes for “processing”, addressed relative to the (now fake) unsafe stack pointer, which drops them precisely onto the current function’s saved registers and its caller’s frame. That one redirected read forges:

  • a saved callee-saved register that later becomes a buffer pointer,
  • the canary-comparison inputs, and
  • the CFI base register used by the homemade check.

Then the note feature calls gets again, and this time the buffer address derives from the forged register, so the write lands on that call’s own return address slot. Self-service ROP: the function writes its own hijack over itself.

Four Checks, Four Falls

Each homemade defense fails for a specific, instructional reason.

  1. The shadow stack. The daemon compared the live return address against a copy it saved earlier. But the copy it consulted was reached through state the overflow already owned, so the comparison was between our value and our value. A check that compares your data to your data is a mirror, not a guard.
  2. The canary. Same shape of failure. The canary that got stored was copied from memory we had staged, and the value it got compared against was popped from a slot we had filled. Both sides of the check came from our pen. No secret survives when its storage and its verifier sit in the same write set.
  3. The homemade CFI. Before each indirect call, the daemon computed a rotated delta between the call target and a base register, and trapped unless the delta was tiny. The base register was one of the saved registers we forged in the redirected read. Set the base equal to the target and the delta becomes zero, and zero passes every threshold. The check never tested “is this target legitimate”. It tested “does the attacker control the base too”, and after the pivot the answer was permanently yes.
// the homemade forward-edge check, paraphrased
uint64_t delta = ror64((uint64_t)target - cfi_base, 3);
if (delta > 1) __builtin_trap();   // cfi_base comes from a register the attacker now controls
  1. And the check that fell first: SafeStack itself. The mitigation’s own metadata, the pointer it uses to define “where the unsafe stack is”, was stored inside the overflow’s reach. It protected the house and wrote the house’s address on the mailbox.

Crossing the Finish Line

With the indirect call defanged, the final chain reads like a checklist of everything the author added being used against them. The call goes to a short mid-function sequence that ends in a gets on an address derived from our forged register, the write lands on that call’s own return slot, and the ROP chain takes over from there. One chain leaks a libc address through puts, computes the libc base, and a second chain, sent only after the leak is parsed, calls system("/bin/sh").

Shell. With PIE next to it, and Full RELRO next to it, and NX next to it, and a canary next to it, and SafeStack next to it, and a shadow stack next to it, and CFI next to it. The reason none of them mattered isn’t that each was weak. The reason is that they all stood on the same unverified assumption.

Variants and Adjacent Ideas

  • If a guard page had separated the two regions, this exact path would die at the page boundary. You’d be back to hunting for unsafe-stack abuse of a different kind, like pointers stored inside overflowable structs, which SafeStack’s own documentation concedes is out of its scope.
  • Multithreaded targets have one unsafe stack per thread, each paired with that thread’s TLS. Same class of adjacency questions, more threads to ask them about.
  • The layout is not a law of nature. Different libc versions, loaders, and allocation histories place these regions differently, which is precisely why “verify in a debugger” is a step, not a suggestion.
  • Hardware shadow stacks (Intel CET) store return addresses in protected memory this technique cannot write. This specific chain would stall there. The overflow, to be clear, would still exist.

The Actual Fix

// patched: the overflow never exists
void save_note(void) {
    char note[48];
    puts("Provide some input for the buffer.");
    if (!fgets(note, sizeof(note), stdin)) return;   // bounded, and checked
    note[strcspn(note, "\n")] = '\0';
}

One line of commentary: no TLS surgery, no register forgery, no CFI puzzle. The boring fix deletes the entire chain in one function.

The defense list, in order of how much each one actually matters:

  1. Fix the read. Bound every input to its destination. fgets, a length-checked read, std::string, whatever your language offers. “Unbounded” is the vulnerability; everything after it is negotiating with the intruder after they’re already inside.
  2. Treat mitigations as layers, not walls. SafeStack plus canary plus RELRO plus NX is a high wall. It is still a wall, and walls have doors you haven’t found yet.
  3. Audit the layout your mitigations assume. Fifteen minutes in a debugger: print the unsafe stack’s region, print the TLS block, check what separates them. If your safety boundary is “these two mmaps never touch”, go watch them not touch.
  4. Don’t ship homemade CFI predicates. A rotated delta against a register-sourced base is a riddle for the attacker, not a defense for your users. The compiler’s real CFI (-fsanitize=cfi) validates call targets against a precomputed set of legitimate ones, with no attacker-controllable input anywhere in the comparison.
  5. Fuzz with AddressSanitizer in CI. The gets that started this whole story is a one-run ASan finding. Every defense in this article exists to compensate for a bug that a sanitizer would have caught on the first input.

Testing and Audit Points

  • Run checksec and actually read the unusual entries. SafeStack shows up as the __safestack_unsafe_stack_ptr symbol; BSS write patterns at call sites betray shadow stacks; compare-and-trap sequences before indirect calls betray homemade CFI.
  • Grep the disassembly for the unbounded family: gets, strcpy, sprintf, scanf("%s"). Unbounded plus SafeStack is this article.
  • In a debugger, run info proc mappings, find the anonymous region the unsafe stack lives in, and compare it against the thread pointer ($fs_base on x86-64). Adjacent with no gap? You’ve found this article’s premise in your own target.
  • Put a watchpoint on __safestack_unsafe_stack_ptr while feeding oversized input. If the watchpoint fires before any crash, the “separate regions” assumption has already failed you.

Mitigation Checklist

CategoryActionHow to verify
Input handlingBound every read to its destinationGrep for gets/strcpy; fuzz with ASan
Stack defensesSafeStack + canary as layers, not the planchecksec, plus a manual layout audit
Layout auditGap or guard page between unsafe stack and TLSinfo proc mappings vs $fs_base in gdb
Forward-edge CFICompiler-generated set membership, never hand-rolled-fsanitize=cfi; review any custom traps
CISanitizer and fuzz runs on every buildASan reports zero on the fuzz corpus

FAQ

Does ASLR stop this? No. ASLR randomizes where regions land, but the unsafe stack and the TLS block move together, so the distance between your buffer and the pointer you want is a constant. One leak of any code or libc pointer, and every address you need falls out with subtraction.

Is SafeStack useless, then? No, and that’s the wrong lesson. Against a plain smash it’s a genuine wall: the return address isn’t where your overflow is, and the usual chains just die. It failed here because a specific layout violated a specific assumption. Use it, verify its assumptions, and don’t let it make you complacent.

Would a stronger canary have helped? The canary wasn’t weak. Its storage and its verifier were both inside the attacker’s write set, and no secret survives that. The lesson is about the placement of trust data, not the entropy of the value.

What about Intel CET and hardware shadow stacks? They’d have stopped the return-address half of this chain, since hardware shadow stacks live in memory userspace stores can’t touch. The buffer overflow would still exist, and any data-only abuse would still be on the table. Hardware raises the floor; it doesn’t mop up the spill.

Final Thoughts

Every defense in this story was individually reasonable, and the chain worked anyway, because they all trusted the same unverified fact about memory layout. SafeStack’s pointer sat in TLS because TLS is “far from the overflow”. The shadow stack’s copy was authoritative because the copy was “read-only in spirit”. The CFI check was sound because its base register was “internal state”. The moment the assumption underneath them cracked, they stopped being independent walls and became a house of cards standing on the same table.

Go check where your table stands.

The vault never got broken into. The staff just kept setting up wherever the ledger said, and by the end, the ledger was ours.

References

⌘
Suggested Searches