Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Every major mobile OS sandboxes each app’s on-disk data by default — Android gives each app a private directory under /data/data/<package> owned by a unique UID, and iOS gives each app its own container directory. That sandbox is a strong default, but it is trivially opted out of: an app can write to shared external storage, set an explicit world-readable file mode, cache sensitive values in an unencrypted preferences file, or store secrets outside any OS-managed protection mechanism entirely. Insecure local storage remains one of the most consistently found issues in mobile app assessments precisely because the *insecure* path is often the path of least resistance for a developer just trying to persist a value quickly.
The impact is straightforward: anyone with local access to the affected data — a second app on the device, a physical attacker with a rooted/jailbroken device or a backup extraction, or (in the worst configurations) any app holding a broad storage permission — can read session tokens, PII, or cryptographic material that the developer assumed was private. Unlike a network-facing bug, this class of vulnerability requires local access, but “local access” covers a wide range of realistic scenarios: a lost or stolen device, a shared/managed device, a malicious sideloaded app, or simply plugging the device into a computer.
Attack Prerequisites
Reaching insecurely stored data generally requires one of the following:
- Root (Android) or jailbreak (iOS) access, or a debuggable build, to read another app’s private sandboxed files directly.
- No root at all, if the target app wrote sensitive data to *external* storage (
/sdcard) or used a world-readable file mode — this is readable by any app with storage access, or via a plainadb pull. - An unencrypted local or cloud backup (Android
adb backup, iTunes/Finder backup, or an iCloud backup not excluded by the app) capturing sandboxed files that would otherwise require root to reach live. - For dynamic confirmation, objection or a plain shell with
run-as(debuggable builds) to browse the app’s own data directory without full root.
How It Works
On Android, the sandbox boundary is enforced by the Linux kernel’s UID/GID permission model: each app’s private directory and files are normally created MODE_PRIVATE (owner read/write only). Historically, Context.MODE_WORLD_READABLE and MODE_WORLD_WRITEABLE let a developer explicitly relax that to world-readable/writable permissions — a legitimate-looking API call that quietly disables the sandbox for that file. These constants were deprecated in API 17 and calling them throws a SecurityException on API 24+, which has reduced but not eliminated the pattern, since external storage (/sdcard, historically FAT-formatted and always broadly readable) remains a second, still-common way to write data outside the sandbox — and prior to Scoped Storage (enforced by default from Android 10/11 for apps targeting modern SDKs), any app holding READ_EXTERNAL_STORAGE could read any other app’s external files.
A second, equally common failure has nothing to do with file permissions: storing sensitive data in a location that was never meant to hold secrets in the first place — SharedPreferences/NSUserDefaults in plaintext XML/plist, an unencrypted SQLite or Core Data database, verbose application logs written to logcat or a log file, or a WebView’s own cache/localStorage. None of these are cryptographically protected by default; on Android, SharedPreferences files under shared_prefs/ are ordinary XML on disk, readable by anything with access to the app’s data directory (root, a backup, or a debuggable build’s run-as) regardless of Keystore’s availability, unless the developer deliberately routes values through EncryptedSharedPreferences instead.
A related and frequently paired mistake is a WebView with a JavaScript bridge (addJavascriptInterface on Android, a WKScriptMessageHandler on iOS) whose exposed methods themselves write sensitive data to an insecure location — for example, a bridge method that persists a user’s profile or auth data to external storage so it can be shared with another app or a downloaded asset. This combines two independent weaknesses (an overly-trusting bridge, and a world-readable destination) into a single chain that is exploitable purely from JavaScript running in the WebView, with no root required to *read back* the result.
Vulnerable Code / Configuration
Persisting a session token to a file with an explicit world-readable mode — still directly settable on openFileOutput and functionally equivalent to the deprecated MODE_WORLD_READABLE constant via a raw mode integer:
// VULNERABLE: file created with world-readable permission bits.
val out = openFileOutput("session.token", Context.MODE_WORLD_READABLE)
out.write(sessionToken.toByteArray())
out.close()
// Resulting file: -rw-r--r-- ... /data/data/com.target.app/files/session.token
KOTLINA WebView JavaScript bridge method that writes user data straight to external storage, combining the bridge-trust issue with world-readable placement — any app with READ_EXTERNAL_STORAGE (or, pre-Scoped-Storage, any app at all) can read the result the moment it is written:
public class JsBridge {
@JavascriptInterface
public void cacheProfile(String json) {
// VULNERABLE: sensitive profile data written unencrypted
// to public external storage, reachable without root.
File f = new File(Environment.getExternalStorageDirectory(),
"appdata/profile.json");
f.getParentFile().mkdirs();
try (FileWriter w = new FileWriter(f)) { w.write(json); }
catch (IOException ignored) {}
}
}
JavaAnd the more mundane but far more common version: an auth token quietly living in plaintext SharedPreferences, protected only by the sandbox rather than by encryption:
// VULNERABLE: plaintext prefs, no EncryptedSharedPreferences/Keystore use.
getSharedPreferences("app_prefs", MODE_PRIVATE)
.edit().putString("auth_token", token).apply()
// -> /data/data/com.target.app/shared_prefs/app_prefs.xml, plain XML.
KOTLINWalkthrough / Exploitation
On a rooted device (or via run-as for a debuggable build), enumerate world-readable files across the app’s sandbox directly:
adb shell "find /data/data/com.target.app -perm -004 -type f 2>/dev/null"
adb shell run-as com.target.app cat shared_prefs/app_prefs.xml
BashNo root is needed at all for anything written to external storage — a plain adb pull (or, for another installed app holding storage permission, a normal file read) is sufficient:
adb pull /sdcard/appdata/profile.json .
cat profile.json
Bashobjection streamlines this for a live assessment without manually constructing find/run-as commands, and can also dump SQLite databases directly:
objection -g com.target.app explore
com.target.app on (google: 13) [usb] # env
com.target.app on (google: 13) [usb] # file download shared_prefs/app_prefs.xml ./app_prefs.xml
com.target.app on (google: 13) [usb] # sqlite connect databases/app.db
BashOn iOS, an unencrypted local backup pulled via idevicebackup2 (no jailbreak needed) or, on a jailbroken device, direct filesystem browsing exposes the same category of issue — plaintext plists, NSUserDefaults, and Core Data stores outside the Keychain:
idevicebackup2 backup --full ./ios-backup
# then locate the app's domain under the backup and inspect its plist/sqlite files
BashNote: Android’s Scoped Storage (default and enforced for apps targeting API 30+) meaningfully shrinks this attack surface for *new* apps, since broad external-storage read access by other apps is no longer granted by default — but apps that still target older SDKs, or that use
MediaStore/SAF escape hatches carelessly, remain exposed, and existing world-readable internal files are unaffected by Scoped Storage entirely.
Opsec: When testing, check *both* the app’s private sandbox and any shared/external locations it touches — a WebView bridge, a share-sheet export, or a download folder is often where sensitive data ends up even when the app’s own
shared_prefs/Keychain usage looks correct.
Detection and Defense
The fix is consistent across platforms: use the OS-provided encrypted storage primitives and never place secrets outside the sandbox:
- Use
EncryptedSharedPreferences/Jetpack Security (backed by the Android Keystore) instead of plainSharedPreferencesfor anything sensitive. - Encrypt local databases (e.g. SQLCipher) rather than storing PII or tokens in a plain SQLite/Core Data file.
- Never write sensitive data to external storage on Android; keep it in the app-private directory, and never disable file-permission defaults.
- Store iOS secrets in the Keychain with a
ThisDeviceOnlyaccessibility class rather thanNSUserDefaultsor a bare plist, as covered in the iOS Data Protection deep dive. - Restrict or eliminate WebView JavaScript bridges that can write to storage, and if one must exist, route all persistence through the app’s normal encrypted-storage APIs rather than letting bridge code write files directly.
- Exclude sensitive caches from backup and test with
objection/MobSFas part of the release process, not just an external assessment.
Real-World Impact
Plaintext session tokens in SharedPreferences, unencrypted local SQLite databases holding PII, and secrets written to external storage are among the most frequently reported findings in mobile penetration test reports and are explicitly called out as a baseline test category in the OWASP Mobile Application Security Testing Guide (MASTG) storage checks — not because the fix is hard, but because the insecure API is often the first one a developer reaches for.
Conclusion
Mobile OS sandboxing is a strong default, but it is only as good as the developer’s choices layered on top of it — a single MODE_WORLD_READABLE call, an unencrypted preferences file, or a WebView bridge that writes to external storage is enough to hand local data to any attacker with device access, root or not. Route every sensitive value through the platform’s encrypted-storage primitives — EncryptedSharedPreferences/Keystore on Android, Keychain on iOS — and treat anything outside them as readable by default.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments