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

Inside an APK: a ZIP archive with a signature bolted on app-release.apk(ZIP, local headers → entries → central directory) AndroidManifest.xml binary XML: package, components, permissions, exported flags, debuggable classes.dex, classes2.dex, … Dalvik bytecode (multidex); decompiles to Java/Kotlin-ish source or smali resources.arsc + res/ compiled resources; res/xml/network_security_config.xml lives here lib/arm64-v8a/*.so, lib/x86_64/*.so native code reached through JNI; check for hardening flags and unsafe parsing assets/ raw files: JS bundles, ML models, config; a frequent home for leaked API keys META-INF/MANIFEST.MF, CERT.SF, CERT.RSA v1 (JAR) signature: per-file digests signed by the developer certificate APK Signing Block (v2 / v3 / v3.1) sits between the entries and the central directory; signs the whole file Which tool reads what apktool d app.apk decodes manifest and resources, disassembles dex to smali (rebuildable) jadx-gui app.apk dex → readable Java, cross-references, string search, deobfuscation helpers apksigner verify --print-certs which signature schemes are present, certificate digests, rotation lineage aapt2 dump badging package, versions, SDK levels, permissions and launchable activity unzip -l / zipinfo quick inventory of entries, sizes, and anything oddly placed Everything here is static; nothing runs.
An APK is a ZIP archive. The manifest and resources are compiled to a binary format, the code is Dalvik bytecode, and the whole file is covered by the APK Signing Block that the installer verifies.

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

  1. List what is installed and where the APK lives: adb shell pm list packages -3 and adb shell pm path com.example.app.
  2. Pull it, including any split APKs for an app bundle: adb pull /data/app/~~.../com.example.app-.../base.apk app.apk.
  3. Record the hash and version so that every finding refers to one specific build.
  4. 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

An app assessment, end to end 1. Acquire •build it yourself, or pull the installed APK from a device you own adb shell pm path com.example adb pull /data/app/…/base.apk •record sha256 and version •verify the signature chain •handle split APKs / app bundles (base + config.arm64_v8a + …) Output: a pinned artefact to test against, so findings are reproducible 2. Static •manifest: exported components, permissions, debuggable, allowBackup •network security config, pinning •intent filters and deep links •storage APIs: files, prefs, DB •crypto usage: ECB, static IVs, hard-coded keys, weak RNG •secrets in strings / assets / BuildConfig •WebView settings, JS bridges •third-party SDK inventory Output: hypotheses to confirm at runtime 3. Dynamic •emulator or rooted test device, your own accounts and data only •logcat for leaked tokens, PII •intercepting proxy; does pinning hold? •inspect /data/data/<pkg> after use •poke exported components: adb shell am start -n … --es k v •instrument with Frida to watch crypto, storage and network calls •backup / screenshot / clipboard Output: confirmed, reproducible findings 4. Report •map each finding to a MASVS control and MASTG test id •severity from impact × likelihood, not from tool output •exact steps: APK hash, device, commands, observed vs expected •a concrete fix per finding (code, config, or design change) •retest after the fix lands Output: something a developer can act on Ground rules that make the result trustworthy Test only apps you wrote or were engaged to test, on devices and accounts you control. Static findings are hypotheses until dynamic testing confirms them. Note the exact build, OS version and configuration, because many controls depend on the SDK level.
Static work produces hypotheses, dynamic work confirms them, and the report maps each confirmed finding to a MASVS control with a reproduction and a fix.

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