research
← All research

Sandbox evasion via CoreEntitlements type confusion

Summary

A type confusion between the ASN.1 BOOLEAN and INTEGER values in the DER entitlement processing functions amfid uses allows macOS applications to claim boolean entitlements, such as com.apple.security.app-sandbox, using the <integer>1</integer> expression instead of <true/>. On macOS 26.3.1, the entitlement passes all validation stages (codesign, both amfid’s anomaly detection and compareToCFRepr checks) and only then the kernel’s entitlement enforcement (IOTaskHasEntitlement, Sandbox.kext) rejects it due to a stricter DER tag check.

This writeup, at its core, is about three chained bugs:

  • Objective-C’s NSNumber class hierarchy creates a semantic equivalence (@1 is equal to @YES) that doesn’t exist in the DER type system (INTEGER isn’t equal to BOOLEAN) or in the CEType enum (3 isn’t equal to 5)
  • amfid accepting the value and passing it over to the kernel (for it to reject it later)
  • Apple-signed services checking an application’s entitlements with SecTaskCopyValueForEntitlement() and comparing the obtained value using isEqual:@YES (or boolValue), ending up in the Apple daemon interpreting that a process is sandboxed whrn it really isn’t.

In a nutshell: a process appears to hold the entitlement from the userspace perspective but really isn’t from the kernelspace perspective, effectively evading the sandbox.

Description

1. DER Encoding of boolean vs integer

Let’s quickly look into ASN.1 DER encoding. The table below shows us how the values encoding for BOOL and INT is computed:

TypeTag byteExample encodingDecoded value
BOOLEAN TRUE0x0101 01 FFtrue
BOOLEAN FALSE0x0101 01 00false
INTEGER 10x0202 01 011

Note how they have the same byte length with different tag bytes. The DER type system, logically, considers them semantically different types.

2. Objective-C type coercion

In CoreFoundation, CFBooleanRef ( which only consists of @YES/@NO) and CFNumberRef (@1/@0) are, well, clearly different types with also different CFTypeID values (21 and 22, respectively). However, CFBooleanRef is basically a wrapper for NSNumber, which means that a CFBooleanRef can be used anywhere an NSNumber is expected, and viceversa. When [NSNumber isEqual:] compares them, it compares the numeric value, not the type:

[@YES isEqual:@1] // Returns YES, and both have numeric value **1**
[@1 isEqual:@YES] // Returns YES

@YES is a CFBooleanRef (a singleton, kCFBooleanTrue with CFTypeID value 21 assigned to it as we stated a bit earlier). @1 is therefore a CFNumberRef (NSConstantIntegerNumber, CFTypeID with value 22). They are, as the reader can already see, different types that compare as equal because the equality between them allows the NSNumber’s own comparison logic to treat CFBooleanRef as a number.

3. Entitlements check

The diagram below maps how entitlements are checked by the AMFI and Sandbox kexts; all these steps will consider the <integer>1</integer> payload as valid.

An app's entitlements XML plist is loaded
|_ codesign embeds XML (slot 5) and DER (slot 7) in the CodeSignature
   |_ Binary is launched -> XNU loads the app's code signature (CMS blob)
      |_ AppleMobileFileIntegrity.kext sends the CMS blob to amfid via MIG
         |_ amfid's compareToCFRepr(DER, XML) -> Validates consistency
            |_ amfid's AMFIUnserializeXML vs CFPropertyList -> Anomaly detection function
              |_ Kernel installs DER in process credentials
                |_ IOTaskHasEntitlement -> der_vm's CEMatchBool(true) - Entitlements enforcement
                   |_ Sandbox.kext -> CEElementMatchBool enforcement
                      |_ SecTaskCopyValueForEntitlement does runtime verification
                         |_ Userland service daemon queries for entitlement

Root cause analysis

1.codesign preserves the integer type

codesign uses CESerializeCFDictionary to create the DER blob from the parsed XML plist. This function checks CFGetTypeID() to determine the DER tag as follows:

  • CFBooleanGetTypeID() -> DER BOOLEAN (tag 0x01)
  • CFNumberGetTypeID() -> DER INTEGER (tag 0x02)

When the XML contains <integer>1</integer>, CFPropertyListCreateWithData creates a CFNumberRef, and CESerializeCFDictionary faithfully encodes it as DER INTEGER - no normalization occurs. This preservation happens by patching the DER and XML slots, the codesign binary will not do this by design but it does accept it when the slots are patched.

2. amfid accepts the mismatched value

The compareToCFRepr function, which is called during verify_code_directory, compares the DER-parsed OSDictionary against the XML-parsed CFDictionary on an element by element basis.

For values, valuesEqual() compares based on the OSDictionary value type:

  • OSBoolean -> compareBool() - pointer-wise comparison against kCFBooleanTrue and kCFBooleanFalse
  • OSNumber -> compareNumbers()

When the DER has INTEGER equal to 1, the OSDictionary value is always an OSNumber(1). Thus valuesEqual dispatches to compareNumbers, which stands as follows:

CFNumberIsFloatType(cfValue)          -> 0 (CFBoolean isn't a float())
CFNumberGetByteSize(cfValue)          -> 1 (passes < 9 check)
CFNumberGetValue(cfValue, int64, &v)  -> v = 1 (since CFBoolean -> 1)
Compare: OSNumber.value(1) == CFNumberGetValue(CFBooleanTrue)(1) -> equal; // The one we care about!

Since CFBooleanRef is structurally linked to NSNumber as we’ve already seen, calling CFNumberGetValue(kCFBooleanTrue, kCFNumberSInt64Type, &v) is successful and returns v = 1. The function doesn’t distinguish between a CFBooleanRef and a CFNumberRef - it reads the numeric value through the NSNumber interface. This causes OSNumber(1) (from DER INTEGER) to match CFBooleanTrue (from XML <true/>). Bottomline, amfid considers INTEGER 1 and BOOLEAN TRUE as equivalent.

3. amfid anomaly detection passes the mismatched value

There’s an amfid function that seems to be a catch-all for weird/non-standard data input that compares the output of AMFIUnserializeXML against CFPropertyListCreateWithData. Both parsers produce the same type for <integer>1</integer> (a number with value 1) and for <true/> (boolean true). Since both XML and DER were created from the same source plist, both slots contain <integer>1</integer>, and the parser happily agrees.

4. Kernel enforcement does the right thing

XNU’s IOTaskHasEntitlement calls OSEntitlements.queryEntitlementBooleanWithProc on the AMFI kext, which uses CoreEntitlements’ CEMatchBool(true). kCEOpMatchBool (opcode 5, it would seem to be) in der_vm_execute_nocopy at 0x5014 calls der_decode_boolean (HLIL representation below):

ccder_blob_decode_range(blob, expected_tag=0x01) 

If the DER element has tag 0x02 (INTEGER), ccder_blob_decode_range rejects it (tag mismatch) and der_decode_boolean returns failure. kCEOpMatchBool then sets context.is_valid = false. The query returns entitlement not matched, which is what’s expected from XNU. The conclusion to this is that the kernel strictly requires DER tag 0x01 (BOOLEAN) and rejects tag 0x02 (INTEGER).

Sandbox.kext follows the same path by means of CEElementMatchBool -> CEElementGetBool -> der_vm_CEType_from_context, which checks CEType == 5 (BOOLEAN). INTEGER (CEType 3) fails, as expected.

5. SecTaskCopyValueForEntitlement returns a CFNumber

When an Apple daemon calls SecTaskCopyValueForEntitlement, the kernel deserializes the DER to a CFDictionary via CEQueryContextToCFDictionary -> objectForActiveContext:

  • DER tag 0x02 (INTEGER) -> CEType 3 -> [NSNumber numberWithLongLong:value] -> returns @1 (CFNumber)
  • DER tag 0x01 (BOOLEAN) -> CEType 5 -> returns kCFBooleanTrue (CFBoolean)

The daemon receives @1 (CFNumber), which [value isEqual:@YES] evaluates as a YES due to the type confusion. As you can probably expect, the false logical conclusion an Apple service arrives is that the entitlement is correctly enforced.

Proof of concept

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <integer>1</integer>
</dict>
</plist>

1. Results on macOS 26.3.1

Some of the tested values are listed below.

Entitlement valueDER tagKernel enforcementDaemon perceptionSandboxed?
<true/>0x01 BOOLEANEnforced@YES (CFBoolean)Yes
<false/>0x01 BOOLEANNot enforced@NONo
<integer>1</integer>0x02 INTEGERNot enforced@1 (*isEqual:@YES = YES)No

Impact

1. Sandbox evasion

Technically, these bugs allows for sandbox evasion, not sandbox escape since the sandbox is never applied - the kernel does not see BOOLEAN TRUE for app-sandbox (per the wrong DER tag), so the Sandbox kext never enforces restrictions and the process is never sandboxed to begin with - it prevented the sandbox from being enforced while appearing sandboxed to every userspace component that checks entitlements. This means that a macOS application can claim com.apple.security.app-sandbox = <integer>1</integer> and run completely unsandboxed - with full filesystem access, network access, and all other capabilities that the App Sandbox normally restricts. Any system component that checks the entitlement via SecTaskCopyValueForEntitlement and compares with isEqual:@YES (or boolValue, or true-false evaluation) will believe the process is sandboxed.

1.2. Affected entitlements

Any boolean entitlement checked by the kernel via IOTaskHasEntitlement or CEMatchBool via Sandbox.kext is affected, including but not limited to:

  • com.apple.security.app-sandbox
  • com.apple.security.device.camera
  • com.apple.security.device.microphone
  • com.apple.security.personal-information.addressbook
  • com.apple.security.personal-information.photos-library
  • com.apple.security.personal-information.location
  • com.apple.security.files.downloads.*

2. macOS App Store as primary attack surface

Sandboxing on macOS is not mandatory for general software distribution; it is specifically and only required for Mac App Store distribution. The only reason all the following criteria do not apply is that the App Store’s notarization process recognizes the <integer>1</integer> payload as invalid and does not allow Mac App Store app publishing.

3. iOS impact

The iOS App Store process from store to device for a malicious application is mitigated by installd’s provisioning profile validation, which performs a DER-level type-strict subset check (CEContextIsSubset) at install time. That said, the iOS kernel has the identical enforcement gap. Worth noting that installd’s CEContextIsSubset performs a strict comparison (cmp w22, w23) on CEType values (3 not equal to 5); since sandboxing is mandatory and by design on iOS, it makes sense this additional check isn’t present on macOS.

Ineligibility criteria

Apple’s rebuttal for rejecting these bugs is that the notarization and publishing processes caught the integer payload on the entitlement .plist file as non-compliant, which shows the non-security-focused reason for the automatic notarization denial. The additional installd check on iOS was the final criterion for the Apple Bounty team’s rejection.

LLM usage disclaimer

The core idea, initial reverse engineering and PoC were human-made and all tooling, final PoC and variants search and analysis was done by Opus 4.7 . Spent human time was about 320 hours over 45 days, and LLM operational and research spent time was about 95 hours.

Proof of concept