Getting Kernel Code Execution: Racing the KEXT Loader
Loading a kernel extension is the most tightly controlled operation on macOS, and for good reason: a KEXT runs in ring 0, inside XNU, with no sandbox and nothing above it. Apple has spent years building a fortress around it: a special signing certificate, user approval, a reboot, and a copy into a SIP-protected staging directory so the code that gets verified is the code that gets loaded. And yet the whole edifice has a soft center: the entire verification happens in user space. The kernel loads whatever the userland tooling hands it, trusting that the checks already passed.
This post follows the KEXT loading process end to end, finds the time-of-check/time-of-use window in the staging logic, and walks CVE-2020-9939: loading an unsigned KEXT by winning a race against the loader with a well-placed symlink. It closes on CVE-2021-1779 and how Big Sur changed the ground. This is the hardest target in the series, and it rewards everything before it: the symlink thinking from last post, the Mach messaging from the IPC post, the patch-diffing from XPC.
The Wall: How Hard Apple Made This
The legitimate path to loading a third-party KEXT is deliberately painful:
- A kernel code-signing certificate: not the ordinary Apple Developer certificate, a special one that requires a separate justification to Apple. Most attackers will never have one.
- Root to initiate the load.
- User approval in System Preferences after authentication: “Secure Kernel Extension Loading.”
- A reboot, as of Big Sur, before the KEXT actually loads.
A “hello world” KEXT is a small bundle with start/stop functions; building it is trivial. Trying
to load it shows the wall immediately:
# filename: kextutil.txt
% sudo kextutil -v hellokext.kext
Copying ... to /Library/StagedExtensions/Users/attacker/<UUID>.kext
# verification fails — no kernel signing certificate
Notice what happened before it failed: the KEXT was copied to
/Library/StagedExtensions/.... That copy is staging: moving the bundle into a SIP-protected
location so it cannot be tampered with after the signature is verified. Staging is supposed to close
the gap between “we checked this code” and “we loaded this code.” It is also exactly where the bug
lives.
Since obtaining a signing certificate is off the table, the only route to kernel code execution is a flaw in the loading process that lets an unsigned KEXT slip through. To find it you have to know the process.
The Loading Process (and Its Fatal Property)
A cast of userland daemons cooperates to load a KEXT: kextload/kextutil to initiate, kextd
to orchestrate, syspolicyd to check policy and user approval, kextcache for the cache. The
flow:
- The user runs
kextutil/kextload, which sends the request tokextdover Mach messages. (Any process can technically send this message: a detail that matters.) kextdruns initial checks, then stages the KEXT: copies it to/Library/StagedExtensions.kextdverifies the signature on the staged copy.kextdaskssyspolicydwhether policy allows it;syspolicydchecks for prior approval or prompts the user.- On success,
kextdenters XNU and the kernel loads the KEXT.
The fatal property, stated outright in the loader’s design: no verification happens in the kernel. The kernel loads what userland tells it to, after userland says the checks passed. So if you can make the staged, verified copy differ from the code that actually loads, or get an unsigned KEXT into the staged location as though it were already approved, there is no second line of defense behind you. Beat the userland checks and the kernel does not re-check.
The TOCTOU: A Temporary Name That Looks Final
Staging (in Catalina’s kext_tools staging.m) has a structure that should make a vulnerability
researcher’s ears prick up. First, stagingEnabled() only checks whether SIP is on: if SIP is
off, nothing is staged at all. Then kextRequiresStaging decides whether a KEXT needs staging at
all, and the rule is essentially: if the KEXT is already under /Library/StagedExtensions, it does
not need staging. Already-staged means already-trusted.
Then comes the move that creates the window. Staging copies the KEXT to a temporary directory before validation, and the temporary name is dangerously similar to the final name:
# filename: staging-paths.txt
temporary (pre-validation): /Library/StagedExtensions/private/tmp/<UUID>.kext
final (post-validation): /Library/StagedExtensions/Users/Shared/some.kext
The bundle is copied to the temporary location, validated, then moved to the final location and the temporary cleaned up. That is a textbook time-of-check/time-of-use sequence: there is a moment between the copy and the cleanup where the filesystem is in a half-finished state the loader assumes is transient, and an attacker can refuse to let it be transient.
CVE-2020-9939: Winning the Race With a Symlink
The exploit chains the “already staged = trusted” shortcut to the temporary-staging window using a symlink, the trick from the previous post. The sequence:
-
Stage a first KEXT containing an embedded symlink, then cancel the load before cleanup deletes the temporary files: terminate the process mid-flight. Because the load was interrupted, the symlink you planted in the staging area remains in place. This is the race: you stop the loader before it tidies up, leaving the filesystem in a state it never meant to persist.
-
The leftover symlink points back out to
/private/tmp: a location you fully control. -
Load a second KEXT down a path that traverses that symlink. The loader believes the second KEXT’s temporary staging location is inside the SIP-protected
/Library/StagedExtensions/private/tmp/<UUID2>.kext, but the symlink redirects that path to/private/tmp/<UUID2>.kext: outside SIP, in your control. -
Now the loader is “staging” into a directory you own. The code it validates and the code it ultimately loads can diverge, because the supposedly-protected staging path resolves to writable ground. An unsigned KEXT loads into the kernel.
The conceptual core is the same one that has run through this whole series: the loader trusted that a
path under /Library/StagedExtensions was tamper-proof, and a symlink made that path resolve
somewhere it was not. SIP protected the directory; it did not protect against the path to it
being redirected. Combine that with “already-staged means trusted” and “the kernel never re-checks,”
and a symlink plus a well-timed process kill yields ring-0 code execution.
This is a race, and races are probabilistic: you are betting you can cancel between the copy and the cleanup. Like most local TOCTOU bugs, you get unlimited retries, so even a narrow window is reliably winnable. The defense against this class is not “make the window smaller,” it is “validate the final target, not a path that can be redirected.”
Once you can load an unsigned KEXT, the kernel is yours, and from there, disabling SIP itself becomes reachable, since kernel code can rewrite the configuration that governs it. Kernel code execution is the master key; SIP is one of the doors it opens.
CVE-2021-1779 and the Big Sur Reset
Following the patch (again, patch-diffing as a discovery method), we examine CVE-2021-1779, a later unsigned-KEXT-load bug, plus how the staging mechanism shifted in newer macOS. Two changes reframe the whole technique:
- A more constrained staging flow. Apple tightened how and where staging happens, narrowing the symlink-redirection trick the first CVE relied on. The follow-up CVE shows the cat-and-mouse: the patch closed one path, research found another.
- “Forget the race, meet interactive mode.” Big Sur’s changes, including the mandatory reboot and a reworked approval flow, altered the assumptions, and notably some approaches shift from racing toward an interactive angle. The architecture moved, and the exploitation moved with it.
The meta-point is that KEXT loading is a moving target Apple is actively trying to eliminate: the research openly speculates third-party KEXTs may eventually be forbidden entirely (and on Apple Silicon, the system extension / DriverKit push is exactly that direction). Each unsigned-load CVE is a snapshot of a fortress mid-renovation.
Why This Matters
The unsigned-KEXT-load bugs are the highest-impact vulnerabilities in this series because the prize is the kernel, and they are a masterclass in why where you verify matters as much as whether you verify. Apple checked the signature thoroughly (in user space, on a staged copy, behind SIP) and it still fell, because the kernel trusted userland’s verdict and the staged location’s integrity depended on a path that a symlink could redirect. Verification in the wrong place, on the wrong object, at the wrong time, is no verification at all.
It also ties the series together. The symlink redirection is the filesystem post. The Mach message
that any process can send to kextd is the IPC post. Following the patch to the next CVE is the XPC
post’s methodology. Getting kernel code execution is not a single exotic trick; it is the
composition of every primitive built so far, aimed at the one boundary that has no boundary above
it.
The final post steps back from individual bugs and chains them: a full macOS penetration test, from a sandboxed foothold through privilege escalation to persistence: the primitives of this entire series assembled into one attack.
References
- Apple. kext_tools: staging.m, security.c (623.11.5). opensource.apple.com.
- MITRE. CVE-2020-9939, CVE-2021-1779.
- Apple. Kernel Extensions and System Extensions; Secure Kernel Extension Loading. developer.apple.com.