Logout in iOS apps is often a UI event, not a storage cleanup. The app sets a boolean flag like isLoggedIn = false in UserDefaults and navigates to the login screen. It does not delete the session token from Keychain, does not purge the URL cache, does not clear cookies. The next person who opens the app on that device - or the attacker who dumps storage after the user tapped logout - has a live session token and everything else the app cached.
The broken pattern
LoginViewController’s logout handler:
1
2
3
4
5
6
7
8
func logout() {
UserDefaults.standard.set(false, forKey: "isLoggedIn")
// Missing: SecItemDelete for api_key
// Missing: UserDefaults.standard.removeObject(forKey: "session_token")
// Missing: URLCache.shared.removeAllCachedResponses()
// Missing: HTTPCookieStorage.shared.deleteAllCookies()
navigateToLogin()
}
The flag flips but four storage layers retain live secrets. Any tool that queries those layers directly, bypassing the isLoggedIn flag entirely, recovers the session in full.
The four storage layers that survive
Keychain. SecItemAdd persisted items live until SecItemDelete is called or the app is uninstalled. Setting isLoggedIn = false does nothing to Keychain. On a jailbroken device or forensic acquisition, all Keychain items are readable after the user “logged out”.
UserDefaults. UserDefaults.standard.set("eyJhb...", forKey: "session_token") writes to a plist file in Library/Preferences/. Removing the isLoggedIn key while leaving session_token means the token survives.
NSURLCache. URLCache.shared caches response bodies and headers on disk in Library/Caches/. A cached response to /api/v1/user/profile contains user PII. The cache survives app restart. Older iOS versions did not purge it on logout even when the app was killed.
NSHTTPCookieStorage. HTTPCookieStorage.shared persists session cookies to Library/Cookies/Cookies.binarycookies. A session cookie that the server set and that the app never deleted is a valid auth credential after logout.
Confirming with Frida: Keychain check post-logout
Tap logout in VulnLabAppiOS, then without relaunching, run:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const SecItemCopyMatching = new NativeFunction(
Process.findModuleByName('Security').findExportByName('SecItemCopyMatching'),
'int', ['pointer', 'pointer']);
const query = ObjC.classes.NSMutableDictionary.dictionary();
query.setObject_forKey_(ObjC.classes.NSString.stringWithString_('genp'), 'class');
query.setObject_forKey_(ObjC.classes.NSNumber.numberWithBool_(true), 'r_Data');
query.setObject_forKey_(ObjC.classes.NSNumber.numberWithBool_(true), 'r_Attributes');
query.setObject_forKey_(ObjC.classes.NSString.stringWithString_('m_LimitAll'), 'm_Limit');
const resultPtr = Memory.alloc(Process.pointerSize);
const status = SecItemCopyMatching(query, resultPtr);
// status=0 means items found; print them
console.log('Keychain after logout:', new ObjC.Object(Memory.readPointer(resultPtr)));
If the api_key or session_token entry is still present, logout did not clean up Keychain.
objection: before and after logout
1
2
objection -g com.vulnlab.iosapp explore
ios keychain dump
Run before logout: note every entry. Tap logout in the app. Run again - the entries that persist are the finding. In VulnLabAppiOS, api_key and session_token both survive.
NSURLCache size check
1
2
3
const cache = ObjC.classes.NSURLCache['+ sharedURLCache'].call(ObjC.classes.NSURLCache);
console.log('Memory usage: ' + cache.currentMemoryUsage()); // bytes
console.log('Disk usage: ' + cache.currentDiskUsage()); // bytes
Non-zero disk usage after logout means the app did not call removeAllCachedResponses. The cached responses themselves may contain auth headers or response bodies with PII.
Cookie check post-logout
1
2
3
4
5
6
7
const cookieStorage = ObjC.classes.NSHTTPCookieStorage['+ sharedHTTPCookieStorage']
.call(ObjC.classes.NSHTTPCookieStorage);
const cookies = cookieStorage.cookies();
for (let i = 0; i < cookies.count(); i++) {
const c = cookies.objectAtIndex_(i);
console.log('[Cookie] ' + c.name() + '=' + c.value() + ' domain=' + c.domain());
}
Session cookies that survive logout are a direct session-continuation finding.
The fix checklist
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
func logout() {
// 1. Keychain - delete each item by service/account
let keychainQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: Bundle.main.bundleIdentifier!
]
SecItemDelete(keychainQuery as CFDictionary)
// 2. UserDefaults - remove sensitive keys explicitly
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "session_token")
defaults.removeObject(forKey: "api_key")
defaults.removeObject(forKey: "isLoggedIn")
defaults.synchronize()
// 3. URL cache
URLCache.shared.removeAllCachedResponses()
// 4. Cookies
if let cookies = HTTPCookieStorage.shared.cookies {
cookies.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
}
navigateToLogin()
}
Each storage layer needs an explicit delete. Missing one leaves a live credential behind.
The server side of the equation
Token deletion on the client is not enough if the token is still valid on the server. The server must invalidate the session token on logout. A client-side logout that only clears local storage but leaves the server-side token valid means any intercepted or previously exfiltrated token still works. Test both: clear the app and verify the token is rejected server-side.
Closing
iOS logout bugs are a category on their own. Four storage layers, any one of which the developer might have forgotten to clear. The objection ios keychain dump before-and-after comparison is the fastest check. Full coverage means checking all four layers. The fix is a checklist, not a heuristic.
Happy Hacking !!