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:
- Stores an encrypted blob in CoreData at first login.
- At launch, decrypts the blob using a key derived from the device UDID.
- Holds the plaintext token in an
NSStringorchar *in the heap. - 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 type | Byte pattern | Notes |
|---|---|---|
| JWT | 65 79 4a (eyJ) | base64url header prefix |
| Bearer token | 42 65 61 72 65 72 20 (Bearer ) | before the token value |
| AWS access key | 41 4b 49 41 (AKIA) | 20-char alphanumeric |
| Stripe live key | 73 6b 5f 6c 69 76 65 (sk_live) | |
Generic api_key= | 61 70 69 5f 6b 65 79 3d | URL-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 !!