← andrd3v

Reverse Engineering Apple's DeviceCheck Token Generation

· by andrd3v · Repository

This document presents an end-to-end reverse engineering of Apple's DeviceCheck token generation flow on iOS, starting from the public DCDevice API and following execution through devicecheckd, DeviceCheckInternal.framework, and the underlying cryptographic primitives.

The goal is to provide a reproducible, engineering-grade description useful for security engineering, red teaming, and low-level reverse engineering work.

Chapter 1 — DeviceCheck.framework entry point

When creating a token from DCDevice, the method -[DCDevice generateTokenWithCompletionHandler:] is used — inside it calls DCDeviceMetadataDaemonConnection. This method creates a connection to the iPhone's devicecheckd daemon:

NSXPCConnection *xpc_connection = (NSXPCConnection *)objc_msgSend(
                              objc_alloc((Class)&OBJC_CLASS___NSXPCConnection),
                              "initWithMachServiceName:options:",
                              CFSTR("com.apple.devicecheckd"),
                              0LL);

devicecheckd

After connecting to the devicecheckd daemon via XPC, the following chain of calls starts. When a connection is received, the daemon invokes -[DCXPCListener listener:shouldAcceptNewConnection:]-[DCClientHandler initWithConnection:], and after the client makes an RPC call → -[DCClientHandler fetchOpaqueBlobWithCompletion].

First of all, -[DCClientHandler fetchOpaqueBlobWithCompletion] calls:

if ( -[DCClientHandler _isSupported](self, "_isSupported") )

The value of this variable is hard-coded in DeviceIdentityIsSupported from DeviceIdentity.framework:

__int64 DeviceIdentityIsSupported_1()
{
  return 1LL;
}

I assume that if DCDevice is unavailable on the device, then there will be a different build of the framework where it is hard-coded to 0.

After checking support, it calls -[DCClientHandler _generateAppIDFromCurrentConnection] — this method obtains <TeamID>.<BundleIdentifier> (format: ABCDE12345.com.example.myApp) using entitlements (-[NSXPCConnection valueForEntitlement:]), and if that fails, it uses SecTaskCopyTeamIdentifier and SecTaskCopySigningIdentifier.

If team_id is valid and not "0000000000", the method combines the team_id and bundle_id with a dot; otherwise it uses only the bundle_id. It returns an appID:

return [appID length] ? appID : nil;

Returning to -[DCClientHandler fetchOpaqueBlobWithCompletion]:

if ( app_id )
{
  DCContext_class = objc_alloc_init((Class)&OBJC_CLASS___DCContext);
  objc_msgSend(DCContext_class, "setClientAppID:", app_id);

  // allocate DCDDeviceMetadata and initialize DCCryptoProxyImpl
  DCDDeviceMetadata = objc_alloc((Class)&OBJC_CLASS___DCDDeviceMetadata);
  DCCryptoProxyImpl = objc_alloc_init((Class)&OBJC_CLASS___DCCryptoProxyImpl);

  init_DCDDeviceMetadata = objc_msgSend(
                             DCDDeviceMetadata,
                             "initWithContext:cryptoProxy:",
                             DCContext_class,
                             DCCryptoProxyImpl);

  objc_msgSend(init_DCDDeviceMetadata, "generateEncryptedBlobWithCompletion:", v4);
}

In summary, the devicecheckd daemon returns an encrypted token (opaque blob) to DeviceCheck.framework via XPC in the completion handler passed to -[DCDDeviceMetadata generateEncryptedBlobWithCompletion:]. This blob is later exposed to callers as the token returned by -[DCDevice generateTokenWithCompletionHandler:].

DCContext, DCDDeviceMetadata, and DCCryptoProxyImpl

The behavior described below is implemented in DeviceCheckInternal.framework.

The first thing that happens in -[DCClientHandler fetchOpaqueBlobWithCompletion] is the initialization of DCContext and assignment of our <TeamID>.<BundleIdentifier> to it:

DCContext_class = objc_alloc_init((Class)&OBJC_CLASS___DCContext);
objc_msgSend(DCContext_class, "setClientAppID:", app_id);

Since DCContext has no -init method of its own, it simply inherits the implementation from NSObject. The method -[DCContext setClientAppID:] sets self->_clientAppID:

id __cdecl __noreturn -[DCContext clientAppID](DCContext *self, SEL a2)
{
  return objc_getProperty_33(self, a2, 8LL, 1);
}

Now our DCDDeviceMetadata is initialized:

init_DCDDeviceMetadata = objc_msgSend(
                           DCDDeviceMetadata,
                           "initWithContext:cryptoProxy:",
                           DCContext_class,
                           DCCryptoProxyImpl_class_arg);

What happens during the initialization of DCDDeviceMetadata:

// instance memory layout
struct DCDDeviceMetadata // sizeof=0x18
{
    unsigned __int8 superclass_opaque[8];
    DCCryptoProxy *_cryptoProxy;
    DCContext *_context;
};

id -[DCDDeviceMetadata initWithContext:cryptoProxy:](DCDDeviceMetadata *self, SEL a2,
    id DCContext_class_arg, id DCCryptoProxyImpl_class_arg)
{
  DCDDeviceMetadata *dc_device_metadata = -[DCDDeviceMetadata init](self, "init");

  if ( dc_device_metadata )
  {
    j__objc_storeStrong((id *)&dc_device_metadata->_cryptoProxy, DCCryptoProxyImpl_class_arg);
    j__objc_storeStrong((id *)&dc_device_metadata->_context, DCContext_class_arg);
  }

  return (id *)dc_device_metadata;
}

All classes have been initialized and now the devicecheckd daemon calls generateEncryptedBlobWithCompletion: — a method that will begin generating the token.

• • •

Chapter 2 — DeviceCheckInternal.framework

Almost all methods are rewritten by me to make them easier to read and understand.

Our daemon invoked the method generateEncryptedBlobWithCompletion: — this is the start of token creation.

void __cdecl -[DCDDeviceMetadata generateEncryptedBlobWithCompletion:](
    DCDDeviceMetadata *self, SEL a2, id completion_arg)
{
  id v4 = objc_retain(completion_arg);

  DCCryptoProxy *cryptoProxy = self->_cryptoProxy;
  DCContext *context = self->_context;

  [cryptoProxy fetchOpaqueBlobWithContext:context
                              completion:^(NSData *blob, NSError *error) {
      // if data is non-zero, call completion(data, nil)
      // if data is zero, create NSError with code 0 and call completion(nil, error)
  }];
}

At this point, -[DCCryptoProxy fetchOpaqueBlobWithContext:completion:] is called with our context and the completion handler:

void __cdecl -[DCCryptoProxyImpl fetchOpaqueBlobWithContext:completion:](
        DCCryptoProxyImpl *self, SEL a2,
        id DCContext_arg, id completion_arg)
{
    id retainedContext = [DCContext_arg retain];
    void (^copiedCompletion)(NSData *, NSError *) = [completion_arg copy];

    if (os_log_type_enabled(self.logger, OS_LOG_TYPE_DEFAULT))
    {
      os_log(self.logger, "Generating certificate...");
    }

    __block id blockContext = retainedContext;
    __block void (^blockCompletion)(NSData *, NSError *) = copiedCompletion;

    [self _fetchPublicKey:^(NSData *publicKey) {
          DCCertificateGenerator *generator = [[DCCertificateGenerator alloc]
              initWithContext:blockContext
                     publicKey:publicKey];

          [generator generateEncryptedCertificateChainWithCompletion:
              ^(NSData *encryptedChain, NSError *error) {
                  blockCompletion(encryptedChain, error);
              }
          ];
      }
    ];
}

Getting the publicKey

First, the publicKey is obtained, and then it is passed on:

void __cdecl -[DCCryptoProxyImpl _fetchPublicKey:](DCCryptoProxyImpl *self, SEL a2, id completion)
{
  DCAssetFetcher *fetcher = [DCAssetFetcher sharedFetcher];
  [fetcher fetchPublicKeyAssetWithCompletion:^(NSData *publicKey) {
      completion(publicKey);
  }];
}

This method calls -[DCAssetFetcher fetchPublicKeyAssetWithCompletion:]:

void __cdecl -[DCAssetFetcher fetchPublicKeyAssetWithCompletion:](
    DCAssetFetcher *self, SEL a2, id publicKeyCompletion)
{
  DCAssetFetcherContext *context = [[DCAssetFetcherContext alloc] init];
  void (^completionBlock)(NSData *) = [publicKeyCompletion retain];

  [context setAllowCatalogRefresh:NO];
  [self _fetchAssetWithContext:context
        completionHandler:completionBlock];
}

We continue following the call chain into _queryMetadataWithContext::

void __cdecl -[DCAssetFetcher _queryMetadataWithContext:completion:](
        DCAssetFetcher *self, SEL a2,
        DCAssetFetcherContext *context, id completion)
{
    id assetQuery = [[self _assetQuery] retain];
    NSUInteger resultCode = [assetQuery queryMetaDataSync];

    if ([retainedContext ignoreCachedMetadata] || resultCode == 2)
    {
        [self _handleMissingMetadataWithContext:retainedContext
                                   completion:completionBlock];
    } else {
      if (resultCode != 0)
      {
          NSError *error = [NSError errorWithDomain:@"com.apple.twobit.fetcherror"
                                               code:0xFFFF_FFFF_FFFF_F448
                                           userInfo:nil];
          completionBlock(nil, error);
          return;
      }

      [self _handleSuccessForQuery:assetQuery
                         completion:completionBlock];
    }
}

The asset validation chain resolves through _handleSuccessForQuery:_validateAsset:+[DCAsset assetWithMobileAsset:], which extracts the public key from com.apple.devicecheck.pubvalue:

+ (DCAsset *)assetWithMobileAsset:(NSDictionary *)mobileAsset {
    NSNumber *version = mobileAsset[@"com.apple.MobileAsset.AssetVersion"];
    if (![version isKindOfClass:[NSNumber class]] || version.integerValue != 1) {
        return nil;
    }

    NSData *pubKeyData = mobileAsset[@"com.apple.devicecheck.pubvalue"];
    if (![pubKeyData isKindOfClass:[NSData class]] || pubKeyData.length == 0) {
        return nil;
    }

    DCAsset *asset = [[DCAsset alloc] init];
    asset.version = 1;
    asset.publicKey = pubKeyData;

    NSNumber *refreshInterval = mobileAsset[@"com.apple.devicecheck.refreshtimer"];
    if ([refreshInterval isKindOfClass:[NSNumber class]]) {
        asset.publicKeyRefreshInterval = refreshInterval.doubleValue;
    }

    return asset;
}

Interestingly, none of this metadata fetching is required for the key material itself: the effective public key is embedded in __37__DCCryptoProxyImpl__fetchPublicKey___block_invoke:

+[NSData dataWithBytes:length:](&OBJC_CLASS___NSData, "dataWithBytes:length:",
    &fallback_server_pubkey, 65LL);

It is hard-coded and the same in all versions of macOS, iOS, and iPadOS:

0450d934fa67bcf6f2dfbf96629e0a7238e9205d75f28cfcd84f35a6
592bbe058a9c0f8edbca2acb67efb774971ca45f7d856a694fb1b9c40
b94fb2e7a5a9498b0

This is 130 hex characters — the key itself is 65 bytes. This value is the public key used by DeviceCheck.

• • •

Chapter 3 — Token encryption

Almost all methods are rewritten by me to make them easier to read and understand.

Returning to fetchOpaqueBlobWithContext: — after the public key is fetched, a DCCertificateGenerator is initialized:

struct DCCertificateGenerator // sizeof=0x18
{
    unsigned __int8 superclass_opaque[8];
    NSData *_publicKey;
    DCContext *_context;
};

id -[DCCertificateGenerator initWithContext:publicKey:](
    DCCertificateGenerator *self, SEL a2, id context_arg, id publicKey_arg)
{
  DCCertificateGenerator *v9 = -[DCCertificateGenerator init](self, "init");
  if ( v9 )
  {
    j__objc_storeStrong((id *)&v9->_publicKey, publicKey_arg);
    j__objc_storeStrong((id *)&v9->_context, context_arg);
  }
  return (id *)v9;
}

After successful initialization, generateEncryptedCertificateChainWithCompletion is called. Conceptually, _generateCertificateChainWithCompletion retrieves a certificate chain from the keychain and passes it into _encryptData, where the final token is constructed.

Two root certificates are fetched from the keychain in __DeviceIdentityIssueClientCertificateWithCompletion_block_invoke; representative examples:

-----BEGIN CERTIFICATE----- MIIDPjCCAuWgAwIBAgIGAZhghIx7MAoGCCqGSM49BAMCMFMxJzAlBgNVBAMMHkJh c2ljIEF0dGVzdGF0aW9uIFVzZXIgU3ViIENBMTETMBEGA1UECgwKQXBwbGUgSW5j LjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yNTA3MzAxMjQ1NTZaFw0yNjA3MDQy ... -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIICIzCCAaigAwIBAgIIeNjhG9tnDGgwCgYIKoZIzj0EAwIwUzEnMCUGA1UEAwwe QmFzaWMgQXR0ZXN0YXRpb24gVXNlciBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTE3MDQyMDAwNDIwMFoXDTMyMDMy ... -----END CERTIFICATE-----

The core of the token logic lives in _encryptData, which builds and encrypts the final payload:

- (NSData *)_encryptData:(NSData *)data // certificates
        serverSyncedDate:(NSDate *)serverDate
                    error:(NSError **)error {

    // Get client App ID as UTF-8 data
    NSString *clientAppID = [self.context clientAppID];
    NSData *clientAppData = [clientAppID dataUsingEncoding:NSUTF8StringEncoding];

    // Current timestamp
    NSTimeInterval timestamp = [serverDate timeIntervalSince1970];

    // AES-GCM mode from CommonCrypto
    const struct ccmode_gcm *gcm = ccaes_gcm_encrypt_mode();

    // Allocate buffers
    size_t payloadLen = inputLen + clientAppLen + 81;
    size_t outputLen  = inputLen + clientAppLen + 235;
    uint8_t *outBuf     = calloc(1, outputLen);
    uint8_t *payloadBuf = calloc(1, payloadLen);

    // Write header (type = 2) and payload length
    *((uint32_t *)outBuf) = 2;
    *((uint32_t *)(outBuf + 150)) = (uint32_t)payloadLen;

    // Copy device's static public key into outBuf at offset 5
    memcpy(outBuf + 5, devicePubKeyData.bytes, devicePubKeyData.length);

    // Assemble payload:
    // [timestamp(8B), inputLen(4B), clientAppLen(4B), inputBytes, clientAppBytes]
    *((uint64_t *)(payloadBuf + 65)) = (uint64_t)timestamp;
    *((uint32_t *)(payloadBuf + 73)) = (uint32_t)inputLen;
    *((uint32_t *)(payloadBuf + 77)) = (uint32_t)clientAppLen;
    memcpy(payloadBuf + 81, inputBytes, inputLen);
    memcpy(payloadBuf + 81 + inputLen, clientAppBytes, clientAppLen);

    // Create ephemeral ECDH key via keybag
    id keybag = [self keybagHandle];
    uint64_t refKey = 0;
    int aksErr = aks_ref_key_create((__int64)keybag, 11, 4, 0, 0, &refKey);

    // Get ephemeral public key (65 bytes)
    size_t ecdhPubLen = 0;
    const uint8_t *ecdhPub = aks_ref_key_get_public_key(refKey, &ecdhPubLen);
    memcpy(outBuf + 85, ecdhPub, ecdhPubLen);

    // ECDH shared secret with device's static public key
    aks_ref_key_compute_key(refKey, 0, 0, devicePubBytes, devicePubLen);

    // Derive key material: HKDF-SHA256 -> 44 bytes (32B key + 12B IV)
    uint8_t hkdfOut[44];
    cchkdf(ccsha256_di(), sharedLen, sharedSecret, 0, NULL, 0x2C, hkdfOut);
    uint8_t *aesKey = hkdfOut;
    uint8_t *aesIV  = hkdfOut + 32;

    // Encrypt with AES-GCM
    ccgcm_one_shot(gcm,
                   32, aesKey,
                   12, aesIV,
                   0, NULL,
                   payloadLen, payloadBuf,
                   outBuf + 154,    // ciphertext
                   16, outBuf + 1); // GCM tag

    NSData *encryptedData = [NSData dataWithBytes:outBuf length:outputLen];

    free(outBuf);
    free(payloadBuf);
    return encryptedData;
}
• • •

Chapter 4 — Conclusion and security implications

This write-up traces the full DeviceCheck token generation path: from calling DCDevice in an app, through XPC into the devicecheckd daemon, into DeviceCheckInternal.framework, and finally to _encryptData, where the payload is constructed and encrypted.

Key stages are covered — initializing the context with TeamID.BundleID, resolving the public key (including the hard-coded fallback), assembling the certificate chain, and forming the opaque blob returned to the caller.

From a security and red-team perspective, this analysis provides the primitives needed to reason about DeviceCheck's trust model, to emulate or instrument the client in controlled environments, and to assess how robustly backends validate and interpret DeviceCheck tokens.

Work by andrd3v. Thanks for the help, whoeevee.