Imagine I've handed you a USB drive. On the USB drive is a copy of an Android app — the APK file itself, plus the contents of /data/data/com.example.app/ from a test phone where the app was installed, used for an hour by a real-feeling test user, then dumped wholesale. Your job, for the next two hours, is to find everything in there that the company shipping the app would not want a security researcher to find.
I want to walk you through that teardown, file by file. The app is composite — anonymized, with the worst patterns from a dozen real engagements stitched together. Every finding I'll describe, we've seen in production. Together, they are roughly what an honest mobile assessment looks like in two hours of focused work.
If you build mobile apps, by the end of this you'll have a list of directories to look at in your own build, and a sense of what to look for in each one. If you sign the checks on mobile app development, you'll have a clearer mental picture of what a "mobile app" actually contains besides the binary your team compiled.
The APK itself
First thing on the desk: the APK. I rename it to app.zip and unzip it. Out come a few dozen files and directories. The interesting ones, for our purposes:
AndroidManifest.xml. We covered the IPC bugs in this file in a separate article. For this teardown, I'm scanning for one specific thing: android:allowBackup="true". It's there. This means Android's automatic-backup feature will copy the app's /data/data directory to Google Drive (or to the user's PC over ADB) by default. Every secret stored locally is now in scope for any attacker who has access to the user's Google account or computer.
One line. Should be "false" for any app that stores secrets. Is often "true" because that's the default Android sets when you don't think about it.
classes.dex. I run it through jadx to decompile. I'm looking for hardcoded strings. grep -r "secret\|api_key\|token\|password" decompiled/. The first hit is in a class called Constants.java:
public static final String API_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc";
public static final String SECRET_BACKUP = "company-internal-2019";
public static final String SUPPORT_API_TOKEN = "Bearer eyJhbGc...";
Three hardcoded secrets. The API key looks like a Stripe live key. The "backup secret" is presumably for HMAC validation. The support API token grants the app permission to file support tickets on behalf of the user — useful, but also extractable by anyone with a copy of the APK.
The fix isn't "obfuscate the strings." Obfuscation is theater. The fix is "don't ship secrets in the binary, ever." If your app needs to call a third-party API, it should call your backend, which calls the third-party API. If a string is in the binary, treat it as published.
res/raw/. Sometimes contains JSON config files, certificate pinning configs, sample data, or test fixtures left in production builds. This app's res/raw/ contains a file called dev_endpoints.json — left over from development, pointing at a staging environment whose security posture is unknown but probably weaker than production. The endpoints are reachable. The staging environment, on closer look, has the same authentication model but with last quarter's database.
I now have access to a staging environment with last quarter's customer data. Forty minutes in.
SharedPreferences
I move to the data dump from the device. First stop: shared_prefs/. This directory contains XML files Android uses for simple key-value storage. They're trivially readable.
I open auth.xml:
<map>
<string name="user_id">8421</string>
<string name="auth_token">Bearer eyJhbGc...</string>
<string name="refresh_token">reload_4f8e2d_y9w...</string>
<long name="token_expires_at" value="1729980000" />
</map>
The auth token, in plain text, in a file that any process running on the device (including malicious apps in some configurations, or a forensic image after physical access) can read. Android provides EncryptedSharedPreferences for exactly this case. The app used SharedPreferences instead, because that's the older API and what the Stack Overflow answer the developer copied recommended.
Worse, because allowBackup is true, this file gets backed up to Google Drive. The user's auth token is now in their Google Drive backup. Anyone with the user's Google credentials has the token.
I open preferences.xml next:
<map>
<string name="user_email">alice@example.com</string>
<string name="last_known_lat">40.7128</string>
<string name="last_known_lng">-74.0060</string>
<boolean name="biometric_enabled" value="true" />
<string name="payment_card_last4">4242</string>
<string name="payment_card_brand">visa</string>
</map>
The user's email, their last known location, and their card metadata. None of this is "secret" in the same way the token is, but it's all PII that didn't have to be there. The card last-4 and brand are presumably used to render the payment screen — but they could be fetched from the API each time, instead of cached. The location is presumably used for some location-aware feature — but caching it means a forensic dump of the phone reveals every place the user has been.
This is the pattern I keep seeing. The app's developer cached things for performance or offline use. Each cached thing is fine in isolation. Together, they amount to a profile of the user that the company never intended to ship.
Databases
Next: databases/. This app uses SQLite. There's a file called app.db.
I open it with the sqlite3 command line. The schema includes tables for messages, contacts, transactions, and a "cache" table that turns out to contain serialized blobs of API responses. The cache table is interesting because it has whatever the API returned, which often includes more fields than the app ever displayed.
I run SELECT * FROM messages LIMIT 5;. Messages are in plaintext. The app claims end-to-end encryption for messages in transit. The local storage isn't encrypted. Whoever reads this file reads every message the user sent or received.
The transactions table contains the user's payment history. Amounts, recipient names, dates, descriptions. The cache table contains the user's full profile, including fields like internal_notes and account_tier that the UI never displays but the API returns.
None of this is encrypted. Realm and SQLite both support encryption at rest — Realm with encryptionKey on the configuration, SQLite via SQLCipher. The app used neither. The developer's decision, when I asked in the debrief, was "we figured the sandbox handled it." The sandbox does not handle physical access, jailbreaks, or backup files.
Cache directories
Now: cache/ and files/. These directories accumulate whatever the app writes during normal operation. I find:
Image thumbnails for every photo the user has viewed in-app. These include photos from secure messages, which the app's UI displays with a "view once" timer and then deletes from the UI. The thumbnails are still in the cache. "View once" was a UI decision, not a storage decision.
WebView cache. Pages the app's embedded WebView has loaded, including their cookies. If the WebView authenticated with a session cookie, the cookie is in this cache. If the WebView loaded a page with the user's profile, the page is here.
Crash dump files. The crash reporter SDK left structured dumps of the app's memory state at crash time. These include in-flight variables — which often include auth tokens, decrypted message contents, and PII that happened to be live in memory when the crash happened.
Log files. The app's logging library left several megabytes of logs in a rotating file. The logs include API requests (with auth headers), user actions (with IDs), and timestamps. A forensic dump reveals roughly the entire history of what the user did in the app over the last several days.
Each of these has a fix. Image cache: clear sensitive thumbnails when they're meant to be gone. WebView cache: disable or scrub on sign-out. Crash dumps: configure the crash reporter to scrub sensitive fields. Logs: don't log auth headers or PII, and don't keep production logs at debug level.
The combined fix is two days of engineering effort. The cumulative leak, without the fixes, is everything.
The app switcher cache
This one isn't in the directory listing because it's stored outside the app's data directory, but it's part of the same teardown.
When the user switches away from your app, iOS and Android both take a snapshot of the current screen — used to render the preview tile in the app switcher. The snapshot is stored in a system-level cache directory. It is, on iOS, accessible to any process with sufficient privileges, and on Android, sometimes accessible to other apps in specific configurations.
The user backgrounds the banking app while it's showing their account balance. The snapshot saves a picture of the balance. The user backgrounds the messaging app mid-conversation. The snapshot saves a picture of the conversation. The user backgrounds the password manager. The snapshot is what you think it is.
The fix is a few lines of code. On iOS: in applicationWillResignActive, replace the current view with a blank placeholder. On Android: set FLAG_SECURE on the window. Most apps that deal with sensitive content do not do this. The first time we run a teardown for a client and show them their own app switcher cache, the reaction is usually the same — a long pause, followed by "well, that's awkward."
The SDK that shipped your data to a server in another country
This is the finding I want to spend the most time on, because it's the one most clients are most surprised by.
I run a network capture of the app while a test user does normal things — logs in, browses, sends a message, makes a purchase. I expect to see traffic to the app's own API. I do see that. I also see traffic to:
- An analytics service, three calls per user action. The analytics payload includes the user's ID, their session token (yes, the actual auth token), the route they're on, and the parameters of the action.
- A crash-reporting service that, during a forced crash, ships the app's full memory snapshot — including in-flight session state and decrypted message contents.
- An advertising SDK that includes the user's device ID, IP, location, and "user properties" (which the app populated with the user's email and tier).
- A feature-flag service that gets pinged on every screen, with the user's identity attached to the request.
- A "performance monitoring" SDK that ships the full body of every HTTP request the app makes — including request bodies that contain card details, PII, and message text.
The last one is the one most teams have never heard of. Performance-monitoring SDKs that ship request bodies are common. Their pitch is "we'll help you debug slow API calls by showing you the actual request/response." The actual delivery is "we'll ship every byte of user data your app sends to our servers, where it sits indexed by user ID."
Most teams have no idea this is happening. The SDK was added by a developer who needed quick visibility into a perf issue, two years ago, and it was never audited. The data this SDK has accumulated about the company's users is, depending on the user base, a meaningful percentage of what the company itself has.
And: the SDK's server is in a different jurisdiction. GDPR compliance, if the company has any users in the EU, just became a complicated conversation.
The eleven findings, on one page
Two hours. One app. Findings:
allowBackup="true"letting auth tokens propagate to Google Drive.- Stripe API key, support API token, and HMAC secret hardcoded in the binary.
- Staging endpoints left in
res/raw/exposing last quarter's customer database. - Auth token in plaintext
SharedPreferences. - User email, location, and payment card details cached in
SharedPreferences. - Unencrypted SQLite database containing messages, transactions, full profile cache.
- Image thumbnails of "view once" media persisting in the cache.
- WebView cache containing session cookies and rendered profile pages.
- Crash dumps containing in-flight auth tokens and decrypted message content.
- App-switcher snapshots of sensitive screens left in the OS cache.
- Performance-monitoring SDK exfiltrating full HTTP request bodies, including PII, to a third party.
None of these required a single exploit. Every one of them was visible to anyone with access to the APK and a copy of the device's /data/data directory. The findings exist because each one represents a small decision the team made — usually for performance, usually for convenience — that aggregated into a profile of the user the team never meant to ship.
What I want you to take from the teardown
The framing that most resonates with the founders we work with is this: your app is not just the code your team wrote. It is the binary, plus the data the binary stores, plus the cache the OS keeps about your app, plus the network traffic the SDKs send, plus the backups Google or Apple takes, plus the screenshots the app switcher saves. The attack surface is all of it. The defense surface, in most apps, is just the part the team wrote.
The work to close the gap looks like the teardown above. Walk through your own app with the same lens. Open the manifest. Open the shared preferences. Open the databases. Watch the network traffic for an hour. Read what each SDK is shipping. Make a list of every place data is stored or transmitted that wasn't deliberate.
For most teams, the list is longer than they expect. The fixes are shorter than they expect. And the conversation with the team is the meeting that, more than any other, shifts the team's mental model from "we secured the app" to "we know what the app actually does."
If you haven't done this teardown on your own app, the next mobile security exercise on your roadmap should be exactly this one. The findings will surprise you. The fixes will not.