Skip to content

sandbox_extension_update_file_by_fileid blocked by access to kernel information #1

@Protonk

Description

@Protonk

On macOS Sonoma 14.4.1 (23E224, Apple Silicon), EntitlementJail can successfully exercise the sandbox extension SPI for issuing and consuming extensions under a dedicated injection-friendly entitlement profile, but sandbox_extension_update_file_by_fileid is not generally usable from userland. The kernel entrypoint for this operation is _syscall_extension_update_file_by_fileid in sandbox.kext, which expects a 24-byte user struct whose first field is a pointer to an 8-byte payload, whose second field is a selector, and whose third field must be zero (or it returns EINVAL). The kernel only reads the payload’s low 32 bits and uses it as a lookup key; EFAULT arises only from malformed pointers/copyin failures, while EINVAL primarily indicates lookup/validation failure.

Static kernel-collection analysis closes the key uncertainty: the value compared against the payload low-32 is an internal id stored at [obj + 0xc0], and this id is constructed in-kernel (class/type bits combined with global counter state and uniqueness checks across a list), not derived from file identity (inode) or token fields. After adding an ABI-matching call variant in EntitlementJail, repeated correlation sweeps show stable outcomes with no EFAULT (call shape confirmed) and near-universal EINVAL for candidates like inode and token numeric fields; the only non-EINVAL case observed is when the payload low-32 equals st_dev, which produces selector-dependent results including OK for selector 2 (and ENOENT for selector 1), but still with no demonstrated access change. Overall, the operation’s success depends on a kernel-minted id that userland cannot presently obtain or predict, leaving update_file_by_fileid blocked as a practical userland maintenance flow on this baseline.

Scope and baseline

  • Host baseline: macOS Sonoma 14.4.1 (23E224), Apple Silicon
  • world_id: sonoma-14.4.1-23E224-arm64-dyld-2c0602c5
  • Evidence sources are static kernel-collection / kext disassembly artifacts (Ghidra) plus EntitlementJail probe runs (no live kernel memory capture).

EntitlementJail: current probe + profile state

Implemented capabilities

EntitlementJail now ships:

  • A sandbox_extension probe (xpc/InProcessProbeCore.swift) that exercises:

    • sandbox_extension_issue_file
    • sandbox_extension_consume
    • sandbox_extension_release
    • plus wrapper and maintenance symbols for discovery/instrumentation
  • A dedicated entitlement profile: ProbeService_fully_injectable_extensions (“fully_injectable_extensions”), allowing issuance/consumption while remaining injection-friendly.

Empirical behavior in EntitlementJail (Sonoma 14.4.1)

Issue/consume path (works)

  • sandbox_extension_issue_file succeeds under fully_injectable_extensions.
  • sandbox_extension_consume succeeds (same or different profile).
  • Token observation: semicolon-delimited tokens; token field 9 matches stat -f %i (inode) for the target file.

Release path (Sonoma limitation)

  • Release calls can return OK, but access is not reliably revoked in-process on 14.4.1; revocation is observed only after process exit.
  • Documented as best-effort cleanup.

Update-by-path (sandbox_extension_update_file) behavior

  • Returns success when called with path + flags.
  • No clear, observable access change has been demonstrated in current tests.

Update-by-fileid (sandbox_extension_update_file_by_fileid) behavior (updated)

  • Earlier attempts using token-derived or swapped-argument call variants produced EINVAL/EFAULT and were not actionable.
  • After correcting the call shape to match the actual kernel ABI (details below), EFAULT can be eliminated (indicating correct copy-in and argument construction).
  • With the corrected ABI, outcomes are stable across runs and show that some values can pass lookup and proceed further.

Kernel ABI closure: _syscall_extension_update_file_by_fileid (sandbox.kext)

Entry point (sandbox.kext): _syscall_extension_update_file_by_fileid
Evidence: dumps/ghidra/out/14.4.1-23E224/sandbox-kext-function-dump/function_dump.json

User ABI at the syscall boundary (confirmed by copy-in sequence)

Disassembly excerpt (canonical addrs):

  • Copies in a 0x18 (24-byte) struct:

    • 0xfffffe000b421aa4 bl 0xfffffe00086b7eb4 ; copy-in 0x18 bytes
  • Uses struct field0 as a pointer to an 8-byte payload and copies in 0x8 bytes:

    • 0xfffffe000b421ab8 ldr x0,[sp, #0x80] ; field0 (pointer)
    • 0xfffffe000b421ac4 bl 0xfffffe00086b7eb4 ; copy-in 8 bytes from field0
  • Enforces field2 == 0:

    • 0xfffffe000b421b24 ldr x9,[sp, #0x90] ; field2
    • 0xfffffe000b421b28 cbz x9,0xfffffe000b421b3c
    • else sets w9=#0x16EINVAL
  • Passes selector and payload onward:

    • 0xfffffe000b421b3c ldr x21,[sp, #0x88] ; field1 (selector)
    • 0xfffffe000b421b54 bl 0xfffffe0008757afc

Inferred struct layout (host-specific, Sonoma 14.4.1):

  • +0x00: pointer to 8-byte payload
  • +0x08: selector (passed onward)
  • +0x10: must be zero (else immediate EINVAL)

EFAULT vs EINVAL split (now grounded)

  • EFAULT: either copy-in of the 0x18 struct fails or copy-in of the 8-byte payload fails (both via 0xfffffe00086b7eb4).
  • EINVAL (0x16): field2 non-zero (fast path), or downstream lookup/validation failure.

Kernel validation and lookup: low-32 payload matched against [obj + 0xc0]

Validation helper: 0xfffffe0008757afc (KC)

Evidence: dumps/ghidra/out/14.4.1-23E224/kernel-collection-addr-window-dump/addr_window_dump_0xfffffe0008757afc.json

Key excerpt:

  • Reads only the low 32 bits of the 8-byte payload:

    • 0xfffffe0008757b20 ldr w0,[x0]
  • Calls internal lookup:

    • 0xfffffe0008757b28 bl 0xfffffe000876a30c
  • Returns EINVAL (0x16) when lookup fails (null return).

  • Uses selector after lookup to pick one of two vtable paths:

    • 0xfffffe0008757b3c cmp x22,#0x2
  • Observed secondary error:

    • 0xfffffe0008757be4 mov w20,#0x2d (vtable missing / alternate failure mode)

Internal id lookup: 0xfffffe000876a30c (KC)

Evidence: dumps/ghidra/out/14.4.1-23E224/kernel-collection-addr-window-dump/addr_window_dump_0xfffffe000876a30c.json

Key excerpt (match against +0xc0):

  • Iterates a list and compares payload low-32 (w22) against an object’s w9 = [obj + 0xc0]:

    • 0xfffffe000876a380 ldr w9,[x20, #0xc0]
    • 0xfffffe000876a384 cmp w9,w22
  • On match, a refcount-ish update is gated by w21 (second arg to lookup):

    • 0xfffffe000876a38c cbz w21, ...
    • 0xfffffe000876a390 ldr w8,[x20, #0x9a4]
    • 0xfffffe000876a39c str w8,[x20, #0x9a4]

Implication: the payload must supply the correct kernel-internal id equal to [obj + 0xc0]. Tokens, inode, or file metadata only matter if they can be mapped to that internal id.


Provenance closure: [obj + 0xc0] is kernel-constructed (not fileid/token-derived)

This closes the “what is [obj + 0xc0]?” question for Sonoma 14.4.1 using static KC artifacts.

List head used by lookup

From 0xfffffe000876a30c:

  • 0xfffffe000876a354 adrp x8,-0x1fff4283000
  • 0xfffffe000876a358 ldr x20,[x8, #0xfa0]

Computed list head address:

  • 0xfffffe000bd7dfa0 (page 0xfffffe000bd7d000 + 0xfa0)

Addr xrefs (from addr_lookup.json) show reads/writes to 0xfffffe000bd7dfa0 by:

Writer #1: FUN_fffffe0008765ed4 (direct, same list head)

Evidence:
dumps/ghidra/out/14.4.1-23E224/kernel-collection-addr-window-disasm/0xfffffe0008765fc4/addr_window_disasm.json

Store site:

  • 0xfffffe0008765fc4 stp w13,w8,[x20, #0xc0]

Decisive provenance one step upstream:

  • Loads a “type-ish” field:

    • 0xfffffe0008765f3c ldr w8,[x16, #0x18]
  • Reads a 16-bit global counter:

    • 0xfffffe0008765f44 ldrh w10,[x9, #0xd80]
  • Constructs the id in w13:

    • 0xfffffe0008765f5c mov w11,#0x18000000
    • 0xfffffe0008765f60 add w11,w11,w8, LSL #0x18
    • 0xfffffe0008765f70 bfxil w13,w10,#0x0,#0x10
  • Ensures uniqueness by scanning existing list entries and comparing:

    • existing [+0xc0] vs proposed w13
    • existing [+0xc4] vs w8
    • on collision: increments and retries
  • Finally stores:

    • [obj + 0xc0] = w13 (constructed id)
    • [obj + 0xc4] = w8 (type-ish)

Interpretation: [obj + 0xc0] is a kernel-minted id assembled from a class prefix (0x18000000), a type component (w8 << 24), and a low-16 counter component, with collision checking across the list.

Writer #2: FUN_fffffe0008a1ef8c (same list head, different constant)

Evidence:
dumps/ghidra/out/14.4.1-23E224/kernel-collection-addr-window-disasm/0xfffffe0008a1f1b0/addr_window_disasm.json

Store site:

  • 0xfffffe0008a1f1b0 stp w11,w10,[x8, #0xc0]

Provenance:

  • Loads a global counter:

    • 0xfffffe0008a1f1a8 ldr w10,[x10, #0xc0c]
  • Uses a different class prefix constant:

    • 0xfffffe0008a1f1ac mov w11,#0x22000000
  • Stores:

    • [obj + 0xc0] = 0x22000000
    • [obj + 0xc4] = counter

Interpretation: there are at least two object “classes” in the same list head namespace, with distinct high-bit prefixes and id constructions.

Bottom line from provenance

  • The id matched in 0xfffffe000876a30c ([obj + 0xc0]) is constructed in-kernel and is not directly derived from:

    • token string fields
    • inode (st_ino)
    • or other ordinary file metadata
  • Even if some userland-visible value correlates with an id in a given session, the generating inputs include kernel-internal state (global counters / class/type fields).


Updated EntitlementJail finding: corrected ABI produces stable outcomes; one st_dev-correlated path returns OK

EntitlementJail added a Sonoma-14.4.1 ABI-matching call variant and selector control:

  • New call variant: payload_ptr_selector

    • Builds the 0x18 struct with:

      • field0 = pointer to 8-byte payload
      • field1 = selector
      • field2 = 0 (forced)
  • Added --selector flag.

  • Ran correlation sweep in a single XPC session under fully_injectable_extensions:

    • issue → consume → update
  • Result: call shape is confirmed (no EFAULT), and outcomes are stable across 5 runs.

Candidate → outcome matrix (stable across 5 runs)

All candidates are interpreted as payload low-32 values (the only bytes the kernel uses in the lookup).

  • consume_rc (value 0):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL
  • st_dev (value 16777233 / 0x01000011):

    • sel0 EINVAL
    • sel1 ENOENT (errno 2)
    • sel2 OK
  • st_ino (value 114566121 / 0x06D…):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL
  • token_field_0 low32 (value 2584418386):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL
  • token_field_5 (value 26 / 0x1a):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL
  • token_field_7 (value 1):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL
  • token_field_10 (value 83 / 0x53):

    • sel0 EINVAL, sel1 EINVAL, sel2 EINVAL

Interpretation consistent with kernel path

  • Since the call shape is correct (no EFAULT) and results are stable, remaining EINVAL outcomes are best explained as lookup misses in 0xfffffe000876a30c (payload low-32 did not match any [obj + 0xc0] in the list).

  • The st_dev candidate producing non-EINVAL results (including OK under selector 2) implies:

    • for that payload value, the lookup is likely succeeding (otherwise it would be EINVAL), and subsequent selector-dependent logic determines the final return (ENOENT vs OK).
  • No access change has been observed tied to the OK case so far; the operational meaning of “success” remains unclear in userland-visible behavior.


Relationship to prior hypotheses and remaining loose ends

Earlier “token/inode should be enough” is now ruled out for this host

  • Kernel lookup key is [obj + 0xc0], and provenance shows it is kernel-constructed.

  • The sweep confirms that:

    • inode (st_ino) does not match
    • tested token numeric fields do not match

Earlier +0x150 sandbox-adjacent stores remain unlinked to this path

Previously identified KC stores to [x23 + 0x150] (e.g., 0xfffffe000b021978 str x0,[x23, #0x150]) are still not tied to the update_file_by_fileid lookup path, which definitively uses +0xc0.


Current conclusion (Sonoma 14.4.1)

  1. The correct kernel ABI for update_file_by_fileid is now identified and implementable from userland (0x18 struct + payload pointer + selector + field2==0).
  2. The lookup key is payload low-32 matched against a kernel-maintained object list’s [obj + 0xc0].
  3. Static KC analysis identifies writers that construct [obj + 0xc0] from class/type components and global counters, i.e., kernel-minted rather than fileid/token-derived.
  4. With the correct ABI, userland can drive the call without EFAULT, and can sometimes obtain non-EINVAL results; one observed case (st_dev with selector 2) returns OK, but no access change has been demonstrated.
  5. Without a way to obtain or predict the runtime [obj + 0xc0] id(s), there is still no general end-to-end userland method to use update_file_by_fileid as a reliable maintenance operation.

Practical stopping point and what would decide the remaining questions

  • To make update_file_by_fileid generally usable from userland, one of the following must become true:

    • a userland-visible API exposes the relevant internal id (or a stable mapping to it), or
    • the id construction can be derived from userland-observable quantities (currently contradicted by static provenance), or
    • kernel instrumentation/debugging can capture the runtime id(s) for correlation and mapping.
  • The single observed st_dev correlation (selector 2 returning OK) is now the most informative anomaly:

    • it suggests there is at least one reachable object id equal to that payload value in the tested environment, but it does not yet establish a general derivation rule nor a user-visible effect.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions