Assessing an Android app is mostly a matter of knowing where to look. The platform is well documented, the file format is a ZIP archive, and the tooling is free. What turns a pile of adb output into a useful assessment is a repeatable order of operations and a standard to measure against. This checklist follows the order I find most efficient, static work first because it is cheap and generates the hypotheses that dynamic testing then confirms, and maps each step onto the OWASP Mobile Application Security Verification Standard (MASVS).
WARNING
Everything below assumes an app you wrote, or one you have been engaged in writing to test, running on a device or emulator you control with test accounts and test data. Decompiling someone else's app may violate its licence; probing someone else's backend without permission is simply unauthorised access. The techniques are the same either way; the authorisation is what makes it an assessment.
What an APK is
Three facts drive the whole methodology. The manifest is binary XML, so you need a decoder before you can read it. The code is .dex bytecode, which decompiles to readable Java far more faithfully than native code ever does. And the signature covers the file, not the behaviour, so a valid signature tells you who built it and nothing about whether it is safe.
Step 1: acquire and pin the artefact
- List what is installed and where the APK lives:
adb shell pm list packages -3andadb shell pm path com.example.app. - Pull it, including any split APKs for an app bundle:
adb pull /data/app/~~.../com.example.app-.../base.apk app.apk. - Record the hash and version so that every finding refers to one specific build.
- Verify the signature and note which schemes are present.
sha256sum app.apk
aapt2 dump badging app.apk | grep -E "^package|sdkVersion|targetSdkVersion|uses-permission" | head -n 20
apksigner verify --print-certs --verbose app.apk
# Verified using v1 scheme (JAR signing): false
# Verified using v2 scheme (APK Signature Scheme v2): true
# Verified using v3 scheme (APK Signature Scheme v3): true
targetSdkVersion matters more than it looks: many platform protections (scoped storage, implicit exported requirements, cleartext defaults) only apply when the app targets a recent API level.
Step 2: decode and read the manifest
apktool d app.apk -o app_src # manifest, resources, smali
jadx -d app_java app.apk # Java source for reading
less app_src/AndroidManifest.xml
The manifest declares every entry point into the app. The attributes to check, in rough order of how often they produce findings:
| Attribute or element | Risk if wrong | What to look for |
|---|---|---|
android:exported="true" on activity/service/receiver/provider | any app on the device can invoke it | exported components without android:permission, especially providers |
android:debuggable="true" | debugger attach, run-as, memory dumps on any device | should never ship in a release build |
android:allowBackup="true" | app data extractable via adb backup on older APIs | set to false or use a backup rules file that excludes secrets |
android:usesCleartextTraffic | plain HTTP permitted | should be false; defaults to false from API 28 |
android:networkSecurityConfig | trust anchors, pinning, cleartext per domain | present, and the referenced XML is sane (see step 5) |
<intent-filter> with BROWSABLE | deep links reachable from any web page | parameters that flow into WebViews, file paths or auth flows |
android:grantUriPermissions, <path-permission> | content provider exposes more than intended | path traversal in openFile, SQL in query selection arguments |
<uses-permission> | over-privileged app | permissions with no corresponding feature |
Since Android 12 (API 31) any component with an intent filter must declare android:exported explicitly, which removed a whole class of accidental exposure, but explicit true with no permission is still common on deep-link activities:
<activity
android:name=".DeepLinkActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" android:pathPrefix="/open" />
</intent-filter>
</activity>
<provider
android:name=".data.NotesProvider"
android:authorities="com.example.notes"
android:exported="false"
android:grantUriPermissions="true" />
The activity is exported by design (it has to be, for links to open it). The question is what DeepLinkActivity does with the URL: does id end up in a WebView.loadUrl, in a file path, in a startActivity with attacker-chosen extras? That is a code question, which is step 3.
Step 3: read the code with a purpose
Decompiled Java is long. Read it with a target list rather than from the top:
# entry points first: every exported component's class
grep -rn "getIntent()\|getData()\|getStringExtra" app_java/sources/com/example | head
# dangerous sinks
grep -rnE "loadUrl\(|addJavascriptInterface|setJavaScriptEnabled\(true\)" app_java/sources
grep -rnE "MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|openFileOutput" app_java/sources
grep -rnE "Cipher\.getInstance\(\"(AES|DES)(/ECB)?\"\)|\"AES/ECB" app_java/sources
grep -rnE "TrustManager|ALLOW_ALL_HOSTNAME_VERIFIER|setHostnameVerifier" app_java/sources
# secrets that should not be in a client
grep -rnE "AKIA[0-9A-Z]{16}|BEGIN (RSA |EC )?PRIVATE KEY|api[_-]?key|client_secret" app_src/res app_src/assets app_java/sources | head
Each hit is a hypothesis. A TrustManager that accepts everything is a finding only if it is actually installed on the client that talks to production; an AES/ECB call is a finding only if it protects something that matters. Dynamic testing settles those questions.
Step 4: storage
Anything the app writes lands under /data/data/<package>/ (or /data/user/0/<package>/), which other apps cannot read on a healthy device but which an attacker with the unlocked phone, a backup, or a root exploit can. On a debuggable build or an emulator you can inspect it directly:
adb shell run-as com.example.app ls -la shared_prefs databases files cache
adb shell run-as com.example.app cat shared_prefs/session.xml
adb shell run-as com.example.app sqlite3 databases/app.db ".tables"
Tokens in SharedPreferences plaintext, SQLite databases with user content and no encryption, and log files under files/ are the classic findings. The platform answer is the Android Keystore for keys, with the key never leaving the hardware-backed store, and encrypted storage (EncryptedSharedPreferences, SQLCipher or similar) for data derived from it. External storage is readable by any app that holds the permission and should never hold anything sensitive.
Step 5: network security config and pinning
The network security configuration file is where the app declares which certificates it trusts, whether cleartext is allowed, and which pins apply. It is referenced from the manifest (android:networkSecurityConfig="@xml/network_security_config") and lives at res/xml/network_security_config.xml.
A reasonable network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Default for every domain: system CAs only, no cleartext -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Pin the API, with a backup pin and an expiry so a rotation cannot brick the app -->
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
<pin digest="SHA-256">GUAL5bejH7czkXcAeJ0vCiRxwMnVBsDlBMBsFtfLF8A=</pin>
</pin-set>
</domain-config>
<!-- Debug builds only: trust user-installed CAs so an intercepting proxy works -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
The pin values above are placeholders; real ones are the base64 SHA-256 of the Subject Public Key Info of the leaf or an intermediate you control.
Things to check: cleartextTrafficPermitted="true" anywhere, <certificates src="user" /> outside debug-overrides (it makes any user-installed CA trusted in release builds), pins with no backup, and pins that have already expired, after which Android silently stops enforcing them.
Libraries can pin too. With OkHttp the configuration is a few lines, and the same two-pin rule applies:
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=")
.add("api.example.com", "sha256/GUAL5bejH7czkXcAeJ0vCiRxwMnVBsDlBMBsFtfLF8A=")
.build()
)
.build()
Pinning is a defence against a compromised or coerced certificate authority, not against a user who controls their own device. An intercepting proxy with a hooking framework will get around it on a rooted test phone, and that is fine: the test is whether a release build on a stock device refuses an unknown CA, not whether pinning is unbypassable.
Step 6: dynamic quick wins
With the static picture in hand, a short dynamic session answers the open questions:
# Does the app leak into the system log?
adb logcat -c && adb logcat | grep -iE "token|password|authorization|session" &
# Poke the exported deep-link activity with unexpected input
adb shell am start -a android.intent.action.VIEW \
-d "https://example.com/open?id=../../etc/hosts" com.example.app
# Query an exported content provider from the shell
adb shell content query --uri content://com.example.notes/notes
# Watch the pinning decision at runtime (needs a rooted device or emulator and Frida)
frida -U -f com.example.app -l observe-tls.js
Then route the device through an intercepting proxy with your own CA installed and confirm the release build refuses to connect, while the debug build (with debug-overrides) does connect, so you can review the API traffic itself: authentication headers, what is sent on first launch, whether identifiers leave the device that the privacy notice does not mention.
Mapping to MASVS
The MASVS v2 control groups are a convenient skeleton for the report, and the companion Mobile Application Security Testing Guide (MASTG) gives a test case identifier for nearly every item above.
| MASVS group | Covers | Where it appears in this checklist |
|---|---|---|
| MASVS-STORAGE | sensitive data at rest, logs, backups, clipboard | step 4, allowBackup, logcat |
| MASVS-CRYPTO | algorithms, key management, randomness | step 3 grep for ECB, hard-coded keys |
| MASVS-AUTH | authentication and session handling with the backend | proxy review in step 6 |
| MASVS-NETWORK | TLS, cleartext, pinning | step 5 |
| MASVS-PLATFORM | IPC, WebViews, exported components, deep links | steps 2, 3 and 6 |
| MASVS-CODE | build hygiene, debuggable, updated dependencies, native hardening | step 1 badging, step 2 manifest, lib/ flags |
| MASVS-RESILIENCE | anti-tampering, root detection, obfuscation | only where the threat model needs it |
| MASVS-PRIVACY | data minimisation, consent, identifiers | proxy review of first-launch traffic |
Severity comes from impact and likelihood for the app's actual threat model, not from the grep that found the issue. An exported provider leaking notes in a note-taking app is critical; a missing root check in the same app is usually not a finding at all.
Further reading
- OWASP, Mobile Application Security Verification Standard (MASVS) and the Mobile Application Security Testing Guide (MASTG).
- Android Developers, Network security configuration, Security with network protocols, and Security tips.
- Android Developers, App manifest overview and Intents and intent filters.
- Tools: Apktool, jadx, adb, and Frida for Android.