Runtime Defences
You can access the slides 🖼️ for this lecture.
Here we discuss defences running at runtime in production.
Non-Executable Memory
In the past, a large part of the address space used to be accessible with execution right. That was quite detrimental from the security point of view: it meant that an attacker armed with a memory write primitive (e.g., as we have seen following a buffer overflow) could write malicious machine code in a memory area that is both executable and writable, and then have the CPU jump to it: that is called a code injection attack.
In the early 2000s, hardware support appeared to tackle that issue by setting part of the address space as non-executable. Such hardware was used to set everything that should not be accessed in executable mode as non-executable: the stack, heap, static data sections, etc. This can be illustrated as follows:
This is an application of the principle of least privilege, and it made code injection attacks much less likely. Today, setting memory as non-executable is generally achieved through a specific bit in each page table entry, controlling the executable/non-executable status of the memory page it corresponds to: the NX bit.
Today, modern systems software aim to enforce the “write xor execute” (W⊕X) principle for each memory area: that principle states that you cannot have an area of memory be both writable and executable at the same time.
Address Space Layout Randomisation
Another defence that is present in almost every system today is address space layout randomisation (ASLR). With ASLR, each invocation of the program will have a different layout for the address space. In other words, code and variables will not be at the same location in memory for subsequent invocations. The goal is to make it harder for the attacker to determine what is where in the address space. Recall from the attacks we have seen previously that many of them require us to know exactly where a buffer to overflow is present or the stack, or exactly where we need to jump in the code segment. This is achieved by observing one invocation of the program, for example with a debugger, and then starting the attack upon a second invocation of the program. With ASLR that will not be easy to achieve because the locations of the data and code we determine with the first invocation are not the same for the second:
Note that the granularity of ASLR in most production systems is coarse: for performance reasons we cannot really randomise the location of each variable independently. Hence, randomisation is realised at the level of a program’s entire segments, as illustrated above. It is realised at load time for the main program, and when dynamic libraries are loaded.
To understand the security implications of coarse-grained ASLR, consider the following program:
int global1 = 42;
int global2 = 43;
int main() {
int local1 = 24;
int local2 = 25;
int *heap_ptr1 = malloc(sizeof(int));
int *heap_ptr2 = malloc(sizeof(int));
printf("data addr 1: %p\n", &global1);
printf("data addr 2: %p\n", &global2);
printf("stack addr 1: %p\n", &local1);
printf("stack addr 2: %p\n", &local2);
printf("heap addr 1: %p\n", heap_ptr1);
printf("heap addr 2: %p\n", heap_ptr2);
free(heap_ptr1);
free(heap_ptr2);
}
This code simply prints the addresses of two local variables, two global variables, and the value of two heap pointers. We observe that:
- The relative distance between two variables located in different segments is randomised across different invocations of the program.
- The relative distance between two variables belonging to the same segment stays the same across executions.
This is because only the base address of each segment is randomised at load time. As a result, if an attacker can leak the value of a single pointer, it is easy for them to compute the address of all other data or code within the containing segment. Hence, the coarse-grained nature of ASLR on modern systems makes it relatively easy to break.
Stack Canaries
The stack canary is a technique protecting the return address on the stack from being overwritten by attackers. The key idea is to place a magic value named the canary right before the return address in a callee’s stack frame, to compare that canary’s value to a ground truth when the callee returns. In practice, stack canaries work as follows:
At build time, the compiler inserts code to place the canary on the stack right after the return address upon each function call:
In our example the canary’s value is 0x1234.
At build time the compiler also inserts code to check that the canary still contains the correct value upon each function return:
At runtime, a canary value is inserted upon each function call, and its value checked when that function returns. Should an attacker attempt a stack smashing attack through a stack buffer overflow vulnerability as we described previously, the overflow will overwrite the value of the canary.
With the canary overwritten, the check when the function returns will fail, detecting the attack.
By default, with modern compilers only certain functions (declaring a char array > 8 bytes) are protected with canaries.
Use -fstack-protector-strong to apply it to more functions (no size limit), and -fstack-protector-all applies it to all functions.
More canaries will increase the security of your program, but will also increase performance and code size overheads: there is a trade-off to make here between security and code size increase/performance impact.
Canaries are not a perfect protection. With current implementations, the same canary value is used to protect all function calls. This means that if the attacker can leak the canary value, for example if there is an overflow in read mode on the stack, then the protection is broken for the entire program.
Other Common Hardening Techniques
Stripping Symbols. Other common protection techniques include stripping your program from symbols and debug information. This makes reverse engineering your code, which is a crucial step in most attacks as we will see in this unit’s lab exercises, much more difficult. To strip a particular binary use:
$ strip <binary>
Read-only Relocations (RELRO). RELRO protects against attacks using the shared library relocation system (Global Offset Table) to hijack a program’s control flow. Relocations represent the resolution of calls to shared libraries at runtime: you can see these as a form of function pointers that an attacker can try to overwrite. Partial and full RELRO sets part or all of the address space areas holding relocation information as read-only. Here we again have a trade-off between security and performance overhead, as full RELRO will significantly increase load time.
RELRO is enabled at the level of the linker. To enable partial RELRO, pass these flags to the compiler (that will itself pass them down to the linker):
$ gcc program.c -Wl,-z,relro -o program
To enable full RELRO:
$ gcc program.c -Wl,-z,relro,-z,now -o program
_FORTIFY_SOURCE macro.
This macro enables some lightweight compile-time/runtime buffer overflow protection checks before sensitive functions such as strcpy, strcat, etc.
It can be enabled through the compiler invocation
$ gcc program.c -D_FORTIFY_SOURCE=1 -o program
A second level is available (-D_FORTIFY_SOURCE=2).
It enables more checks but may break the program.
checksec.
A binary can be analysed to check for the presence or absence of the hardening techniques we covered here, using the checksec tool.
Here is an example of usage:
$ gcc -fstack-protector-strong -D_FORTIFY_SOURCE=2 -O2 -Wl,-z,relro -Wl,-z,now myapp.c -o myapp
$ strip myapp
$ checksec --file myapp
RELRO STACK CANARY NX PIE Symbols FORTIFY
Full RELRO Canary found NX enabled PIE enabled No Symbols Yes
checksec reports on the presence and status of RELRO, stack canary, NX bit, debug and other strippable symbols, and FORTIFY_SOURCE.
The PIE field here indicates whether the binary is a position independent executable (PIE), a technology that allows loading segments of the binary anywhere in the address space – something required for ASLR.
Control Flow Integrity
Here we cover with a bit more details a last, more advanced, technique, named control flow integrity (CFI). We have seen previously how control flow hijacking attacks such as stack smashing force the program to take illegitimate code paths in the control flow graph, i.e. code paths that were not intended by the programmer when the source code was written. CFI enforces that the code paths executed at runtime by the program conform to the CFG originally intended by the programmer.
CFI generally involves two protections:
- Forward-edge CFI, checking that function pointers and C++ virtual tables always have legitimate targets.
- Backward edge CFI, checking that return addresses also always have legitimate targets.
CFI: Forward Edge Protection. Regarding forward edge protection, CFI enforces that when a function pointer or an entry in a C++ virtual table is called, the target should be a valid function. What valid means here depends on the implementation: with coarse-grained CFI, the protection will just check that the target is the beginning of a function. With fine-grained CFI, the protection will make sure that only the functions whose addresses are assigned to the function pointer or virtual table in the code can be called.
LLVM/Clang has a software implementation of CFI; to enable it, add the following compiler flags:
$ clang -g -fsanitize=cfi -flto -fvisibility=hidden program.c -o program
This will instruct the compiler to insert the necessary instrumentation for CFI checks.
Recent Intel processors also have CFI in hardware through a technology called Intel Control-Flow Enforcement.
A special instruction endbranch64 marks valid targets for indirect branches, such as virtual table member invocations.
CFI: Backward Edge Protection. Regarding backward edge protection, which protects the return address on the stack, this is achieved for CFI with what is called a shadow stack. The shadow stack is a separate stack that stores a copy of the return address upon each function call. When the callee returns, the return address to jump to is checked against the copy in the shadow stack: if they don’t match, it may be indicative of an attack.
Here is an illustration of the shadow stack:
Assume we are running the code of a function f1, we have its frame on the stack, and the shadow stack which is empty for now.
When f1 calls f2, it pushes the return address on the stack normally, but also a copy of it on the shadow stack:
Assume that f2 calls a function f3, which itself calls another function f4.
The process repeats with each function call: the return address is pushed normally on the stack and a copy is placed in the shadow stack.
When f4 runs we have the following:
When f4 returns to f3, the return address on the stack is checked against the corresponding entry in the shadow stack; if they match, all is well.
If, during the execution of f4, an overflow lets the attacker overwrite the return address, the check would fail.
After the check, the return address copy is popped from the shadow stack.
As the program returns from f3 to f2 and then from f2 to f1, similar checks are done.
Of course the shadow stack needs to be placed by the compiler at a location in memory that is very hard for an attacker to access. Clang’s implementation randomises that location and protects it with unmapped guard pages that will fault if accessed, however the shadow stack itself needs to stay readable and writable by the program to perform its function. Hardware shadow stack implementations provide stronger protection, and the shadow stack content can generally not be accessed with standard loads and stores, only with specific shadow stack manipulation instructions.