
Two findings will earn most of your report on a typical mobile app: a secret sitting in cleartext on disk, and a certificate pin that a one-line script removes. The OWASP MASVS lists its control groups alphabetically, but you should not test them in that order. Test where the yield is.
This checklist runs storage and network first, platform and code second, and resilience last and only when it is in scope. It works for iOS and Android, with the divergences (Keychain vs Keystore, IPA vs APK) called out inline. Pair it with our overview of mobile app penetration testing for the methodology behind it.
Test storage first because it produces the highest-severity findings for the least effort. The rule is blunt: nothing sensitive in cleartext, and no key bundled in the binary. After exercising every feature so the app has written its data, pull and read its private directory:
$ adb shell run-as com.target.app ls shared_prefs/
session.xml analytics.xml flags.xml
$ adb shell run-as com.target.app cat shared_prefs/session.xml
<map>
<string name="auth_token">eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjQ0MTd9...</string>
<string name="pin">4417</string> <- user PIN in cleartext
</map>A live session token and a PIN in plain XML is a clean High. For crypto, decompile and grep for keys and weak modes; jadx makes hardcoded material trivial to spot:
$ jadx -d out/ app.apk && grep -rniE 'SECRET|KEY *=|"AES' out/sources/
out/sources/com/target/crypto/Cipher.java:22:
Cipher c = Cipher.getInstance("AES/ECB/PKCS5Padding"); <- ECB mode
out/sources/com/target/crypto/Cipher.java:9:
static final byte[] KEY = "q1w2e3r4t5y6u7i8".getBytes(); <- hardcoded keyECB plus a bundled key means identical plaintext blocks encrypt identically and every install shares one key. These map to MASVS-STORAGE and MASVS-CRYPTO (MASTG-TEST-0001 onward).
Route the app through Burp or mitmproxy with your CA installed, then answer two questions: does TLS validate correctly, and is the pin real? If traffic flows with your CA trusted, pinning is missing (MASVS-NETWORK gap). If it blocks, attempt the cheapest bypass and watch whether it lands:
$ objection -g com.target.app explore
com.target.app on (Pixel: 13) [usb] # android sslpinning disable
(agent) Found okhttp3.CertificatePinner, overriding check()
(agent) Found javax.net.ssl.TrustManagerImpl, overriding verifyChain()
(agent) SSLContext.init() pinned managers replaced
[+] SSL Pinning disabled. 4 hooks installed.If those four hooks are all it takes, the pin offers little real protection and you write it up accordingly. When objection's stock hooks miss a custom implementation, fall back to a targeted Frida script on the exact pinning class. And when the app ignores the proxy entirely or uses raw sockets, switch to forced redirection, covered in intercepting proxy-unaware app traffic.
Platform checks examine how the app talks to the OS and other apps; code checks examine injection and debug hygiene. Dump the manifest, list every exported component, and launch them straight from adb to test for unauthorized actions:
$ grep -n 'android:exported="true"' src/AndroidManifest.xml
47: <activity android:name=".AdminActivity" android:exported="true">
52: <provider android:name=".DataProvider" android:exported="true">
$ adb shell am start -n com.target.app/.AdminActivity
Starting: Intent { cmp=com.target.app/.AdminActivity }
# admin screen opens with no auth - exported component, MASVS-PLATFORMsetJavaScriptEnabled, addJavascriptInterface exposure, and file:// access.android:allowBackup="true", and secrets in logs (adb logcat | grep -i token).On iOS, test custom URL schemes and universal links for the same unauthorized-action issues. These map to MASVS-PLATFORM and MASVS-CODE.
Resilience (MASVS-R) matters when the client itself enforces licensing, anti-fraud, or business rules the server cannot fully re-validate. It is not required for every app, so confirm scope before spending time here. Test root and jailbreak detection by running on a rooted device and trying the cheapest bypass first:
$ objection -g com.target.app explore
com.target.app on (Pixel: 13) [usb] # android root disable
(agent) Hooking RootBeer.isRooted() -> returning false
(agent) Hooking File.exists() for /system/bin/su -> false
[+] Root detection bypassed. App proceeded past the check.If a single hook flips the result, the control is weak. On a recent assessment of a fintech app, the detection was worse than weak: the app called RootBeer.isRooted(), logged the boolean, and then never branched on it. The root check ran on every launch and changed nothing, so we did not even need the hook. The goal of resilience is not perfect protection (it does not exist) but enough friction to be meaningful; our guide on root detection and Android security shows real versus cargo-cult detection.
The biggest gap is testing only the happy path. You log in, tap through the main screens, and never invoke the exported admin activity, the password-reset deeplink, or the feature behind a flag, which is exactly where broken authorization hides. Walk every exported component and every URL scheme, not just the UI you reach by tapping.
The second gap is trusting static tools. MobSF tells you a string looks like a key and a component is exported; it cannot tell you the token is live, the pin is bypassable, or the biometric gate protects a real check. Confirm every static flag dynamically with Frida before you write it. The findings table below shows what a confirmed-and-scored excerpt looks like, with the config-level fix for each.
Every finding gets a concrete configuration change, not generic advice. For the storage finding, replace plain SharedPreferences with the encrypted variant backed by a Keystore master key:
// Android - EncryptedSharedPreferences, key never leaves the Keystore
val key = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val prefs = EncryptedSharedPreferences.create(
context, "session", key,
PrefKeyEncryptionScheme.AES256_SIV,
PrefValueEncryptionScheme.AES256_GCM)KeyGenParameterSpec in the Keystore and use AES/GCM/NoPadding; on iOS, the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.<pin-set> in network_security_config.xml and set cleartextTrafficPermitted="false", but rely on short-lived, scoped server-side tokens as the real control.android:exported="false" or require a signature-level permission, and enforce server-side authorization regardless.Treat client-side root detection as friction, not a boundary, and tie each fix back to the control it satisfies so the right team owns it.