Advanced Memory Corruption Protections & Techniques#
Introduction#
In the previous text, we walked through some of the fundamental memory corruption vulnerabilities and the basic defenses against them.
Although just the defenses we covered can go a long way in defending from number of attacks, as the complexity of software systems keeps growing and the attackers become smarter and more hungry, the defenses need to get smarter too.
In this text we cover some of the more advanced and recent mitigations, as well as newer attack techniques.
FORTIFY_SOURCE in Compilers#
FORTIFY_SOURCE is a compiler feature that tries to fix some of the functions with broken size-unaware APIs available in libc. It does it by inserting additional checks to the functions and by keeping track of buffer sizes, when it can.
When you enable FORTIFY_SOURCE, the compiler replaces calls to potentially dangerous functions with safer versions that include bounds checking. For example, the strcpy in:
char buffer[10];
strcpy(buffer, user_input);becomes __strcpy_chk(buffer, user_input, 10).
The __strcpy_chk variant knows the size of the destination buffer (which the compiler assumed from the buffer declaration) and will abort the program if the source string is too long. This protection is added to functions working with memory - memcpy, memset, memmove, strings - strcpy, strcat, sprintf, input/output - gets, fgets, read, write, etc.
It can be enabled with the -D_FORTIFY_SOURCE=2 compiler flag, which enables only “conservative” protections, where the size of the buffer is known at compile time.
However, code like this remains vulnerable:
void fill_buffer(char *ptr) {
strcpy(ptr, "Hello, pitfalls, this text is long");
}
void main() {
char *buf = malloc(10);
fill_buffer(buf);
}_FORTIFY_SOURCE=3 tries to fix this limitation by passing the buffer size alongside the pointer. Which seems nice, but can degrade performance and bring other unexpected consequences. Unfortunately, it’s not magic.
Runtime Sanitizers in Compilers#
Although sanitizers are mostly used only for debugging (and security testing :)) and not as a production protection, they are so powerful and useful we wanted to mention them here.
AddressSanitizer (ASAN)#
AddressSanitizer (enabled with -fsanitize=address) is perhaps the most useful of them. It detects:
- buffer overflows (stack and heap)
- usage of data that the given code doesn’t own anymore - use-after-free, but also accessing local variables from a function after the function had returned etc.
- double-free
- memory leaks - buffers without any reference to them
ASAN instruments every memory access in your program. It maintains “shadow memory” that tracks which memory is valid to access, in which for every 8 bytes of application memory are represented as 1 byte. Valid memory is marked as accessible, freed memory as poisoned, “redzone” memory as poisoned.
Before every memory access the shadow memory is checked whether the wanted memory location is accessible, if not, the execution aborts.
Redzones are inserted around allocations to detect overflows:
[Redzone | buffer that is used | Redzone]If we try to execute the UAF code from the previous lecture with ASAN enabled, we get this abort from ASAN:
=================================================================
==336533==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c20b83e0080 at pc 0x7fc0b9a75d31 bp 0x7ffe0ecc4040 sp 0x7ffe0ecc3810
READ of size 2 at 0x7c20b83e0080 thread T0
#0 0x7fc0b9a75d30 (/lib64/libasan.so.8+0x75d30) (BuildId: cbfe49f3b7600c4f194d4c54774c977296e9d98a)
#1 0x0000004011e9 in main (/home/sijisu/Documents/cuni/pitfalls/pitfalls-demos/07/uaf+0x4011e9) (BuildId: 5f3c3bdf9054a13479a5577ca8871b1c0d193263)
#2 0x7fc0b962b2fa in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7fc0b962b3ca in __libc_start_main_impl ../csu/libc-start.c:360
#4 0x0000004010d4 in _start ../sysdeps/x86_64/start.S:115
0x7c20b83e0080 is located 0 bytes inside of 64-byte region [0x7c20b83e0080,0x7c20b83e00c0)
freed by thread T0 here:
#0 0x7fc0b9b208eb (/lib64/libasan.so.8+0x1208eb) (BuildId: cbfe49f3b7600c4f194d4c54774c977296e9d98a)
#1 0x0000004011dd in main (/home/sijisu/Documents/cuni/pitfalls/pitfalls-demos/07/uaf+0x4011dd) (BuildId: 5f3c3bdf9054a13479a5577ca8871b1c0d193263)
#2 0x7fc0b962b2fa in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
previously allocated by thread T0 here:
#0 0x7fc0b9b21c2b in malloc (/lib64/libasan.so.8+0x121c2b) (BuildId: cbfe49f3b7600c4f194d4c54774c977296e9d98a)
#1 0x0000004011b7 in main (/home/sijisu/Documents/cuni/pitfalls/pitfalls-demos/07/uaf+0x4011b7) (BuildId: 5f3c3bdf9054a13479a5577ca8871b1c0d193263)
#2 0x7fc0b962b2fa in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
SUMMARY: AddressSanitizer: heap-use-after-free (/home/sijisu/Documents/cuni/pitfalls/pitfalls-demos/07/uaf+0x4011e9) (BuildId: 5f3c3bdf9054a13479a5577ca8871b1c0d193263) in main
Shadow bytes around the buggy address:
0x7c20b83dfe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7c20b83dfe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7c20b83dff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7c20b83dff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x7c20b83e0000: fa fa fa fa 00 00 00 00 00 00 05 fa fa fa fa fa
=>0x7c20b83e0080:[fd]fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
0x7c20b83e0100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7c20b83e0180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7c20b83e0200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7c20b83e0280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x7c20b83e0300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==336533==ABORTINGAs you might have guessed, ASAN slows down programs significantly and uses ~doubles the memory use. However, this is usually a small price to pay for finding a hard to get bug when debugging or testing.
Again, unfortunately this is not magic, so it can make mistakes.
UndefinedBehaviorSanitizer (UBSan)#
UBSan (enabled with -fsanitize=undefined) detects undefined behavior in C/C++ including integer overflow/underflow, division by zero, null pointer dereference, and so on.
Compiling and running this code:
int overflow() {
int x = INT_MAX;
return x + 1; // UBSan will catch this
}will result in:
main.c:5:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'Other Sanitizers#
Other useful sanitizers include MemorySanitizer (-fsanitize=memory) which detects reads of uninitialized memory, and ThreadSanitizer (-fsanitize=thread) which detects data races in multithreaded programs.
Hardware Security Features#
Modern CPUs include hardware features specifically designed to make exploitation harder. These features provide security guarantees that are difficult or impossible to achieve purely in software.
Memory Tagging (MTE)#
ARM’s Memory Tagging Extension (MTE), introduced in ARMv8.5-A, is a hardware feature that tags memory allocations and pointers with small (4-bit) tags. Every 16-byte aligned block of memory has a 4-bit tag stored separately in memory. Pointers also contain a 4-bit tag in their upper bits (which are normally unused on 64-bit systems). When accessing memory, the CPU checks that the pointer tag matches the memory tag.
Pointer: [Tag (4 bits) | Address (60 bits)]
Memory: [16 bytes of data] → Associated with 4-bit tagWhen memory is allocated, both the pointer and the memory region get a random tag. When the memory is freed and later reallocated, it gets a different tag. This makes use-after-free bugs detectable because the old pointer has a different tag than the reallocated memory, causing a crash when accessed. MTE also helps detect buffer overflows that cross into adjacent allocations with different tags, and double-free attempts.
However, MTE doesn’t protect against overflows within the same allocation (same tag), and there’s a 1/16 chance tags match by coincidence.
MTE is available, for example, on Google Pixel 8 and newer, Apple iPhone 17 and newer, with upstream Linux kernel support.
ARM Pointer Authentication (PAC)#
ARM Pointer Authentication Codes (PAC) cryptographically signs pointers to detect tampering. All possibly targeted values in memory (like pointers, the return address…) are stored there only signed, meaning the attacker cannot easily figure them out in advance. The signature (PAC) is computed using the pointer value itself, a secret key that’s different per process, and a context value like the stack pointer to prevent reuse. Before using the pointer, the signature is verified – if the pointer was modified, verification fails and the program aborts.
This effectively protects return addresses (preventing ROP and return-to-libc), function pointers, vtables in C++ virtual function tables, and other sensitive pointers. However, it can be bypassed if an attacker can forge valid PACs - either if the key is leaked or a suitable signing gadget is present in the code.
ARM PAC is available in ARMv8.3-A and newer processors like Apple M1/M2 etc. Apple uses it extensively on iOS and macOS.
Intel Control-flow Enforcement Technology (CET)#
Intel CET is a set of hardware features designed to prevent control-flow hijacking attacks through two main components: Shadow Stack and Indirect Branch Tracking (IBT).
The Shadow Stack is a hardware-enforced second stack that stores only the return addresses and not data. On every return the address to be returned to from the normal stack is verified against the return address from the shadow stack. If there is a mismatch, the CPU knows something funky is happening. This makes exploiting stack buffer overflows with i.e. ROP, hard.
Indirect Branch Tracking protects indirect jumps like function pointers and jump instructions with register operands (jmp rax…). It works by marking all the intended jump destinations with the ENDBR64 (End Branch 64-bit) instruction. When an indirect jump occurs, the CPU checks that the target starts with ENDBR64 – if not, it crashes. This makes control flow hijacking and even ROP harder, because all gadgets now need to start with ENDBR64.
Intel CET is available on Intel Core processors since the 11th generation and is supported by both Linux and Glibc. Support from all hardware, OS and binary is required for CET to be enabled. Compile with -fcf-protection=full to get both shadow stack and IBT protection. Windows also uses CET in its kernel.
“So with all those new shiny protections binary security is solved, right?”#
Well, it’s not that easy. Although these hardware and software mitigations make exploitation significantly more difficult, they do not make it impossible.
First, these protections are not universally deployed. Getting hardware, OS, and software all in sync to support features like MTE or CET requires effort. Older systems lack the necessary hardware support entirely. Embedded devices are particularly problematic – they often run outdated software on CPUs without modern security features, and updating them is difficult or impossible. Even on systems with modern hardware support, low-level code often operates in environments where these protections don’t apply or must be disabled for performance or compatibility reasons, leaving critical components without them. And many impactful bugs lie in that area.
Second, even with all protections enabled, skilled attackers continue to find ways to bypass them. Exploits for mobile devices with these protections are among the most valuable to threat actors, which demonstrates both the effectiveness of the mitigations and the determination of attackers to overcome them. To see how sophisticated attackers chain multiple vulnerabilities and bypass protections like MTE and PAC, we recommend reading in-the-wild exploit write-ups like this analysis of BLASTPASS exploit targeting iPhones.
Fuzzing#
Sanitizers, like ASAN, are most effective when combined with fuzzing – automatically feeding programs with malformed inputs to trigger bugs. Modern fuzzers track code coverage and intelligently mutate inputs to explore new execution paths.
AFL++ (American Fuzzy Lop) and LibAFL are among the most popular fuzzers.
Although it might seem too dumb to work, fuzzing really does work very well to find bugs. From finding bugs in games, to finding bugs in major open source projects or syscall handling in the Linux kernel.
Exploitation process#
Modern exploitation often requires chaining multiple vulnerabilities together in a sophisticated attack chain. A single buffer overflow is rarely enough anymore – modern defenses require attackers to be much more creative.
A typical modern exploit might follow this pattern:
-
Information Leak: First, the attacker needs to defeat ASLR by leaking memory addresses. This might be done through format string bugs, out-of-bounds reads, or other vulnerabilities that disclose memory contents. Once the attacker knows where libraries and code are located in memory, they can construct their attack.
-
Memory Corruption: The attacker uses a memory corruption vulnerability (buffer overflow, use-after-free, etc.) to gain control over program execution. This might involve overwriting function pointers, vtable entries, or return addresses.
-
Bypassing Control-Flow Protections: With DEP, stack canaries, and potentially CET or PAC in place, the attacker cannot simply inject shellcode or use straightforward ROP. They may need to:
- Consider manipulating just data rather than control flow
- Locate authenticated pointers or signing gadgets (for PAC)
- Find valid gadgets that start with
ENDBR64(for CET) - Chain together valid function calls in unintended ways
-
Achieving Code Execution: Finally, the attacker might be able to achieve arbitrary code execution, often by calling existing functions like
system(),execve(), or by building a ROP chain that callsmprotect()to make memory executable.
Real world exploits, especially those targeting mobile devices or modern systems, often chain together 3-5 different vulnerabilities to achieve their goal. This is why exploits for fully-patched iPhones or Android devices with all security features enabled can be worth millions of dollars – they represent months of research and sophisticated engineering to bypass multiple layers of defense.
TLDR#
Advanced Compiler Protections:
- FORTIFY_SOURCE: Runtime bounds checking for standard library functions
- Sanitizers (ASAN, UBSan, MSan): Powerful runtime bug detection for development/testing
- ASAN detects buffer overflows, use-after-free, double-free
- UBSan detects undefined behavior like integer overflow
- often used with fuzzing to find bugs automatically
Hardware Security Features:
- Memory Tagging (ARM MTE): Tags memory and pointers with 4-bit tags, detects use-after-free and some overflows
- Pointer Authentication (ARM PAC): Cryptographically signs sensitive pointers like return addresses
- Shadow Stack (Intel CET): Hardware-protected second stack for return addresses, breaks traditional ROP
- Indirect Branch Tracking (CET-IBT): Validates indirect jump targets with
ENDBR64instructionReal world exploits:
- Modern exploits chain multiple vulnerabilities to bypass layered defenses
- Typical chain: information leak (defeat ASLR) -> memory corruption -> bypass control-flow protections -> code execution
- Even with all protections, sophisticated exploits for modern systems (iOS, Android) remain possible but extremely valuable
- Defense in depth: multiple layers make exploitation exponentially harder, not impossible