Home iOS - CCCrypt Live Interception
Post
Cancel

iOS - CCCrypt Live Interception

CCCrypt is the one-shot CommonCrypto function that the majority of iOS apps reach for when they want AES or DES. Static analysis tells you the algorithm constant. It does not tell you the runtime key or the plaintext being encrypted. CCCrypt passes both as pointer arguments. Hook the function, read the pointers, you have the key, the IV, and the data. This collapses any symmetric encryption an app does - no matter how the key was derived - into a cleartext read.


The CCCrypt signature

1
2
3
4
5
6
7
8
9
10
11
12
13
CCCryptorStatus CCCrypt(
    CCOperation  op,         // 0 = kCCEncrypt, 1 = kCCDecrypt
    CCAlgorithm  alg,        // algorithm enum (see below)
    CCOptions    options,    // kCCOptionPKCS7Padding=1, kCCOptionECBMode=2
    const void  *key,        // raw key bytes
    size_t       keyLength,  // key length in bytes (16=AES128, 32=AES256)
    const void  *iv,         // IV pointer (NULL for ECB)
    const void  *dataIn,     // input bytes
    size_t       dataInLength,
    void        *dataOut,    // output buffer
    size_t       dataOutAvailable,
    size_t      *dataOutMoved
);

Every argument is available in args[] at onEnter. The key and plaintext are readable before the crypto operation executes.


Algorithm enum table

ConstantValueNotes
kCCAlgorithmAES0AES, the common case
kCCAlgorithmDES156-bit key, deprecated
kCCAlgorithm3DES23 x 56-bit key
kCCAlgorithmCAST3 
kCCAlgorithmRC44stream cipher
kCCAlgorithmRC25 
kCCAlgorithmBlowfish6 


Hooking CCCrypt for one-shot mode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const CCCrypt = Module.findExportByName('libcommonCrypto.dylib', 'CCCrypt');
Interceptor.attach(CCCrypt, {
    onEnter: function(args) {
        const op      = args[0].toInt32();  // 0=encrypt, 1=decrypt
        const algo    = args[1].toInt32();  // kCCAlgorithmAES=0
        const opts    = args[2].toInt32();  // kCCOptionPKCS7Padding=1, ECB=2
        const keyLen  = args[4].toInt32();
        const key     = args[3].readByteArray(keyLen);
        const ivPtr   = args[5];
        const iv      = ivPtr.isNull() ? null : ivPtr.readByteArray(16);
        const dataLen = args[7].toInt32();
        const data    = args[6].readByteArray(Math.min(dataLen, 512));

        const hexKey  = Array.from(new Uint8Array(key))
            .map(b => b.toString(16).padStart(2, '0')).join('');
        const hexIV   = iv ? Array.from(new Uint8Array(iv))
            .map(b => b.toString(16).padStart(2, '0')).join('') : '(null)';

        console.log('[CCCrypt] op=' + op + ' algo=' + algo + ' opts=' + opts);
        console.log('[CCCrypt] keyLen=' + keyLen + ' key=' + hexKey);
        console.log('[CCCrypt] iv=' + hexIV);
        console.log('[CCCrypt] dataLen=' + dataLen);
        console.log('[CCCrypt] data=' + Array.from(new Uint8Array(data))
            .map(b => b.toString(16).padStart(2, '0')).join(''));
    }
});

Run this and trigger any crypto operation in VulnLabAppiOS. In CryptoViewController, the key vulnkey appears as 76756c6e6b6579 and the IV is 16 zero bytes (00000000000000000000000000000000).


On VulnLabAppiOS: what the hook surfaces

  • Key: vulnkey - a short, hardcoded ASCII string, 7 bytes, padded to 16 for AES-128
  • IV: 00000000000000000000000000000000 - all zeros
  • Algorithm: 0 (AES)
  • Options: 1 (PKCS7 padding), no ECB flag - so CBC mode with a zero IV

A zero IV under CBC with a static key means the first block of every message with the same content is identical ciphertext. Known-plaintext attack works trivially. With the key and IV extracted, decrypt any captured ciphertext offline:

1
2
3
4
5
6
from Crypto.Cipher import AES
key = b'vulnkey\x00\x00\x00\x00\x00\x00\x00\x00\x00'  # padded to 16 bytes
iv  = b'\x00' * 16
ct  = bytes.fromhex('<captured ciphertext hex>')
cipher = AES.new(key, AES.MODE_CBC, iv)
print(cipher.decrypt(ct))


Streaming mode: CCCryptorCreate / CCCryptorUpdate / CCCryptorFinal

Apps that encrypt large payloads use the streaming API. Hook CCCryptorCreate for the key, CCCryptorUpdate for the chunks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const CCCryptorCreate = Module.findExportByName('libcommonCrypto.dylib', 'CCCryptorCreate');
Interceptor.attach(CCCryptorCreate, {
    onEnter: function(args) {
        const op     = args[0].toInt32();
        const algo   = args[1].toInt32();
        const opts   = args[2].toInt32();
        const keyLen = args[4].toInt32();
        const key    = args[3].readByteArray(keyLen);
        const ivPtr  = args[5];
        const iv     = ivPtr.isNull() ? null : ivPtr.readByteArray(16);
        const hexKey = Array.from(new Uint8Array(key))
            .map(b => b.toString(16).padStart(2, '0')).join('');
        console.log('[CCCryptorCreate] op=' + op + ' algo=' + algo + ' key=' + hexKey);
        if (iv) console.log('[CCCryptorCreate] iv=' + Array.from(new Uint8Array(iv))
            .map(b => b.toString(16).padStart(2, '0')).join(''));
    }
});

const CCCryptorUpdate = Module.findExportByName('libcommonCrypto.dylib', 'CCCryptorUpdate');
Interceptor.attach(CCCryptorUpdate, {
    onEnter: function(args) {
        const dataLen = args[2].toInt32();
        const data    = args[1].readByteArray(Math.min(dataLen, 256));
        console.log('[CCCryptorUpdate] plaintext chunk (' + dataLen + ' bytes)');
        try {
            // attempt UTF-8 read first
            console.log(args[1].readUtf8String(dataLen));
        } catch(e) {
            console.log(Array.from(new Uint8Array(data))
                .map(b => b.toString(16).padStart(2, '0')).join(''));
        }
    }
});

The onEnter of CCCryptorUpdate fires once per chunk with the plaintext before encryption (or ciphertext before decryption). For decrypt operations, op=1, the args[1] contains ciphertext and the output buffer after the call contains plaintext.


Decrypting captured ciphertext offline

Once the hook gives you the key and IV, any ciphertext captured from the network or from disk can be decrypted without the device:

1
2
3
4
5
6
7
# OpenSSL one-liner (hex key and IV from hook output)
echo '<ciphertext hex>' | xxd -r -p \
  | openssl enc -d -aes-128-cbc \
    -K 76756c6e6b657900000000000000000 \
    -iv 00000000000000000000000000000000 \
    -nosalt \
  | xxd

The key and IV the hook printed are the only inputs required.


What to do when the function is not CCCrypt

Some apps wrap CommonCrypto in a C++ layer or use CryptoKit. For CryptoKit, hook AES.GCM.seal and AES.GCM.open at the Swift level:

1
2
3
4
// Swift-mangled name; use 'nm' or class-dump to find the actual symbol
const sealSym = Process.findModuleByName('CryptoKit')
    .findExportByName('_$s9CryptoKit3AESO3GCMO4seal_5using5nonce12authenticatingQr_AA13SymmetricKeyV');
// Attach and read args

The mangled name changes per Swift toolchain version. Use nm Payload/VulnLabAppiOS.app/VulnLabAppiOS | grep -i 'aes.*seal\|seal.*aes' to find the exact symbol.


Closing

CCCrypt is the most common iOS crypto call. Its arguments are plaintext at hook time. The hook is four lines in Frida. Key extraction reduces any app-level symmetric encryption to a cleartext read.

Happy Hacking !!

This post is licensed under CC BY 4.0 by the author.