Home iOS - Runtime Memory Scanning for Secrets
Post
Cancel

iOS - Runtime Memory Scanning for Secrets

Static analysis finds secrets that are in the binary. It misses secrets that are assembled at runtime - a token decrypted from CoreData at launch, a JWT built from a stored key and a fresh timestamp, an API key fetched from a remote config endpoint and held only in memory. These exist exclusively as live data in the process heap and registers. Memory.scan() finds them anyway.


Why static analysis misses runtime material

Consider an app that:

  1. Stores an encrypted blob in CoreData at first login.
  2. At launch, decrypts the blob using a key derived from the device UDID.
  3. Holds the plaintext token in an NSString or char * in the heap.
  4. Never writes the plaintext anywhere on disk.

strings on the binary shows the CoreData model name and the decryption routine symbols, not the token. Ghidra shows the decryption logic, not the key. The token exists only at process runtime, in memory. Frida can read it there.


Memory.scan() for Bearer tokens

Scan all readable memory regions for the 7-byte ASCII pattern Bearer :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hex: 42 65 61 72 65 72 20
Process.enumerateRanges({ protection: 'r--', coalesce: true }).forEach(function(range) {
    Memory.scan(range.base, range.size, '42 65 61 72 65 72 20', {
        onMatch: function(address, size) {
            // Read up to 256 bytes starting at the match
            try {
                const token = address.readUtf8String(256);
                console.log('[Bearer] @ ' + address + ' -> ' + token);
            } catch(e) {}
        },
        onError: function(reason) {},
        onComplete: function() {}
    });
});

Include r-x ranges too if you want to catch strings that landed in executable pages:

1
Process.enumerateRanges({ protection: 'r', coalesce: false })

protection: 'r--' catches heap, stack, and data segments. Most live tokens land on the heap, so r-- is the right starting filter.


Pattern variants

Secret typeByte patternNotes
JWT65 79 4a (eyJ)base64url header prefix
Bearer token42 65 61 72 65 72 20 (Bearer )before the token value
AWS access key41 4b 49 41 (AKIA)20-char alphanumeric
Stripe live key73 6b 5f 6c 69 76 65 (sk_live) 
Generic api_key=61 70 69 5f 6b 65 79 3dURL-param style

Run each pattern in a separate scan or combine with a wildcard mask if the frida version supports it.


ObjC.choose for live NSString heap scan

Frida can walk the Objective-C heap and enumerate every live NSString instance. Filter by content:

1
2
3
4
5
6
7
8
9
10
11
12
13
ObjC.choose(ObjC.classes.NSString, {
    onMatch: function(str) {
        var s = str.toString();
        // filter for anything that looks like a secret
        if (s.includes('Bearer') || s.includes('eyJ') || s.includes('token') ||
            s.includes('sk_live') || s.includes('AKIA') || s.length > 40) {
            console.log('[NSString heap] ' + s);
        }
    },
    onComplete: function() {
        console.log('[ObjC.choose] scan complete');
    }
});

This is slower than Memory.scan because it walks the Objective-C object graph, but it finds tokens wrapped in NSString objects even when there is no fixed byte prefix to scan for.


Scoping to the app heap only

Process.enumerateRanges includes system libraries, CoreData internals, and UIKit. Narrow to regions that belong to the app bundle to reduce noise:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const appBundle = ObjC.classes.NSBundle['+ mainBundle'].call(ObjC.classes.NSBundle)
    .bundlePath().toString();

Process.enumerateRanges({ protection: 'rw-', coalesce: false }).forEach(function(range) {
    // include only anonymous heap ranges (no path) or ranges in the app bundle
    if (!range.file || range.file.path.includes(appBundle) || range.file.path === '') {
        Memory.scan(range.base, range.size, '42 65 61 72 65 72 20', {
            onMatch: function(address) {
                console.log('[app heap] Bearer @ ' + address + ' = ' +
                    address.readUtf8String(128));
            },
            onError: function() {}
        });
    }
});

Heap pages are typically anonymous (no range.file path). Library pages have a file path. Filtering out file-backed pages keeps the scan focused on the app’s dynamic allocations.


Practical example: finding a CoreData-decrypted session token

Trigger login in VulnLabAppiOS. The app fetches and decrypts a session_token field from a CoreData entity:

1
2
let token = decrypt(storedBlob, key: derivedKey)   // plaintext lands in memory
self.sessionToken = token

After login completes, run the Bearer scan. The token appears at an address inside the process heap. Read 200 bytes from that address to capture the full value including any surrounding context (key name, structure offset).

Replay it against the API directly:

1
2
curl -H "Authorization: Bearer <extracted_token>" \
  https://api.vulnlabapp.example.com/v1/user/profile

If it returns 200 OK, the token is valid and was never written to disk in plaintext.


When to run the scan

  • Immediately after login - captures session tokens assembled from login response.
  • Before any network call that uses auth - captures tokens being constructed for the request header.
  • After background refresh - captures tokens refreshed by background tasks.

Timing matters. Tokens on the stack are freed after the function returns. Tokens in instance variables persist as long as the object lives. ObjC.choose finds both, Memory.scan finds both if the heap page has not been zeroed.


Automating with a stalker trace

For tokens assembled via string concatenation across multiple calls, Stalker can capture the assembly sequence. That is heavier instrumentation. Memory.scan after the operation completes is the right first step.


Closing

Heap scanning fills the gap that static analysis and Keychain audits leave. Any secret that exists in process memory at any point is recoverable via Memory.scan or ObjC.choose. Run both patterns after every authentication flow, before every network call. The combination surfaces secrets that never touch disk.

Happy Hacking !!

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