
Run MobSF over almost any production Android build and the score comes back in the 30s out of 100, with a wall of red rows before you have even opened a device. That gap is the point: a web pentest of your backend never touches the cleartext token in shared_prefs, the AES key compiled into a constants file, or the certificate pin a stock Frida script peels off in ten seconds. Those live on the client, and the client belongs to the attacker.
Mobile application penetration testing is the practice of attacking that client the way a real adversary does. This post walks the engagement end to end: where you start, the actual tool output you read along the way, how iOS and Android diverge, a sample findings table from a report, and the configuration changes that actually close the gaps.
Mobile app penetration testing is an authorized security assessment of a mobile application's client code, its on-device data, and the backend APIs it calls, with the goal of finding and exploiting vulnerabilities before an attacker does. You report each one with reproduction steps, a CVSS score, and the OWASP MASVS control it violates.
It is fundamentally different from a server pentest because the threat model assumes the attacker owns the runtime. They root or jailbreak the device, decompile the app, read its private storage, hook its functions in memory, and tamper with its traffic. So the assessment treats the binary as a fully visible, fully controllable artifact, not a trusted black box. Risk categories come from the OWASP Mobile Top 10; the verifiable controls and test procedures come from MASVS and the MASTG.
You start static, because it is free, fast, and tells you where to point the device phase. On Android the first move is to decode the package and look at what apktool reconstructs:
$ apktool d banking.apk -o src/
I: Using Apktool 2.9.3 on banking.apk
I: Baksmaling classes.dex...
I: Decoding AndroidManifest.xml with resources...
$ tree -L 2 src/
src/
├─ AndroidManifest.xml
├─ apktool.yml
├─ res/
├─ assets/
│ └─ config.properties <- bundled config, always read this
└─ smali/That config.properties in assets/ is the kind of file that ships secrets. The manifest tells you the attack surface (every exported="true" component is reachable). Then decompile to readable Java with jadx and grep the tree for the obvious mistakes:
$ jadx -d out/ banking.apk && grep -rniE 'secret|api[_-]?key|AES' out/sources/
out/sources/com/bank/crypto/KeyVault.java:14:
private static final String AES_KEY = "j8Hk2Lp9QwRt5VxZ"; <- hardcoded 128-bit key
out/sources/com/bank/net/ApiClient.java:31:
String API_SECRET = "sk_live_4eC39HqLyjWDarjt";A symmetric key in KeyVault.java means every install ships the same key, so anything it encrypted on disk is decryptable by anyone with the APK. On iOS the equivalent entry is a decrypted IPA loaded into Hopper or Ghidra, with otool -L for linked libraries and class-dump for the Objective-C class layout.
Static findings are leads, not confirmed vulnerabilities. MobSF gives you a fast scored triage that ranks where to dig, but its score is a starting point, not a verdict:
$ docker run -p 8000:8000 opensecurity/mobile-security-framework-mobsf
# upload banking.apk, then read the summary:
Security Score : 38/100
Grade : C
[HIGH] App can be installed on a vulnerable Android version (minSdk 21)
[HIGH] Debug certificate / android:debuggable could be true
[WARN] App uses SharedPreferences in MODE_WORLD_READABLE pattern
[HIGH] Hardcoded secrets found (3) - see 'Secrets' tabMobSF flags that a string looks like a key and that a component is exported. What it cannot tell you is whether that token is live, whether the pin falls to a stock script, or whether the biometric gate protects a real authorization check or a cosmetic screen. Only the running app answers those questions, which is why the device phase exists.
Dynamic analysis runs the app on a rooted or jailbroken device and proves which static leads are exploitable. The single most common confirmation is pulling the app's private storage after you have logged in and clicked around:
$ adb shell run-as com.bank.app cat shared_prefs/session.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="auth_token">eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjQ0MTd9...</string>
<string name="refresh">rt_9f2a7c1b04e3</string>
<int name="pin" value="4417" />
</map>That is a live JWT, a refresh token, and a user PIN in plaintext, readable on any rooted device. The next confirmation is the network layer. Proxy the app through Burp, and if it pins, attach Frida with objection's pinning bypass and watch it land:
$ objection -g com.bank.app explore
com.bank.app on (Pixel: 13) [usb] # android sslpinning disable
(agent) Custom TrustManager check bypassed
(agent) OkHttp 3.x CertificatePinner.check() bypassed
(agent) SSLContext.init() custom TrustManager injected
[+] Pinning bypassed. Traffic now visible in Burp.Three lines of console output and the pin is gone. When the app refuses the proxy or uses raw sockets, you escalate to forced redirection, covered in intercepting proxy-unaware app traffic. This static-then-dynamic loop is exactly what agentic pentesting automates across many apps and methods at once.
The methodology and the MASVS controls are shared, but the platform internals diverge in ways that change your toolchain on day one. Android ships an APK you unpack with apktool and decompile with jadx; iOS ships an IPA with a Mach-O you must first decrypt (FairPlay), then disassemble in Hopper or Ghidra.
/data/data; iOS needs a jailbreak or a re-signed debug build.The full per-control test list for both platforms lives in our mobile app penetration testing checklist.
On a recent assessment of a retail banking app, the login and money-movement flows pinned their certificate flawlessly, and the team was rightly proud of it. The rewards section, though, loaded in a WebView with setJavaScriptEnabled(true) and an addJavascriptInterface bridge exposing a native object, with no pinning on that traffic at all. We MITM'd the rewards endpoint, injected JavaScript that called the exposed bridge, and reached native app context, including the same session token from the storage finding above. The happy path was bulletproof; the side door was wide open.
That is the recurring lesson. The exploitable findings are rarely zero-days in the binary. They are the storage the team forgot to encrypt, the SDK that pins separately from the host, and the one screen that skipped the control everything else enforced. Below is what that report excerpt looks like, scored and mapped to controls.
Durable remediation is configuration, not advice to "use strong passwords." Each finding above has a concrete fix:
EncryptedSharedPreferences backed by a Keystore master key (MasterKey.Builder(context).setKeyScheme(AES256_GCM).build()) on Android, or the Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly on iOS. Never store the PIN at all.KeyGenParameterSpec with setUserAuthenticationRequired where appropriate) so the key never exists in the binary or leaves secure hardware.<pin-set> in network_security_config.xml) plus a real attestation check raises the cost; the durable control is server-side, with short-lived, scoped tokens that are useless once exfiltrated.addJavascriptInterface for untrusted content, set setJavaScriptEnabled(false) where possible, and pin the WebView's traffic too.Treat client-side root and jailbreak detection as friction, not a boundary, because any client check can eventually be hooked. We map these controls to regulated data in mobile pentesting for data protection compliance.