Insecure Local Data Storage on Mobile Devices

Insecure Local Data Storage on Mobile Devices - article cover image Mobile API OSINT
Time it takes to read this article 6 minutes.

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 plain adb 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
KOTLIN

A 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) {}
    }
}
Java

And 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.
KOTLIN

Walkthrough / 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
Bash

No 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
Bash

objection 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
Bash

On 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
Bash

Note: 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 plain SharedPreferences for 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 ThisDeviceOnly accessibility class rather than NSUserDefaults or 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/MobSF as 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

Copied title and URL