로그인 / 등록 Account

Many available exploits are based on techniques such as Return Oriented Programming and Shell Code Injection and execution, as well as other ways an attacker can gain control and subvert the expected execution flow from an application’s execution flow. These exploits aren't novel, nor are techniques to try to prevent them. Compilers deploy a wide range of hardening at compilation-time to mitigate the risk of arbitrary code execution. These include Stack Canaries, Stack-clash protection, and FORTIFY_SOURCE as examples of builtin protections against stack corruption and control flow integrity.

With that in mind we are going to have a look at the Control-Flow Integrity (CFI) protection implemented by the Clang compiler for x86_64 architecture.

What is CFI?

CFI, or Control-flow integrity, may refer to any mechanism which tries to ensure the execution flow is valid when calling or returning from functions during the software’s runtime. Some of the hardenings mentioned earlier in the introduction can be considered CFI protection. Most of them are implemented by both GCC and Clang, however Clang also has the more sophisticated ability to validate forward-edge function calls during runtime. This means the generated code restricts the control-flow only to valid execution traces, making arbitrary code execution much more difficult.

How Clang’s CFI works

In a nutshell, clang extracts the control-flow graph (CFG) during compile-time to determine what functions legitimately call each other. This information is then used to generate code validating the function calls represented by call or callq instructions. At runtime, calls are validated against this information, and on detecting an illegal call or return, program execution is interrupted to avoid an attacker subverting the program’s control flow.

Not all execution traces may be extracted by static analysis; in some scenarios, parts of the execution trace can only be determined at runtime. Calls to virtual functions or class casting in software that were written in C++, as an example, can only be determined during execution. For these cases clang relies on LTO (link-time optimization) information. To compile a code with CFI support one necessarily needs to compile it with LTO enabled.

Example analysis

Let’s consider a simple code to make it more comprehensible:

 1 #include <stdio.h>                      
 2 #include <string.h>                     
 3                                         
 4 #define AUTHMAX 4                       
 5                                         
 6 struct auth {                           
 7  char pass[AUTHMAX];                
 8  void (*func)(struct auth*);        
 9 };                                      
10                                         
11 void success() {                        
12  printf("Authenticated successfully\n");
13 }                                       
14                                         
15 void failure() {                        
16  printf("Authentication failed\n");  
17 }                                       
18                                         
19 void auth(struct auth *a) {             
20  if (strcmp(a->pass, "pass") == 0)  
21      a->func = &success;            
22  else                               
23      a->func = &failure;            
24                                         
25 }                                       
26                                         
27 int main(int argc, char **argv) {       
28  struct auth a;                     
29                                         
30  a.func = &auth;                    
31                                         
32  printf("Enter your password:\n");  
33  scanf("%s", &a.pass);              
34                                         
35  a.func(&a);                        
36 }          

We have an auth data structure which holds a password and a pointer to a function which will handle the authentication function itself. In the main() function the password is read from stdin via a scanf() call into the pass member, which is a four byte char array.

The lack of input sanitization allows us to overwrite the function pointer set at line 30. An attacker can leverage this weakness by setting the pointer to the function address which confirms our login process, in this case success(). This would cause the expected control flow to be diverted and controlled by us when the func pointer is dereferenced at line 35.

First we are going to compile and execute the code above without CFI support, trying to hijack the execution flow to force a login even with the incorrect password.

Compiling without CFI support:

clang -o password_nocfi password.c     

Exploiting the vulnerability and taking the execution flow

During this step a little binary analysis is needed to determine that the success() function is located at memory address 0x400680. Crafting an input allows us to correctly set the a.func pointer to the memory position we want:

$ perl -e 'print "a"x8 .  "\x80\x06\x40"' | ./password_nocfi
Enter your password:
Authenticated successfully

The Perl script produces a sequence of eight characters: four of them fit into the password’s buffer while the other four are padding, and then success() function’s address. It clearly bypasses the password checking with the wrong password.

Let’s try again, but now compiling with CFI support:

clang -o password password.c -flto -fsanitize=cfi -fvisibility=default

Using the same input as before:

$ perl -e 'print "a"x8 .  "\x80\x06\x40"' | ./password
Enter your password:
Illegal instruction (core dumped)

The program was interrupted by an Illegal instruction exception. Looking at the code generated will explain this behavior:

-0x8(%rbp) contains the a.func pointer value, so it’ll be moved to %rax register
 400743:    48 8b 45 f8         mov -0x8(%rbp),%rax    

It also loads the auth() address into %rcx register, this information was generated at compile-time based on the CFG.

 400747:    48 b9 70 07 40 00 00 movabs $0x400770,%rcx  
 40074e:    00 00 00                                      

Then it compares both addresses, if they are equal it then jumps to the right address and calls the function stored at the address described by the pointer. Otherwise it’ll execute an undefined instruction (ud2) which will cause the program to abort, avoiding the illegal behaviour introduced by the attacker. 

 400751:    48 39 c8            cmp %rcx,%rax      
 400754:    74 02               je 400758 <main+0x58>
 400756:    0f 0b                           ud2                   
 400758:    48 8d 7d f0         lea -0x10(%rbp),%rdi   
 40075c:    ff d0                           callq  *%rax          
 40075e:    31 c0               xor %eax,%eax      
 400760:    48 83 c4 20         add $0x20,%rsp     
 400764:    5d                              pop %rbp           
 400765:    c3                              retq                  

Summary

In the section above we showed the benefits of Clang’s Control-Flow Integrity support. It can successfully detect and stop an attempt of control flow hijack caused by a stack-based buffer overflow and immediately terminate the program’s execution.

Enabling CFI is highly recommended for all programs that process untrusted input or sit at a security boundary. It provides a very simple and effective security hardening for a very common class of software vulnerabilities, with only a tiny impact to performance.

It’s recommended developers activate CFI support whenever a user has full control of an input which may lead to overflows, any possibility of code injection, or changes in function’s return address as a very simple and effective security hardening.

A few constraints

Currently, for x86_64 architecture, LLVM can only validate forward-edge control flow, thus function return (backward-edge) is not checked. Given CFI requires the software to be compiled with the LTO option, this may cause some issues when compiling software linked with shared libraries in some cases.

GCC support for CFI

Currently gcc only supports CFI enforced by hardware mechanisms, such Intel’s CET or ARM’s PAC technologies.

featured in these channels:Security


About the author

Marco Benatto is a Senior Product Security Engineer at Red Hat and experienced Linux developer who has worked with several userspace applications and is currently focused on kernel-space and filesystems, with a background in memory management.