Skip to content

4. Mitigating Memory-Safety Vulnerabilities

Use a Memory-Safe Language

Just use Java, Python, Go, Rust, Swift, etc. They do compile-time and runtime checks that prevent memory errors from occuring. Boom, done.

Writing Memory-Safe Code

In code, write your pre-conditions and post-conditions for every function you write and using invariants to prove that these conditions are satisfied.

Defensive programming: always add checks in your code just in case something could go wrong.

  • Always check that a pointer is not null before dereferencing it, even if you are sure that the pointer is always going to be valid

Use safe libraries, which use functions that check bounds so you don't have to.

  • fgets rather than gets

  • strncpy rather than strcpy

  • snprintf instead of sprintf

Building Secure Software

Use tools to analyze and patch insecure code.

  • Run-time checks that do automatic bound-checking

  • On check failure, direct it towards a controlled crash, ensuring the attacker does not succeed

  • Hire someone to look over your code for memory safety errors

  • Probe your own system for vulnerabilities

  • Fuzz test (test with random inputs), corner cases, Valgrind

Exploit Mitigations

Code hardening defenses are mitigations: they try to make common exploits harder and cause exploits to crash instead of succeeding, but they are not foolproof.

The only way to prevent all memory safety exploits is to use a memory-safe language.

Mitigation: Non-Executable Pages

A common buffer overflow involves the attacker to write some machine code into memory, and then redirect the program to execute the injected code.

[shellcode] + [4 bytes of garbage] + [address of buf]

To defend: make some portions of memory non-executable. This means that the computer should not interpret any data in these regions as CPU instructions. You can think of it as not allowing the eip to ever contain the address of a non-executable part of memory.

Since modern systems separate memory into pages, make each page of memory either writable or executable, not both.

It defends against stack smasking attacks

Subverting Non-Executable Pages: Return Into libc

Non-executable pages do not stop an attacker from executing existing code in memory. C programs have libraries of up to million of lines. All these instructions are marked as executable (and non-writable), since the programmer may want to call these functions legitimately.

Attackers can just overwrite the rip with the address of a C library function.

  • execv function lets the attacker start executing the instructions of some other executable. It takes the string of the filename of the program to execute. Put what you want to run on the stack. pwned.

Subverting Non-Executable Pages: Return-Oriented Programming

Return-oriented programming is a technique that overwrites a chain of return addresses starting at the rip in order to execute a series of "ROP gadgets" which are equivalent to the desired malicious code.

We create a custom shellcode using pieces of code that already exist in memory. Instead of executing an existing function, like we did with "return to libc", with ROP you can execute your own code by simply executing different pieces of different code.

If we wanted to add 4 to the value currently in the edx register as part of a larger program. In loaded memory, we have the following functions:

foo:
    ...
    0x4005a1 <foo+33> mov %edx, %eax
    0x4005a3 <foo+35> leave
    0x4005a4 <foo+36> ret
    ...
bar:
    ...
    0x400604 <bar+20> add $0x4, %eax
    0x400608 <bar+24> pop %ebx
    0x40060a <bar+26> leave
    0x40060b <bar+27> ret

To emulate the add $0x4, %edx instruction, move the value in edx to eax using the gadget in foo then add 4 to eax using gadget in bar.

Therefore set the first return address to 0x004005a1 and the second return address to 0x00400604 to produce the desired result. Every time we jump to ROP gadget, we eventually execute the ret instruction and then pop the next return address off the stack, jumping to the next gadget. We just have to keep track that our desired value is now in a different register, and because we execute a pop %ebx instruction in bar before we return, we also have to remember that the value in ebx has been updated after executing these gadgets—but these are all behaviors that we can account for using standard compiler techniques. In fact, so-called “ROP compilers” exist to take an existing vulnerable program and a desired execution flow and generate a series of return addresses.

General strategy:

  • Write a chain of return addresses at the rip to achieve the behavior that we want. Each return address should point to a gadget, which is a small set of assembly instructions that already exist in memory and usually end in a ret instruction. The gadget then executes its instructions and ends with a ret instruction, which tells the code to jump to the next address on the stack, thus allowing us to jump to the next gadget!

There are usually enough gadgets in memory for you to be able to run any shellcode you want. ROP compilers exist that will automatically generate a ROP chain for you based on a target binary and desired malicious code.

Mitigation: Stack Canaries

When we call a function, the compiler places a known dummy value, the stack canary, on the stack. This canary value is not used by the function at all, so it should stay unchanged throughout the duration of the function. The compiler will then check that the canary value has not been changed when the function returns. If canary changes, crash the program, since that is evidence something bad has happened.

Stack canary uses the fact that many common stack smashing attacks involve overflowing a local variable to overwrite sfp and rip directly above. If an attacker starts writing at a buffer and wants to overwrite the rip, then they must overwrite everything in between the buffer and the rip.

The stack canary is a random value generated at runtime. It is 1 word long. Stack canaries are usually guaranteed to contain a null byte (the first one usually). This lets the canary defend against string-based memory safety exploits, such as vulnerable calls to strcpy that read or write values from the stack until a null byte is encountered.

The canary value changes each time the program is run.

Subverting Stack Canaries

Many exploits that the stack canary cannot detect:

  • Stack canaries can't defend against attacks outside of the stack, such as vulnerable heap memory

  • Stack canaries don't stop an attacker from overwriting other local variables. If there is an authenticated flag in the stack, we can still attack that

  • Some exploits can write to non-consecutive parts of memory, such as format string vulnerabilities can let an attacker write directly to the rip without having to overwrite everything between a local variable and the rip, thus writing around the canary.

Other techniques for defeating the stack canary:

  • Guessing the canary. There are usually 24 bits of entropy (randomness) Let's say the attacker is lucky and runs it \(2^{24}\) times. In a 32-bit architecture, if every attempt took 1 second, it would take over 100 days to succeed (on 64-bit, it would take 2 million years if they did 1k attempts per second).

  • Leak the canary: sometimes the program has a vulnerability that allows the attacker to read parts of memory, like format string vulnerabilities. If we leak the value, write it down, and then inject an exploit that overwrites the canary with its leaked value, all under a single run, we can pwned the system.

Mitigation: Pointer Authentication

Pointer authentication takes advantage of the fact that in a 64-bit architecture, many bits of the address are unused. A modern CPU might support a 4 TiB space, which needs 42 bits to address all of memory. There is then a lot of unused address space. Idea: consider using these unused bits to store a secret like the stack canary. Replace the 22 unused bits with some secret value, known as pointer authentication code (PAC) before pushing the value on the stack.

If the rip of the function in a 64-bit system is 0x0000001234567899. Address space is 40bits, and so the top 24 bits are always 0. Instead of pushing this address to the stack. Replace the unused bytes with PAC. However, this will make dereferencing the address invalid. Therefore, CPU will check the PAC is unchanged. If correct, CPU replaces the secret with the original unused bits to make the address valid again.

Let's make it better and have the CPU use a different PAC for every pointer stored on the stack with some special math to determine them on the fly.

Let's say we have \(f(key, address)\), which outputs a PAC by performing some operation on these two inputs. The function will be deterministic. The function is also secure: an attacker who doesn't know the value of key cannot output secret values of their own.

With PAC enabled, an attacker is never able to overwrite pointers on the stack without generating the corresponding secret for the attacker's malicious address.

To subvert this, we might have to find a separate vulnerability in the program that allows the attacker to trick the program into creating a validated pointer. Or, they could try to discover the key.

Mitigation: Address Space Layout Randomization

ASLR is a mitigation that tries to make predicting addresses in memory more difficult. Each time the program is run, the beginning of each section of memory is randomly chosen. If the program imports libraries, randomize the starting addresses of each library's source code.

ASLR causes absolute addresses of variables, save registers, and code instructions to be different each time the program is run. Therefore, the attacker must guess the address of their own malicious instructions.

  • Attacker cannot place shellcode on the stack without knowing the address of the stack

  • Attacker cannot place shellcode on the heap without knowing the address of the heap

  • The attacker cannot construct an ROP chain or a return-to-libc attack without knowing the address of the code

Some constraints of ASLR:

  • Segments need to start at a page boundary. In other words, the starting address of each section of memory needs to be a multiple of page size (typically 4096 bytes in a 32-bit architecture)

Subverting ASLR

Two ways to subvert the ASLR

  • Guess the address.

  • Leak the address: sometimes the program has a vulnerability that allows the attacker to read parts of memory. For example, format string vulnerabilities. The stack often stores absolute addresses, such as pointers and saved registers. If the attacker can leak an absolute address, they may be able to determine the absolute address of other parts of memory relative to the absolute address they leaked.

ASLR randomizes absolute addresses by changing the start of sections of memory, but it does not randomize the relative addresses of variables.

Combining Mitigations

Let's now just use multiple mitigations to force the attacker to find multiple vulnerabilities to exploit the program. This process is known as synergistic protection, where one mitigation helps strengthen another mitigation. Combining ASLR and non-executable pages results in an attacker not being able to write their own shellcode, because of non-executable pages, and not being able to use existing code in memory, because they don't know the addresses of that code.

To defeat ASLR and non-executable pages, the attacker needs to find two vulnerabilities: find a way to leak memory and reveal the address location to defeat ASLR, and then find a way to write to memory and write an ROP chain.