The Synopsys Software Integrity Group is now Black Duck®. Learn More

close search bar

Sorry, not available in this language yet

close language selection

CyRC Case Study: Exploitable memory corruption using CVE-2020-25669 and Linux Kernel

Cybersecurity Research Center

Aug 17, 2022 / 11 min read

Aims and challenges of memory corruption research

One of the primary goals of the Black Duck Cybersecurity Research Center (CyRC) is to determine the extent to which vulnerabilities can be exploited. Publicly available advisories often describe the impacts of vulnerabilities in general terms, with boilerplate scores applied to vulnerabilities based on their type. Memory corruption bugs, however, are challenging to exploit on modern systems due to exploit mitigation mechanisms such as Address Space Layout Randomization and Pointer Authentication. Proof-of-concept “exploits” that are included in the advisories for memory corruption vulnerabilities will usually cause the affected application to crash, demonstrating an availability impact. Proof-of-concept exploits that demonstrate more severe impacts, such as arbitrary code execution, are rarer. 

In the modern world, advanced threat actors are a fact of life—a fact many of our customers are all too familiar with. Many software bugs, especially those involving memory corruption, can be leveraged in ingenious ways to achieve the total subversion of the target application. Determining the level of exploitability of specific vulnerabilities is an art shrouded in mystery to the untrained eye. In the course of our work, we often find vulnerabilities for which our team’s knowledge and experience cast doubt on their reported impacts. 

This blog post highlights an interesting vulnerability and some of our analysis methods. Vulnerability CVE-2020-25669 involves a memory corruption issue within the Linux kernel. One of the most important factors with regards to the kernel’s stability and security is the safe management of dynamically allocated memory resources. It is very important that objects that exist in dynamically allocated portions of memory are not accessed by a kernel component once it has released that portion of memory for use by other kernel components. To do so constitutes a so-called use-after-free event. Use-after-free bugs can have very serious security implications, as attackers can leverage them for powerful exploit primitives.

Summary of CVE-2020-25669

On November 5, 2020, an advisory was published on the oss-security mailing list detailing a vulnerability within the Linux kernel’s Sun Microsystems keyboard driver (located at drivers/input/keyboard/sunkbd.c). This advisory, which includes a proof-of-concept exploit, describes how a use-after-free event can occur in the sunkbd_reinit() function. The proof-of-concept exploit reliably triggers the use-after-free event but does not demonstrate any impact beyond a potential denial of service. Some of the publicly available advisories that describe CVE-2020-25669 state that the vulnerability could potentially be leveraged to achieve privilege escalation, but this guidance appears to have been issued on general terms. (As previously stated, this is common with advisories that discuss memory corruption vulnerabilities.) Until recently, there were not any publicly available resources about how the vulnerability could be exploited to carry out such an attack. Now though, CyRC has demonstrated that an attacker in possession of a suitable reallocation gadget could leverage this vulnerability to gain control of the instruction pointer. A local attacker could potentially use this execution redirection primitive as part of a chain to construct a full-fledged privilege escalation exploit.

Vulnerable code analysis

The Sun Microsystems keyboard driver uses a structure (struct sunkbd) to describe a keyboard device. Instances of this structure are used to store information relating to each device’s state. The structure is defined as follows:


struct sunkbd { 
unsigned char keycode[ARRAY_SIZE(sunkbd_keycode)]; 
   struct input_dev *dev; 
   struct serio *serio;       
  struct work_struct tq;       
  wait_queue_head_t wait;
  char name[64]; 
  char phys[32];       
  char type;     
  bool enabled;      
 volatile s8 reset;      
 volatile s8 layout; 
};

The sunkbd_connect() function allocates instances of this structure within the kernel heap by invoking kzalloc():

sunkbd = kzalloc(sizeof(struct sunkbd), GFP_KERNEL);

As you can see, the sunkbd->tq member is of type struct work_struct. This structure is used to perform deferred work within the kernel. “Deferred work” refers to code that will be executed at some point in the future. A range of mechanisms are available within the kernel for performing deferred work. These deferred work mechanisms are required by various components of the kernel, with one particularly important component being interrupt handlers. Deferred work is vital to the implementation of interrupt handlers due to the constraints that these handlers are subjected to. One such constraint is that interrupt handlers cannot execute code that might sleep, because interrupt handlers execute in interrupt context instead of process context. If an interrupt handler needs to execute code that might sleep, a workqueue can be used. These are deferred work mechanisms that will cause the deferred code to be executed in process context. The function to be executed in process context can be associated with a work_struct instance whenever it is initialized. In this case, that task is performed in sunkbd_connect():


INIT_WORK(&sunkbd->tq, sunkbd_reinit);

The actual scheduling of this work is performed in sunkbd_interrupt() whenever its data argument matches SUNKBD_RET_RESET and the keyboard device is enabled:

case SUNKBD_RET_RESET:
              if (sunkbd->enabled)
                  schedule_work(&sunkbd->tq);
                   sunkbd->reset = -1;
                break;

This will add the work to the global workqueue, and sunkbd_reinit() will eventually be executed via a kernel worker thread. It is within the sunkbd_reinit() function that the use-after-free event will occur. Whenever the function is invoked, it is passed a pointer to the struct work_struct instance that was used to schedule it. The first action performed by sunkbd_reinit() is to obtain a pointer to the struct sunkbd instance that contains this struct work_struct instance:

static void sunkbd_reinit(struct work_struct *work){
     struct sunkbd *sunkbd = container_of(work, struct sunkbd, tq);

The function will then call wait_event_interruptible_timeout():

wait_event_interruptible_timeout(sunkbd->wait,sunkbd->reset >= 0 || !sunkbd->enabled, HZ);

This will put the worker thread to sleep if the condition sunkbd->reset >= 0 || !sunkbd->enabled is true. The third argument specifies how long the worker thread will sleep. In this case, a hardcoded sleep duration of one second has been specified. The sunkbd_reinit() function will reawaken after one second and will continue executing the code that follows the call to wait_event_interruptible_timeout(). The first line of this code is an invocation of serio_write().

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);

How a use-after-free event can occur

We will examine serio_write() momentarily; let us first examine how the use-after-free event can occur. Recall that the first action performed by sunkbd_reinit() is to obtain a pointer to a struct sunkbd instance, which will be stored in the sunkbd variable. We have seen that this instance resides within heap memory. If that memory is freed whenever sunkbd_reinit() is sleeping, a use-after-free event will occur whenever sunkbd_reinit() awakens and attempts to dereference the pointer stored in sunkbd. The memory that sunkbd points to can be freed via the sunkbd_disconnect() function. This function is used to deregister a given keyboard device. This involves releasing the resources allocated for that device. The definition of this function is as follows:


static void sunkbd_disconnect(struct serio *serio)
{
struct sunkbd *sunkbd = serio_get_drvdata(serio);     
sunkbd_enable(sunkbd, false);       
input_unregister_device(sunkbd->dev);      
 serio_close(serio);       
serio_set_drvdata(serio, NULL);      
kfree(sunkbd);
}

As you can see, the final action performed by this function is to free the sunkbd object. If sunkbd_disconnect() is invoked whenever sunkbd_reinit() is sleeping, and it deregisters the device that the sunkbd variable refers to, a user-after-free event will occur whenever sunkbd_reinit() awakens. This is precisely how the publicly available proof-of-concept exploit operates. The hardcoded sleep period of one second that is specified within sunkbd_reinit() provides a large and dependable race window. This makes the proof-of-concept reliable.

How to achieve arbitrary code execution

So how can this vulnerability be leveraged to achieve arbitrary code execution? Let us return to the line of code that is executed once sunkbd_reinit() awakens:


serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);

This function call involves dereferencing sunkbd in order to retrieve the value of sunkbd->serio. If sunkbd references freed memory, a fault will occur. Under normal circumstances, however, sunkbd->serio denotes a valid pointer to a struct serio instance. If we examine the definition of this structure, we will see that it contains numerous functions pointers:

struct serio {       
void *port_data;       …
int (*write)(struct serio *, unsigned char);
int (*open)(struct serio *);
void (*close)(struct serio *);       
int (*start)(struct serio *);       
void (*stop)(struct serio *);       …

The definition of serio_write() is as follows:

static inline int serio_write(struct serio *serio, unsigned char data){
   if (serio->write)            
      return serio->write(serio, data);      
   else            
     return -1;}

The function tests whether the serio->write function pointer has been set. If it has, the function that it points to will be called.

Instruction-level analysis

Let’s now examine sunkbd_reinit() within a debugger to see how the call to serio_write() is implemented at the instruction level.

Dump of assembler code for function sunkbd_reinit:

  push   r12        
  push   rbp        
  push   rbx        
  mov    rbx,rdi [1]    
  mov    rdi,QWORD PTR [rbx-0x8]     [2]

During the function prologue, the value contained in the rdi register is moved into the rbx register at [1]. On x86_64 Linux systems, the rdi register is used to store the first argument to a function. We know that the first, and only, argument of sunkbd_reinit() is a pointer to the work_struct instance that was used to schedule its invocation. At [2], eight is subtracted from that pointer. The resulting address is then dereferenced, and the value at that location is loaded in rdi. The purpose of this instruction can be determined by examining the definition of struct sunkbd:


struct sunkbd {
unsigned char keycode[ARRAY_SIZE(sunkbd_keycode)];           
 struct input_dev *dev;              
struct serio *serio;   <- rbx – 0x8            
struct work_struct tq; <- rbx             
wait_queue_head_t wait;              
char name[64];            
char phys[32];             
char type;              
bool enabled;             
volatile s8 reset;              
volatile s8 layout;
};

The address stored in rbx during the function prologue is a pointer to sunkbd->tq. We can see that the tq member is found immediately after the serio member, which is a pointer to a struct serio instance. Pointers are eight bytes long on x86_64 Linux systems, so the result of subtracting eight from rbx will be a pointer to sunkbd->serio. Instruction [2] proceeds to dereference this pointer to load the value of sunkbd->serio into rdi. This is how the first argument to serio_write() is obtained: rdi↓

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);
serio_write() is declared as an inline function, so its instructions can simply be incorporated into those of sunkbd_reinite() by the compiler. We saw that serio_write() attempts to execute the function pointed to by serio->write:

if (serio->write)              
return serio->write(serio, data);

At the instruction level, this is accomplished by performing pointer arithmetic on the value stored in rdi:

mov    rax,QWORD PTR [rdi+0xd8]
test   rax,rax

We know that the rdi register stores a pointer to a struct serio instance. The hexadecimal number 0xd8 (decimal 216) is the offset of the write function pointer within that instance, and so adding 0xd8 to rdi and then dereferencing the result will yield the value of the write function pointer itself. This is loaded into rax, and the subsequent test rax, rax instruction is used to determine if the function pointer has been set to a nonzero value. These two instructions therefore correspond to the if (serio->write) condition. The next few instructions are as follows:

je     0xffffffff817c15a2 <sunkbd_reinit+176>
mov    esi,0xe
call 0xffffffff81e00ee0 <__x86_indirect_thunk_rax>
If the value of serio->write is zero, a jump to the function epilogue occurs. If it is nonzero, __x86_indirect_thunk_rax() is called. This is a function trampoline, which concludes with the following two instructions:

mov    QWORD PTR [rsp],rax
ret
This has the effect of loading the value of rax (i.e. serio->write) into rip, thus redirecting execution to the location specified by the serio->write function pointer. We have seen how the evaluation of sunkbd_reinit() can lead to the execution of the function specified by serio->write. The possibility of achieving arbitrary code execution stems from that fact that the use-after-free bug provides an attacker with the opportunity to control the contents of the struct sunkbd instance used in sunkbd_reinit(). Recall that this instance can be freed by triggering the execution of sunkbd_disconnect() whenever sunkbd_reinit() is sleeping. An attacker in possession of a suitable reallocation gadget could potentially cause the freed sunkbd buffer to be reallocated and filled with data they control. Let us once again examine the first line of code that will be executed once sunkbd_reinit() awakens:

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED)

We have seen that the inline serio_write() function performs pointer arithmetic in order to obtain the serio->write function pointer, which is then loaded into the instruction pointer via __x86_indirect_thunk_rax(). From this chain of events, we can pick out the following instructions of interest:

mov    rbx,rdi                   [3]
mov    rdi,QWORD PTR [rbx-0x8]   [4]
mov    rax,QWORD PTR [rdi+0xd8]  [5]
mov    QWORD PTR [rsp],rax       [6]
ret                             [7]

Through their reallocation gadget, the attacker has control over the heap memory buffer that the sunkbd variable refers to, and that rdi points into at instruction [3]. They therefore have control over the value of sunkbd->serio, which is retrieved at instruction [4]. Instruction [5] adds 0xd8 (216) to this value and dereferences the resulting address. The value at this address is loaded into the instruction pointer by instructions [6] and [7]. An attacker must therefore carry out the following steps to achieve arbitrary code execution:
  1. Establish the address of an in-memory value that they would like to load into rip.
  2. Subtract 0xd8 (216) from this address.
  3. Trigger the execution of sunkbd_reinit(), which will set the value of the sunkbd variable and then go to sleep.
  4. Trigger the execution of sunkbd_disconnect() whenever sunkbd_reinit() is sleeping in order to free the heap memory that the sunkbd variable references.
  5. Use their reallocation gadget to overwrite the value of sunkbd->serio with the address that was calculated in step 2.

Exploitation demonstration

What follows is a step-by-step example that demonstrates such an attack. The publicly available proof-of-concept exploit was used in conjunction with a malicious reallocation event to perform this example, the goal of which was to load a user-controlled value into the instruction pointer. The system call table is a convenient source of function pointers for such a demonstration, and so this example involves code execution being redirected to the __x64_sys_read() routine (the first member of the system call table in this instance). The example was performed using QEMU with GDB attached so that the contents of memory could be examined.

First, let us examine the contents of the rdi register at the point that kfree() is called within sunkbd_disconnect(). This will tell us where sunkbd resides in memory:


(gdb) print/x $rdi
0xffff888006bd9600
The sunkbd buffer therefore resides at address 0xffff888006bd9600. The serio member of sunkbd resides at an offset of 136 (0x88) bytes into the sunkbd structure (i.e., at address 0xffff888006bd9688). This is the member of sunkbd that needs to be overwritten in order to redirect code execution. Several steps need to be taken to determine the value that the serio member should be overwritten with. First, the attacker must decide which function pointer they would like to load into the instruction pointer. As previously stated, the first entry in the system call table (__x64_sys_read) was chosen for this example.

(gdb) print/x sys_call_table
$14 = 0xffffffff82000280 <sys_call_table>
The system call table begins at address 0xffffffff82000280. Arithmetic must be performed on this address to arrive at the value that will be used to overwrite the sunkbd->serio. Specifically, 216 must be subtracted—recall that 216 (0xd8) is added to the serio pointer to locate serio->write. The malicious address is therefore 0xffffffff820001a8. The attacker must then identify a suitable reallocation gadget and then use it to reallocate the memory associated with the sunkbd object that was freed by sunkbd_disconnect(). The attacker must ensure that the maliciously crafted address is placed at an offset of 136 bytes from the beginning of the reallocated buffer. If the attacker carries these steps out correctly, they will have successfully overwritten sunkbd->serio by the time sunkbd_reinit() awakens: Before reallocation:

(gdb) print/x ({struct sunkbd}$sunkbd)->serio
$3 = 0xffff888006bce000
After reallocation:

(gdb) print/x ({struct sunkbd}$sunkbd)->serio
$10 = 0xffffffff820001a8
The following sequence of instructions will be executed after sunkbd_reinit() awakens, which will load the __x64_sys_read function pointer into rip:

mov    rdi,QWORD PTR [rbx-0x8]

rbx points to sunkbd->work.
rbx-0x8 produces a pointer to sunkbd->serio (i.e., a pointer to the maliciously crafted address that was placed within the reallocated buffer).

This pointer is dereferenced, loading the crafted address into rdi. After this instruction is executed, rdi will point to 216 bytes prior to the system call table:

print/x $rdi
$12 = 0xffffffff820001a8
The next instruction to be executed is as follows:

mov    rax,QWORD PTR [rdi+0xd8]

rdi+0xd8 produces a pointer to the first entry in the system call table (__x64_sys_read). After this instruction is executed, rax will contain a pointer to the __x64_sys_read() routine. The following sequence of instructions is then executed:

call   0xffffffff81e00ee0 <__x86_indirect_thunk_rax>        
jmp    0xffffffff81e00ee5 <__x86_retpoline_rax>       
call   0xffffffff81e00ef1 <__x86_retpoline_rax+12>        
mov    QWORD PTR [rsp],rax
ret

The final two instructions in this sequence will cause the contents of rax to be loaded into the instruction pointer. Execution will therefore have been redirected to __x64_sys_read:

(gdb) si        
__do_sys_read (count=<optimised out>, buf=<optimised out>, fd=<optimised out>) at fs/read_write.c:625

Execution has therefore been redirected to a user-controlled location. This is a powerful exploit primitive that an attacker could potentially incorporate into a full-fledged chain to achieve a full compromise of the target system.

Continue Reading

Explore Topics