Grego AI | Apple PCC: Revocation Check Skipped

Apple

CloudAttestation

Apple PCC: Revocation Check Skipped

We found a bug that let servers Apple had stopped trusting keep reading people's private Apple Intelligence requests.

Failure path

EnsembleValidator requested certificate revocation checks, but X509Policy.init stored nil instead of the supplied policy. Trust evaluation never consulted Apple's CRL or OCSP services, so a revoked, unexpired DCIK certificate could pass admission.

Impact and conditions

A revoked node with an unexpired DCIK certificate could rejoin an ensemble when its attestation and membership checks passed. It could receive the shared CIO seed and derive mesh keys protecting in-flight Apple Intelligence request data.

Background

When your iPhone decides an Apple Intelligence request is too heavy to run locally, it sends it to Private Cloud Compute, Apple’s cloud that’s built so nobody (Apple included) can see your data.

Each PCC node is an Apple silicon server, and its Secure Enclave holds the node’s identity key, the DCIK (Data Center Identity Key). Big models get split across an ensemble of nodes that pass activations and KV-cache back and forth. That’s your request data in flight, encrypted with mesh keys that every node derives from one shared secret, the CIO seed.

To join, a node connects to its peers over Attested TLS, and they run EnsembleValidator on it: certificate chain, Secure Enclave signature, software measurements, and a membership list. The idea is simple: prove who you are, prove what you’re running, get the keys.

Revoked node passes certificate and attestation checks because revocation lookup never runs, then receives the CIO seed and mesh keys.

Figure 1. Everything in the middle column has to pass before the leader shares the CIO seed. The revocation lookup is the one row that never runs.

Revocation is the part of that gate meant to catch a node Apple no longer trusts. Every production DCIK cert points to Apple’s public OCSP responder and CRL (http://crl.apple.com/dcarcag2.crl). At the time of reporting, that CRL listed 48 DCIK certificates revoked on April 16, 2026.

Revoked isn’t expired, either. The DCIK cert we looked at is valid from 2024 to 2031, so revocation means Apple stepping in years early: a key leaked, the hardware got tampered with or walked out of a data center, or for whatever reason the machine just shouldn’t be touching user data anymore.

The key line

The whole bug lives in the initializer of X509Policy, the part of CloudAttestation that checks a node’s certificate (X509Policy.swift):

public init(required: Bool, roots: [SecCertificate], clock: Date? = nil, revocation: RevocationPolicy? = nil) {
    self.required = required
    self.roots = roots
    self.clock = clock
    self.body = nil
    self.revocation = nil   // the `revocation` argument is never used
}

Every other argument gets stored. revocation gets ignored and the field is set to nil. The caller hands it a revocation policy, and the initializer says “thanks, but no thanks”. That’s the only place self.revocation is ever written, so every X509Policy built from this code has revocation == nil, no matter what the caller asked for.

Here’s what that field was supposed to switch on, in evaluateCertificateChain:

var networkFetchAllowed = false
...
policies = [dcAttestationPolicy]        // chains to Apple's Data Center Attestation root

if let revocation {                      // always nil, so this never runs
    guard let revocationPolicy = SecPolicyCreateRevocation(revocation.rawValue) else {
        throw Error.invalidRevocationPolicy
    }
    networkFetchAllowed = true
    policies.append(revocationPolicy)
}
...
result = SecTrustSetNetworkFetchAllowed(trust, networkFetchAllowed)   // always false
...
guard SecTrustEvaluateWithError(trust, &error) else {
    throw Error.untrusted(cause: error!)
}

That if let is the only place a revocation SecPolicy ever gets created, and the only place network fetching gets turned on (which trustd needs to actually go ask the CRL or OCSP server).

With revocation stuck at nil, the check looks at the chain and the dates but never asks about revocation. A revoked cert and a healthy one look identical.

So… who actually asks for revocation? Exactly one caller: EnsembleValidator, the code PCC nodes run on each other. On every production node it switches the check on and asks for [.any]:

// EnsembleValidator.validate(): true on every production node
copy.checkRevocation = true

// EnsembleValidator.policy
let revocationPolicy: X509Policy.RevocationPolicy? = self.checkRevocation ? [.any] : nil
X509Policy(required: self.strictCertificateValidation, roots: self.trustAnchors,
           clock: self.clock, revocation: revocationPolicy)   // ...which init throws away

So the strictest, most production-y config in the codebase asks for revocation and silently gets none. The other validators (NodeValidator, MuxValidator) never ask at all, so this was the one place revocation was supposed to matter.

Expected policy rejects the revoked node after a CRL or OCSP lookup; shipped policy skips revocation and admits it with mesh keys.
Figure 2. Same call, two outcomes. Left is what [.any] should have done. Right is what actually runs, row by row.

Shipping Binary Inspection

Source is nice, but it’s not what actually runs. So we looked at a real compiled binary.

CloudAttestation ships as a private framework on macOS, living inside the dyld shared cache. On an Apple silicon Mac running macOS 15.7.3 (24G419), one command dumps it:

dyld_info -disassemble \
  /System/Library/PrivateFrameworks/CloudAttestation.framework/Versions/A/CloudAttestation \
  > ca_disasm.txt

Note that the framework file on disk is just a stub, so otool and nm show nothing. dyld_info pulls it out of the shared cache.

That build is from before the 26.4 fix. The ensemble code only runs on PCC nodes, but macOS ships the same framework, so it’s a nice way to see what the compiler actually did with the source.

Here’s X509Policy.init(required:roots:clock:revocation:), trimmed to the interesting bits:

0x22D7AAE64   mov    x19, x2            ; clock
0x22D7AAE68   mov    x20, x1            ; roots
0x22D7AAE6C   mov    x21, x0            ; required
0x22D7AAE70   mov    x22, x8            ; self
0x22D7AAE78   bl     X509PolicyVMa      ; first call, x3/w4 (revocation) were never saved
...
0x22D7AAEC8   ldrsw  x8, [x23, #0x1c]   ; field offset of self.revocation
0x22D7AAECC   add    x26, x22, x8       ; x26 = &self.revocation
0x22D7AAED0   str    xzr, [x26]         ; default value of the optional property
0x22D7AAED4   strb   w25, [x26, #0x8]
...
0x22D7AAEE0   strb   w21, [x22]         ; self.required = required
0x22D7AAEE4   str    x20, [x22, #0x8]   ; self.roots = roots
0x22D7AAEF0   bl     0x22d789a34        ; self.clock = clock
0x22D7AAEF4   stp    xzr, xzr, [x23]    ; self.body = nil
0x22D7AAEF8   str    xzr, [x26]         ; self.revocation = nil, value 0
0x22D7AAEFC   strb   w25, [x26, #0x8]   ; Optional tag 1, i.e. .none

Swift first sets the optional fields to their default nil, then runs the body in order: required, roots, clock, body, then revocation. That last write is a zero plus tag 1, aka nil. The real revocation argument comes in on x3/w4, and the function never even looks at those registers before its first call. A fixed build would store x3/w4 there. This one hardcodes nil. Line 78, compiled.

The other half lives in evaluateCertificateChain, which got inlined:

0x22D7AA9A0   bl     _SecPolicyCreateDCAttestation    ; always added
...
0x22D7AA9F8   ldrb   w19, [x8, #0x8]                  ; w19 = Optional tag of self.revocation
0x22D7AA9FC   cmp    w19, #0x1                        ; tag 1 means nil
...
0x22D7AAA04   b.ne   0x22d7aaa4c                      ; only a non-nil value goes to the revocation path
...
0x22D7AAA60   bl     _SecPolicyCreateRevocation       ; dead, never reached
...
0x22D7AAB08   eor    w1, w19, #0x1                    ; networkFetchAllowed = (revocation != nil) = 0
0x22D7AAB0C   mov    x0, x23                          ; trust
0x22D7AAB10   bl     _SecTrustSetNetworkFetchAllowed

Our favorite line is eor w1, w19, #0x1. The compiler folded networkFetchAllowed right into the Optional’s tag byte, so it’s literally computing “fetch allowed = revocation isn’t nil”. Init always writes tag 1, so SecTrustSetNetworkFetchAllowed always gets 0 and SecPolicyCreateRevocation never runs.

So… the binary does exactly what the source says. It asks for revocation, stores nil, and then never checks.

From handshake to mesh keys

So we know that a revoked cert gets through X509Policy. Does anything after that catch it? Nope. Here’s what happens when a revoked node (call it N) joins or rejoins an ensemble:

  1. Handshake. N connects to a peer over mutual TLS 1.3 and shows a fresh certificate with its attestation tucked inside. The peer hands that to EnsembleValidator.
  2. X509Policy. N’s DCIK cert chains to Apple’s root and hasn’t expired. Revocation should’ve stopped it here, but thanks to the nil, it passes.
  3. SEP attestation. N still has its real key in its real Secure Enclave, so the signature checks out.
  4. Membership. The only membership check is “is N’s UDID in the ensemble config?” It is. Revoking a cert doesn’t change a UDID, and nothing edits that config at runtime.
  5. Everything else. AP ticket, SEP image, cryptexes, secure config, software release, hardware, device mode, darwin-init. N is real hardware running the real release, so it’s all green.
  6. Key binding. The attested key matches the TLS key and hasn’t expired. N is in.

Once N is in, the leader sends it the ensemble’s shared secret, and N derives the mesh keys from it (EnsemblerTLS.swift, setCryptoKey):

case .ensembleAcceptAndshareCIOKey(let cioKey):
    self.sharedKey = SecureSymmetricKey(data: cioKey)
...
guard let derivedKey = try sharedKey?.getDerivedKey(type: .MeshEncryption) else { ... }
self.initialSharedkey = try self.sharedKey?.getDerivedKey(type: .TlsPsk)

It’s one seed for the whole ensemble, and the mesh key comes from it. So N doesn’t just get its own slice of the work. It has the same keys as every other node, which covers all the activations and KV-cache flying between them.

Here’s the punchline: revocation was the only check asking “does Apple still trust this node?” Everything else asks “is this real Apple hardware running the right software?”, and a revoked node is exactly that.

Sixteen authenticity checks pass; the one revocation check never runs, so the node receives the CIO seed and mesh keys.

Figure 3. Sixteen checks ask “is it genuine?” and a revoked node passes all of them. One check asks “does Apple still trust it?” and that’s the one that never runs.

The End Result

This snippet pasted from our triaging scratchpad explains it well:

revoked node → untrustworthy → never kicked out → keeps reading sensitive user request data

Simply put, a revoked node is one Apple decided can’t be trusted with user data anymore. With this bug it just keeps doing its normal job: sitting in the ensemble, holding the mesh keys, and handling the activations and KV-cache of every request that ensemble serves.

And if the DCIK key itself leaked (the first reason we mentioned), it’s worse. Whoever has that key can sign attestations claiming whatever software they want, and revocation was the one check left that could’ve stopped them.

Notably, in ensembles revocation only gets checked at the entrance. Nothing watches the CRL, and nothing kicks a node out of a running ensemble when it gets revoked. Revocation only gets a say during a new handshake, and those happen a lot:

  • Every key rotation. Attestations last 8 hours (TLSChannel.swift), and when the shared key rotates, nodes reconnect with a fresh one.
  • Every reconnect. Dropped connections retry and handshake again.
  • Every reboot or re-formation. New ensemble, new handshakes.

Each one re-checks the same DCIK cert, since a refresh only makes a new TLS key, not a new identity cert. Each one was a chance to shut the door, and each one let the revoked node right back in. And since DCIK certs last for years, “revoked but not expired” isn’t a short window. It can be the rest of the machine’s life.

The Fix

The fix we proposed is the most satisfying kind: one line.

-        self.revocation = nil
+        self.revocation = revocation

That makes the if let revocation branch reachable, so [.any] turns into a real revocation check and trustd actually asks Apple’s CRL and OCSP servers about the node. A revoked serial comes back revoked, the handshake fails, and N never sees the keys.

Plus a few extras we suggested:

  • A regression test that builds X509Policy(…, revocation: [.any]) and checks that a revoked cert actually gets rejected. The public source has no revocation test at all, which is how a one-liner like this survives multiple releases.
  • Fail closed in prod. Ask for [.any, .requirePositiveResponse], so a node whose status can’t be confirmed gets rejected instead of let through.
  • Tie membership to revocation. When Apple revokes a node, pull its UDID from the ensemble config and re-form, so it’s out right away instead of at the next handshake.

The nil was there in the very first public source release, and it’s still in the public source today. Apple fixed it in shipping software with the 26.4 releases in March 2026.

Acknowledgements

Apple confirmed our report and credited GregoAI under Additional recognition (CloudAttestation) on August 20, 2026, in four advisories:

Thanks to Apple Product Security for a pleasant back-and-forth, and to the PCC team for publishing the source so bugs like this can be found!

References