May 7 / 2026 / Reading Time: 5 minutes

Rotten Apples Returns - macOS Codesigning Translocation Revisited

 

In 2021, our vulnerability research team discovered a flaw in amfid that allowed transplanting a codesigning entitlements blob from one binary onto another — what we called Rotten Apples. Apple's M1 release inadvertently killed that specific bug, though they never acknowledged it as a vulnerability and deemed it normal behavior.

We have more "normal behavior" to report.

For reference, the original 2021 Python implementation was disarmingly simple:

 

Simple enough that Apple deemed it "normal behavior." The M1 transition killed it incidentally. The new variant is harder to wave away.

amfid — heavy is the head that wears the crown

amfid is the userland daemon at the centre of macOS code signing enforcement. It works in concert with AMFI.framework — the shared library that handles entitlement policy — and AppleMobileFileIntegrity.kext, the kernel extension that does the actual enforcement. Together they form a triumvirate intended to guarantee that what runs on your Mac is what it claims to be.

The vulnerability in this report lives in AMFI.framework, not amfid. Within it there is a small group of whitelisted entitlements. The logic is this: if a code signature blob contains only entitlements drawn from the unrestrictedEntitlements list — which includes both the com.apple.security.* prefix and specific EXACT entries beyond it — it's considered unrestricted. No provisioning profile required; any Developer-ID certificate holder can use those entitlements. The framework will permit that blob to be transplanted onto any binary whose codesigning anchor is not Apple itself — third-party and Developer-ID signed binaries are in scope; Apple-signed system binaries are not.

Here's what's actually in those lists.

unrestrictedEntitlements — 4 entries. No provisioning profile required; any Developer-ID cert holder can use these:

 

MATCHENTITLEMENTNOTES
EXACTcom.apple.private.signing-identifierOverride the signing identifier string (distinct from the certificate identity)
PREFIXcom.apple.security.*Entire security namespace — see hardened runtime list below
EXACTcom.apple.developer.hardened-processHardened runtime opt-in
EXACTcom.apple.developer.hardened-process.Trailing-dot variant — distinct separate entry

softRestrictedEntitlements — 2 entries. Checked before unrestricted, these override the com.apple.security.* prefix match for specific keys — meaning you cannot steal these even though they fall under the prefix:

MATCHENTITLEMENTNOTES
EXACTcom.apple.application-identifierCannot be transplanted
PREFIXcom.apple.security.application-groupsCannot be transplanted

That com.apple.security.* prefix in unrestrictedEntitlements is still doing a lot of work. It covers the full hardenedRuntimeEntitlements list — all 7 entries, all 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 of these are directly useful for malware TTPs.

"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

With this knowledge, you have two options for the donor signature: steal one from a real application with suitable entitlements (Obsidian's Electron.framework is a good candidate), or craft your own. Either way, 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 enough — CS_REQUIRE_LV (library validation — cleared via csops(CS_OPS_CLEAR_LV)) would still catch a mismatched dylib being loaded. The key flag to clear is CS_REQUIRE_LV, done via 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 can run, 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 (the common case for tools like Obsidian) are owned by that user, so no privilege escalation is required; admin group membership is sufficient for apps installed to /Applications. Second, the evil dylib and backup copy must be written into that directory before the race starts — both cp operations in the setup phase require the same write access. Neither step requires root.

The race itself exploits a time-of-check to time-of-use (TOCTOU) window in how dyld loads frameworks. By using the raw renamex_np syscall with the RENAME_SWAP flag, the real framework binary and the evil dylib are atomically swapped — the path is never absent, so there's no gap where a missing file would raise suspicion. A racer thread hammers this swap thousands of times per second while the target application is repeatedly launched and killed. Against Electron.framework inside Obsidian, we won the race 100% of the time across 2,000 iterations.

Addendum: the sudo chain

There's a compounding issue worth noting separately — though its precise role needs stating clearly: this is a persistence mechanism, not a privilege escalation. Writing to /private/etc/sudo.conf requires root. An attacker who already has root can use this chain to ensure their payload re-executes with euid=0 on every subsequent sudo invocation, surviving reboots and without any visible indicator to the user.

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 the dlopen with library validation disabled, and on success, does not re-enable it.

sudo holds the com.apple.private.security.clear-library-validation entitlement specifically to enable this code path. The result: 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 every time it executes thereafter. The payload runs inside a setuid-root, Apple-signed binary, with no library validation in force.

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 will never inspect. The payload runs silently on every sudo invocation with no visible indicator unless the user has a network monitor like Little Snitch detecting outbound connections from the payload.

"Once you have root, sudo ensures you keep it — silently, on every invocation, with no indicator visible to the user."

Where does this go?

The practical applications are significant. With CS_REQUIRE_LV cleared and a transplanted identity, you have a viable implant deployment primitive. The com.apple.security.* namespace includes hypervisor entitlements — so spinning up your own hypervisor is within scope. The transplanted cs.* entitlements modify the signed binary's own runtime behaviour — disabling library validation on itself, permitting JIT mappings, allowing dyld environment variables, and so on. Cross-process debugging is a separate capability requiring com.apple.security.get-task-allow on the target or a dedicated debugger entitlement; the transplanted cs.* list does not grant it. What the transplant does give you is a process that AMFI treats as a legitimately entitled binary — which is the foundation for the dylib injection described above and for any capability gated on those specific cs.* flags.

The longer arc is more interesting. macOS is slowly migrating AMFI's responsibilities to the TXM (Trusted Execution Monitor), which runs at a separate privilege level isolated from XNU, SEPROM, and userland — a model closer to how the iPhone handles this. M5-class Macs with eMTE are beginning to look more like a jailbroken iPhone than a traditional Mac. Once the migration is complete, codesigning vulnerabilities will either remain as logical bugs or require complex chains that bridge ARM privilege levels and compromise the full chain of trust.

That's good for defence. But it won't stop the research. Apple cuts off the branch; another grows. Such is the game.

 

 

 

 

Share This Insight: