Rotten Apples Returns — macOS Codesigning Translocation Revisited
In 2021, OSec’s vulnerability research team discovered a flaw in amfid that allowed transplanting codesigning entitlements from one binary to another. We called it “Rotten Apples.” Apple’s M1 release inadvertently patched this specific bug, though Apple never acknowledged it as a vulnerability, deeming it normal behavior.
The original 2021 Python implementation was notably simple, which Apple dismissed as “normal behavior.” The M1 transition killed it incidentally, but a new variant has emerged that proves harder to dismiss.

amfid — heavy is the head that wears the crown
amfid is the userland daemon at the center of macOS code signing enforcement. It works alongside AMFI.framework, the shared library handling entitlement policy, and AppleMobileFileIntegrity.kext, the kernel extension performing actual enforcement. Together they form a triumvirate intended to guarantee what runs on your Mac matches its claims.

The vulnerability resides in AMFI.framework, specifically within a small group of whitelisted entitlements. The logic: if a code signature blob contains only entitlements from the unrestrictedEntitlements list (including the com.apple.security.* prefix and specific exact entries) it’s considered unrestricted. No provisioning profile is required; any Developer-ID certificate holder can use those entitlements. The framework permits this blob to be transplanted onto any binary not anchored by Apple. Third-party and Developer-ID signed binaries qualify; Apple-signed system binaries do not.
unrestrictedEntitlements — 4 entries
| MATCH | ENTITLEMENT | NOTES |
|---|---|---|
| EXACT | com.apple.private.signing-identifier | Override the signing identifier string |
| PREFIX | com.apple.security.* | Entire security namespace |
| EXACT | com.apple.developer.hardened-process | Hardened runtime opt-in |
| EXACT | com.apple.developer.hardened-process. | Trailing-dot variant |
softRestrictedEntitlements — 2 entries
| MATCH | ENTITLEMENT | NOTES |
|---|---|---|
| EXACT | com.apple.application-identifier | Cannot be transplanted |
| PREFIX | com.apple.security.application-groups | Cannot be transplanted |
The com.apple.security.* prefix in unrestrictedEntitlements covers the full hardenedRuntimeEntitlements list: all 7 entries beginning with com.apple.security.cs.*: cs.disable-library-validation, cs.automator-plugins, cs.allow-unsigned-executable-memory, cs.disable-executable-page-protection, cs.allow-relative-library-loads, cs.allow-dyld-environment-variables, and cs.allow-jit. Several are directly useful for malware tactics.
“If a code signature blob contains only entitlements from the unrestrictedEntitlements list—and none from softRestrictedEntitlements—AMFI will permit it to be transplanted onto any non-Apple-anchored binary.”
The attack: signature transplant + dyld race
You have two donor signature options: steal one from a real application with suitable entitlements (Obsidian’s Electron.framework works well), or craft your own. The transplant process is straightforward. Locate the LC_CODE_SIGNATURE load command in the donor’s Mach-O, copy the blob, append it to your malicious binary’s __LINKEDIT segment, and add a new load command pointing to it.

On its own, a transplanted signature isn’t sufficient. CS_REQUIRE_LV (library validation, cleared via csops(CS_OPS_CLEAR_LV)) would still catch mismatched dylibs. The key flag to clear is CS_REQUIRE_LV using csops(CS_OPS_CLEAR_LV). Once cleared, the transplanted identity and all its entitlements travel with the binary, and library validation no longer applies.

Before the race executes, two prerequisites must be satisfied. First, the attacker needs write access to the target application’s bundle directory, specifically the Frameworks/ subtree inside the .app package. On macOS, apps installed by the current user (common for tools like Obsidian) are owned by that user, requiring no privilege escalation; admin group membership suffices for /Applications installs. Second, the malicious dylib and backup copy must be written before the race starts, and both operations require the same write access. Neither step demands root.
The race exploits a time-of-check to time-of-use (TOCTOU) window in how dyld loads frameworks. Using the raw renamex_np syscall with the RENAME_SWAP flag, the real framework binary and malicious dylib are atomically swapped. The path never becomes absent, eliminating gaps where a missing file would raise suspicion. A racer thread hammers this swap thousands of times per second while the target application launches and terminates repeatedly.

Against Electron.framework inside Obsidian, the race succeeded 100% across 2,000 iterations.

Addendum: the sudo chain
A compounding issue deserves separate mention, though its precise role needs a clear statement: this is a persistence mechanism, not privilege escalation. Writing to /private/etc/sudo.conf requires root. An attacker already possessing root can use this chain to ensure their payload re-executes with euid=0 on every subsequent sudo invocation, surviving reboots without visible user indicators.
The mechanism: sudo on macOS clears CS_REQUIRE_LV (via csops(CS_OPS_CLEAR_LV)) when plugin loading fails for a readable, validly signed binary, including ad-hoc signed binaries. The failure path retries dlopen with library validation disabled, and upon success, does not re-enable it.
sudo holds the com.apple.private.security.clear-library-validation entitlement specifically enabling this code path. By writing a plugin entry to /etc/sudo.conf pointing at an ad-hoc signed dylib, a root-level attacker can cause sudo to silently load their payload as euid=0 on every subsequent execution. The payload runs inside a setuid-root, Apple-signed binary with no library validation enforced.
Cleanup requires only root, not a SIP bypass. Both /private/etc/sudo.conf and the plugin directory /usr/local/libexec/sudo/ are explicitly excluded from SIP protection; a privileged user can remove both without bypassing SIP. The persistence survives as long as the attacker retains root and the victim does not audit /private/etc/sudo.conf, a file most users never inspect. The payload executes silently on every sudo invocation without visible indicators unless the user runs network monitors like Little Snitch detecting outbound payload connections.

“Once you have root, sudo ensures you keep it—silently, on every invocation, with no visible indicator to the user.”
Where does this go?
The practical applications are significant. With CS_REQUIRE_LV cleared and a transplanted identity, you possess a viable implant deployment primitive. The com.apple.security.* namespace includes hypervisor entitlements, so spinning up your own hypervisor falls within scope. The transplanted cs.* entitlements modify the signed binary’s own runtime behavior: disabling library validation on itself, permitting JIT mappings, allowing dyld environment variables, and similar capabilities. Cross-process debugging requires separate com.apple.security.get-task-allow on the target or dedicated debugger entitlements; the transplanted cs.* list does not grant it. What the transplant provides is a process AMFI treats as legitimately entitled, which underpins the dylib injection described and any capability gated on those specific cs.* flags.
The longer trajectory proves more intriguing. macOS slowly migrates AMFI’s responsibilities to the TXM (Trusted Execution Monitor), which runs at a separate privilege level isolated from XNU, SEPROM, and userland, a model resembling how the iPhone handles this. M5-class Macs with eMTE increasingly resemble jailbroken iPhones rather than traditional Macs. Once migration completes, codesigning vulnerabilities will either persist as logical bugs or require complex chains bridging ARM privilege levels and compromising the full chain of trust.
That benefits defense. But it won’t halt research. Apple cuts off the branch; another grows. Such is the game.
