Skip to content
All articlesMobile Pentesting

Mobile App Pentesting Lab Setup: The Complete 2026 Guide (Android, Burp, Frida)

Cywarx 19 Jul 2026 31 min read
Share
Mobile App Pentesting Lab Setup: The Complete 2026 Guide (Android, Burp, Frida)

Introduction

Most mobile bug bounty submissions fail before testing even begins. The device isn't rooted, the proxy isn't trusted, the target app pins its certificate, or the whole setup breaks on the next reboot and never gets rebuilt. The result is the same: no clean traffic, no findings.

This guide removes that friction permanently. You will build a rooted Android 14 lab from a clean image and finish with a setup you can re-arm in under a minute, every session, for years. It is written to be followed once, top to bottom — each step states exactly what to run and what success looks like, so you never advance on a broken foundation.

Every command in this guide was executed on a live Android 14 device (Genymotion, x86_64).

What you will have at the end

  • A rooted Genymotion Android 14 emulator (Magisk), with a working Google Play Store.
  • Burp Suite's CA trusted by browsers (Chrome + Firefox) and apps — durably, across reboots.
  • Frida + Objection configured to defeat SSL pinning, root detection, and Frida detection.
  • A single command (geny.sh) that re-arms the lab after every boot.

Prerequisites

  • A Linux host (Kali or Ubuntu recommended) with hardware virtualization enabled in BIOS.
  • Basic comfort with a terminal and adb.
  • Burp Suite (Community or Professional) installed on the host.
  • Authorization to test your target (see Scope and legality below).

How trust works on Android (read this first)

Certificate trust on Android is not a single switch. There are two trust stores, and three classes of client each read a different one. Misunderstanding this is the most common reason testers lose hours to ERR_CERT_AUTHORITY_INVALID:

ClientReadsCovered in
Chrome and most browsersthe user storePart 5A
Firefoxits own bundled storePart 5B
Appsthe system store, and most also pin on topPart 5C (trust) + Parts 8–10 (pinning)

Match the method to the client and interception is reliable. Get it backwards and nothing works.

Scope and legality. Only test applications you own or are explicitly authorized to test — your own app, a signed engagement, or an in-scope bug bounty program. For banking and production apps, test only against your own account. Rooting a device or intercepting traffic for an app or account you do not control may be a criminal offense. When in doubt, stop.


1. Host Tools and Requirements

Install the emulator backend, Android platform tools, and the Frida toolchain.

# ADB + VirtualBox (Genymotion's backend) + xz (to unpack frida-server)
sudo apt update
sudo apt install -y adb virtualbox xz-utils

# Frida CLI + Objection, isolated with pipx
sudo apt install -y pipx
pipx ensurepath            # adds ~/.local/bin to PATH — restart your shell afterwards
pipx install frida-tools   # provides: frida, frida-ps, frida-trace (pulls in the frida core)
pipx install objection     # runtime exploration and one-command bypasses

Use pipx, not pip. Objection and frida-tools are Python 3 only. On Kali/Debian a bare pip may resolve to the wrong interpreter and fail with "Could not find a version that satisfies the requirement." pipx always uses Python 3 and isolates each tool. To keep them in one environment instead: pipx install frida-tools then pipx inject frida-tools objection.

Confirm the install and record the version — frida-server on the device must match it exactly:

frida --version            # e.g. 17.15.1

Verify — the command prints a version such as 17.15.1. Note this number.

Choosing an emulator

OptionStrengthsLimitationsRecommendation
Genymotion (Personal)Fast, VirtualBox-backed, drag-and-drop installs, built-in GAppsFree for personal use only; images ship unrooted (rooted in Part 3)Use this
Android Studio AVDFree, official, Google Play imagesPlay images aren't rooted (use rootAVD); slowerFallback
Physical deviceReal hardware APIs; defeats emulator detectionRequires unlock/root; more setupFor hardened apps (Part 10)

Note. Genymotion's Android 12+ images are locked user builds — adb root is refused and there is no su out of the box. You root them yourself with Magisk in Part 3. This is identical to how a real device is rooted and is stable once complete.


2. Create and Boot the Device

  1. Install Genymotion Desktop (Personal edition, free) from genymotion.com.
  2. Create a device using a Pixel / Android 14 template.
  3. Allocate 8 GB RAM and 4 CPUs.
  4. Start the device.

Connect from the terminal and confirm ADB sees it:

adb devices -l
# 192.168.56.106:5555   device product:vbox86p model:Pixel_9

adb shell getprop ro.build.version.release   # 14
adb shell getprop ro.product.cpu.abi         # x86_64

Verify — the device is listed, the release is 14, and the ABI is x86_64. If nothing appears, run adb kill-server && adb start-server, then adb connect 192.168.56.106:5555.

Note. Commands in this guide use a plain adb, which targets the single connected device. If you run more than one device at once, add -s <serial> to each command (for example adb -s 192.168.56.106:5555 shell …) to select the target.


3. Root the Device with Magisk

A clean Genymotion Android 14 image is a locked user build with no root:

adb shell 'su -c id'   # su: inaccessible or not found
adb root               # adbd cannot run as root because the device is not rooted

Root is installed with Magisk — using Genymotion's own Magisk build. This is the detail that derails most attempts:

Warning. A generic Magisk.apk or .zip will not work on Genymotion, and neither will rootAVD. Genymotion images are system-as-root (no patchable boot or init_boot partition), so Genymotion ships a custom installer that establishes root through an init.rc mechanism instead. Use only that file.

Step 1 — Download the installer. From Genymotion's official page, Install Magisk on Genymotion, download magisk_v30.7_genymotion_installer.zip (Android 12+). Save it to ~/tools/.

Step 2 — Flash it. With the device window focused, drag the zip file onto the device screen. Genymotion executes the installer with root privileges. When it completes, reboot:

adb reboot

Note. The CLI equivalent, gmtool device flash, is license-gated and returns "A license is required to use this feature" on the Personal edition. The drag-and-drop method is the supported path on the free edition.

Step 3 — Verify root:

adb wait-for-device
adb shell 'su -c id'                          # uid=0(root) ... context=u:r:magisk:s0
adb shell pm list packages | grep magisk      # package:com.topjohnwu.magisk

Verifysu -c id returns uid=0(root) … context=u:r:magisk:s0, and the Magisk package is present. Root is now permanent across reboots.

Note. adb root still does not work on a user build. To run a privileged command, wrap it: adb shell 'su -c "<command>"'. Several commands below follow this pattern.

Optional — Google Play. If your image has no Play Store, use Open GApps in the Genymotion toolbar and sign in. Play will not download apps until you complete Part 4.


4. Fix the Network (Play Store)

Genymotion bridges the VM to your host network, where it acquires a real IPv6 address that is not actually routable. Google services and the Play Store prefer IPv6, so every connection stalls for ~25 seconds and times out. The symptom is the Play Store stuck on "Downloading…" indefinitely, with gRPC DEADLINE_EXCEEDED … waiting_for_connection in the logs. IPv4 works correctly throughout. The fix is to disable IPv6.

Step 1 — Disable IPv6 now:

adb shell 'su -c "for c in all default wlan0 radio0 eth0; do sysctl -w net.ipv6.conf.$c.disable_ipv6=1; done"'

Step 2 — Make it permanent with a small Magisk module so it survives reboots:

M=/tmp/noipv6; rm -rf $M; mkdir -p $M
printf 'id=noipv6\nname=Disable IPv6\nversion=1.0\nversionCode=1\nauthor=lab\ndescription=Force IPv4 (Genymotion IPv6 is not routable)\n' > $M/module.prop
printf '#!/system/bin/sh\nuntil [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 2; done\nsleep 3\nfor c in all default wlan0 radio0 eth0; do sysctl -w net.ipv6.conf.$c.disable_ipv6=1 2>/dev/null; done\n' > $M/service.sh
chmod 644 $M/service.sh
( cd $M && zip -qr /tmp/noipv6.zip . )
adb push /tmp/noipv6.zip /data/local/tmp/noipv6.zip
adb shell 'su -c "magisk --install-module /data/local/tmp/noipv6.zip"'

Step 3 — Confirm connectivity:

adb shell 'toybox nc -w 6 google.com 443 </dev/null && echo CONNECTED'

Verify — the output is CONNECTED. The Play Store now downloads apps normally.

Note. The VM reaches your host at 192.168.56.1 (the VirtualBox host-only gateway), not 10.0.2.2 (which is the Android Studio AVD address). You will use 192.168.56.1 for the proxy in Part 6.


5. Trust Burp's CA (Browsers and Apps)

Recall the trust model: browsers read the user store; apps read the system store and usually pin. You will configure all three clients.

Step 1 — Export Burp's CA on the host. Find your listener port under Burp → Proxy → Proxy settings → Proxy listeners (commonly 8080):

BURP=127.0.0.1:8080                                          # your Burp listener
curl -s http://$BURP/cert -o burp.der
openssl x509 -inform der -in burp.der -out burp.pem
HASH=$(openssl x509 -subject_hash_old -noout -in burp.pem)   # e.g. 9a5ba575
cp burp.pem "$HASH.0"
echo "Cert file = $HASH.0"
adb push "$HASH.0" /data/local/tmp/

Verify — the script prints a hash such as 9a5ba575. The -noout flag is required; without it $HASH captures the entire PEM and every later command fails.

5A. Browsers — Chrome (user store)

adb shell 'su -c "
  mkdir -p /data/misc/user/0/cacerts-added
  cp /data/local/tmp/'"$HASH"'.0 /data/misc/user/0/cacerts-added/
  chown system:system /data/misc/user/0/cacerts-added/'"$HASH"'.0
  chmod 644 /data/misc/user/0/cacerts-added/'"$HASH"'.0
  chcon u:object_r:keychain_data_file:s0 /data/misc/user/0/cacerts-added/'"$HASH"'.0
  stop && start
"'

Verify — after Part 6, Chrome loads https://example.com with no warning and the request appears in Burp.

Note. Modern Chrome ships its own root store and ignores CAs added to the system store at runtime, but it honors the user store. Always use the user store for Chrome.

5B. Browsers — Firefox (its own store)

Firefox ignores the Android trust store entirely. Launch Firefox once to create its profile, then enable one preference.

GUI: open about:config, set security.enterprise_roots.enabled to true, and restart Firefox.

Scripted:

PKG=org.mozilla.firefox
PROF=$(adb shell "su -c 'ls /data/data/$PKG/files/mozilla/ | grep .default'" | tr -d '\r')
UID=$(adb shell "su -c 'stat -c %u /data/data/$PKG'" | tr -d '\r')
printf 'user_pref("security.enterprise_roots.enabled", true);\n' > /tmp/user.js
adb push /tmp/user.js /data/local/tmp/user.js
adb shell "su -c 'cp /data/local/tmp/user.js /data/data/$PKG/files/mozilla/$PROF/user.js; \
  chown $UID:$UID /data/data/$PKG/files/mozilla/$PROF/user.js; \
  restorecon /data/data/$PKG/files/mozilla/$PROF/user.js; am force-stop $PKG'"

Verify — after restarting Firefox, https://example.com loads with the padlock.

5C. Apps — a durable Magisk CA module

On Android 14 the system trust store spans two locations: /system/etc/security/cacerts/ and the read-only APEX path /apex/com.android.conscrypt/cacerts/. Apps may read either, so your CA must be present in both.

Warning. A one-off mount -t tmpfs … from a shell does not work here. That mount lands in a transient mount namespace — it never reaches the zygote-spawned app processes, and it disappears on the next framework restart. The reliable method is a Magisk module: Magisk's magic-mount runs before zygote, in the global namespace, and reapplies on every boot.

Step 1 — Build and install the module (covers both stores):

M=/tmp/burpca; rm -rf $M; mkdir -p $M/system/etc/security/cacerts
cp "$HASH.0" $M/system/etc/security/cacerts/$HASH.0      # $HASH from Step 1 above

cat > $M/module.prop <<EOF
id=burpca
name=Burp CA System Trust (A14)
version=1.0
versionCode=1
author=lab
description=Trust the Burp CA in the app (system + APEX) trust store.
EOF

# Magic-mount injects /system/etc/security/cacerts automatically.
# post-fs-data.sh also injects the Conscrypt APEX store, before zygote, so apps inherit it.
cat > $M/post-fs-data.sh <<EOF
#!/system/bin/sh
MODDIR=\${0%/*}
CERT="\$MODDIR/system/etc/security/cacerts/$HASH.0"
APEX=/apex/com.android.conscrypt/cacerts
i=0; while [ ! -d "\$APEX" ] && [ \$i -lt 30 ]; do sleep 0.3; i=\$((i+1)); done
[ -d "\$APEX" ] || exit 0
TMP=/dev/.burpca; rm -rf "\$TMP"; mkdir -p "\$TMP"
cp -f "\$APEX"/* "\$TMP"/ 2>/dev/null; cp -f "\$CERT" "\$TMP"/
chmod 644 "\$TMP"/*; chcon u:object_r:system_security_cacerts_file:s0 "\$TMP"/* 2>/dev/null
mount -t tmpfs tmpfs "\$APEX"; cp -f "\$TMP"/* "\$APEX"/
chmod 644 "\$APEX"/*; chcon u:object_r:system_security_cacerts_file:s0 "\$APEX"/* 2>/dev/null
EOF
chmod 644 $M/post-fs-data.sh

( cd $M && zip -qr /tmp/burpca.zip . )
adb push /tmp/burpca.zip /data/local/tmp/burpca.zip
adb shell 'su -c "magisk --install-module /data/local/tmp/burpca.zip"'
adb reboot

You can also install the zip through Magisk → Modules → Install from storage.

Step 2 — Verify it reached a running app's namespace (the check the tmpfs method fails):

adb wait-for-device; sleep 5
PID=$(adb shell "su -c 'pidof com.android.systemui'" | tr -d '\r')
adb shell "su -c 'ls /proc/$PID/root/system/etc/security/cacerts/$HASH.0 /proc/$PID/root/apex/com.android.conscrypt/cacerts/$HASH.0'"

Verify — both paths are listed (no "No such file"). Non-pinned apps now trust Burp, permanently. Pinned apps still require Frida (Part 8).


6. Route Traffic Through Burp

In Burp → Proxy → Proxy settings → Bind to address, select All interfaces so the VM can reach the listener.

Method 1 — ADB global proxy (recommended):

adb shell settings put global http_proxy 192.168.56.1:8080     # enable  (host = 192.168.56.1)
adb shell settings put global http_proxy :0                    # disable

Method 2 — Wi-Fi settings (GUI): Settings → Network & Internet → Wi-Fi → (long-press the network) → Modify → Advanced → Proxy: Manual, host 192.168.56.1, port your Burp listener. Useful when Method 1 misbehaves.

Confirm: open Chrome and visit https://example.com.

Verify — the request appears in Burp → Proxy → HTTP history. (Test Firefox only after 5B. An app failing here is pinning, addressed in Part 8 — not a proxy fault.)

SymptomCauseFix
Nothing in HTTP historyBurp bound to loopback onlyBind to All interfaces, re-set the proxy
ERR_PROXY_CONNECTION_FAILEDWrong host IPUse 192.168.56.1 (not 10.0.2.2, not your Wi-Fi IP)
All connections time out, even with the proxy offBroken IPv6Apply Part 4
HTTPS fails in an app onlyThe app pins its certificateUse Frida (Part 8)

7. Automate the Proxy and the Lab

Add proxy shortcuts to ~/.zshrc or ~/.bashrc. These read your host IP automatically from the network interface, so the proxy keeps working when your IP changes:

export BURP_PORT=8080              # your Burp listener
export BURP_IFACE=wlan0            # the host interface the VM can reach (vboxnet0 for Genymotion's static 192.168.56.1)

# Auto-detect the host IP from $BURP_IFACE and point the device at Burp
alias proxy_on='adb shell settings put global http_proxy $(ip -4 addr show "$BURP_IFACE" | grep -oP "(?<=inet\s)\d+(\.\d+){3}"):$BURP_PORT'
alias proxy_off='adb shell settings put global http_proxy :0'

Note. Set BURP_IFACE to the interface the VM actually reaches. Genymotion is bridged to your Wi-Fi, so wlan0 works; or use vboxnet0, whose address is always the static 192.168.56.1. Check your interfaces with ip -4 addr. Bind Burp to All interfaces either way.

Then create a single helper to re-arm the non-persistent parts each session. Root, both certificates, and the IPv6 fix persist; only frida-server needs restarting after a boot. On Genymotion it must be started through su with setsid, because a plain nohup … & over adb shell su -c dies when the shell exits. Save as ~/tools/geny.sh:

#!/usr/bin/env bash
BURP_IFACE="${BURP_IFACE:-wlan0}"; BURP_PORT="${BURP_PORT:-8080}"
HOST_IP=$(ip -4 addr show "$BURP_IFACE" | grep -oP '(?<=inet\s)\d+(\.\d+){3}')   # auto-detect host IP
adb connect 192.168.56.106:5555 >/dev/null 2>&1     # your device serial (no-op if already connected)
adb shell "su -c 'for c in all default wlan0 radio0 eth0; do sysctl -w net.ipv6.conf.\$c.disable_ipv6=1 2>/dev/null; done'"
adb push ~/tools/frida-server /data/local/tmp/frida-server >/dev/null
adb shell "su -c 'chmod 755 /data/local/tmp/frida-server; pkill -f frida-server 2>/dev/null; setsid /data/local/tmp/frida-server >/dev/null 2>&1 </dev/null &'"
adb shell "settings put global http_proxy ${HOST_IP}:${BURP_PORT}"
echo "[ok] IPv6 disabled, frida-server running, proxy -> ${HOST_IP}:${BURP_PORT}"
chmod +x ~/tools/geny.sh

Verify — running ~/tools/geny.sh prints [ok] IPv6 disabled, frida-server running, proxy -> <your-host-ip>:8080.


8. Defeat SSL Pinning with Frida

Many apps pin their server certificate, rejecting even a trusted CA. Frida hooks the application at runtime and disables those checks. This part covers standard Java-layer pinning, which Objection clears in one command. Hardened apps are covered in Part 10.

Step 1 — Install frida-server on the device. Two rules: the version must match frida --version exactly, and the architecture must match the device (x86_64 on Genymotion). Derive the filename rather than hard-coding it:

VER=$(frida --version)
ABI=$(adb shell getprop ro.product.cpu.abi | tr -d '\r')     # x86_64
wget -O frida-server.xz "https://github.com/frida/frida/releases/download/$VER/frida-server-$VER-android-$ABI.xz"
unxz frida-server.xz
mkdir -p ~/tools && mv frida-server ~/tools/frida-server

Step 2 — Start it (through su, detached):

adb push ~/tools/frida-server /data/local/tmp/frida-server
adb shell "su -c 'chmod 755 /data/local/tmp/frida-server; setsid /data/local/tmp/frida-server >/dev/null 2>&1 </dev/null &'"
frida-ps -U | head

Verifyfrida-ps -U returns a list of running processes. A version-mismatch error here is the most common failure; re-download to match frida --version.

Note. The older pattern adb root; adb shell "/data/local/tmp/frida-server &" is for the Android Studio AVD. It fails on Magisk-rooted Genymotion because adb root is refused and a bare & dies with the shell. Use the su -c 'setsid … &' form. geny.sh already does this.

Step 3 — Bypass pinning with Objection:

objection -g com.example.app explore --startup-command "android sslpinning disable"

Verify — Objection reports pinning disabled, and the app's HTTPS traffic appears in Burp. (com.example.app is a placeholder — list packages with adb shell pm list packages.)

Objection command reference (full)

Launching Objection from the shell. Use explore for an interactive session, run for a single command, or patchapk to embed the Frida gadget into an APK for use on a non-rooted device:

objection -g com.example.app explore                                      # interactive REPL
objection -g com.example.app explore --startup-command "android sslpinning disable"
objection -g com.example.app explore -s "android root disable"            # multiple startup cmds with ;
objection -g com.example.app run android sslpinning disable               # one command, no REPL
objection -g com.example.app explore --startup-script hooks.js            # load a Frida script on start
objection patchapk -s target.apk                                          # repackage APK with Frida gadget (non-root)

All commands below run inside the explore REPL. <pkg> is shorthand for the target package (e.g. com.example.app).

# --- Session / help ---
help                                   # list all commands (help <cmd> for detail)
commands                               # full command tree
reconnect                              # reconnect to the agent
jobs list                              # show active hooks/jobs (each hook is a job)
jobs kill <job-id>                     # remove a specific hook
exit                                   # quit

# --- SSL pinning / root / debug bypasses ---
android sslpinning disable             # kill certificate pinning (OkHttp, TrustManager, Conscrypt, …)
android sslpinning disable --quiet     # same, without per-call logging
android root disable                   # bypass common root-detection checks
android root simulate                  # make the app believe the device IS rooted (testing)

# --- Recon: environment & components ---
env                                    # app data dirs, code paths, libraries
frida                                  # Frida runtime + device info
android hooking list activities        # all activities
android hooking list services          # all services
android hooking list receivers         # all broadcast receivers
android hooking get current_activity   # the activity on screen right now
android hooking list classes           # every loaded class (large)
android hooking search classes login   # find loaded classes by keyword
android hooking search methods <pattern>            # find methods by keyword
android hooking list class_methods com.example.app.AuthManager   # methods of one class

# --- Method hooking / runtime tampering ---
android hooking watch class com.example.app.AuthManager          # log every call into a class
android hooking watch class_method com.example.app.Login.check --dump-args --dump-return --dump-backtrace
android hooking set return_value com.example.app.RootCheck.isRooted false
android hooking set return_value com.example.app.License.isValid true
android hooking generate simple com.example.app.AuthManager      # print a ready-to-edit Frida hook

# --- Local storage & secrets ---
android keystore list                  # entries in the Android Keystore
android keystore watch                 # log Keystore usage live
android keystore clear                 # wipe Keystore entries (destructive)
sqlite connect /data/data/<pkg>/databases/app.db    # open a DB → then: .tables / .schema / SQL
android clipboard monitor              # dump clipboard contents as they change

# --- Filesystem (operates inside the app sandbox) ---
pwd ; ls ; cd /data/data/<pkg>         # browse the sandbox
file cat /data/data/<pkg>/shared_prefs/prefs.xml     # print a file
file download /data/data/<pkg>/shared_prefs/prefs.xml ./prefs.xml
file upload payload.txt /data/local/tmp/payload.txt
rm /data/local/tmp/payload.txt

# --- Memory ---
memory list modules                    # loaded native modules
memory list exports libssl.so          # exported symbols of a module
memory search "api_key" --string       # scan process memory for a string
memory dump all app_memory.bin         # dump the full process memory
memory dump from_base <module> <offset> <size> out.bin

# --- UI / screenshots ---
android ui screenshot shot.png         # capture the current screen
android ui FLAG_SECURE false           # disable FLAG_SECURE (allow screenshots of "secure" screens)

# --- Intents / deep links ---
android intent launch_activity com.example.app.DeepLinkActivity
android intent launch_service com.example.app.SyncService

# --- Run a raw shell command / load a custom Frida script ---
android shell_exec "id"                # run a device shell command from the REPL
import myhook.js                        # load and run a custom Frida script in the app

Note. Hooks are jobsandroid hooking watch … keeps logging until you jobs kill <id> (or jobs list to see them). Replace every <pkg> and class/method name with the target's real identifiers, which you discover with android hooking search classes / search methods.

Ready-made bypass scripts

When Objection's generic bypass fails, load a community script with frida -U -f com.example.app -l script.js. The most comprehensive maintained suite is HTTP Toolkit's, covering Java, native, and Flutter pinning:

git clone https://github.com/httptoolkit/frida-interception-and-unpinning.git ~/tools/htk-unpin
# Edit ~/tools/htk-unpin/config.js: paste burp.pem into CERT_PEM, set PROXY_HOST=192.168.56.1, PROXY_PORT=8080
ResourcePurposeURL
HTTP Toolkit — frida-interception-and-unpinningBroadest, maintained; Java + native + Flutterhttps://github.com/httptoolkit/frida-interception-and-unpinning
Objectionandroid sslpinning disable / android root disablehttps://github.com/sensepost/objection
Frida CodeShareSearchable community scriptshttps://codeshare.frida.re/
Frida releasesfrida-server builds (match your version)https://github.com/frida/frida/releases
OWASP MASTGReference methodologyhttps://mas.owasp.org/MASTG/
r0captureFull TLS capture below the pinning layerhttps://github.com/r0ysue/r0capture

Note. The popular pcipolloni CodeShare script does not disable pinning — it re-pins the app to your CA, loaded from a hard-coded device path. Without that file you get FileNotFoundException: /data/local/tmp/cert-der.crt. Either push the DER first (adb push burp.der /data/local/tmp/cert-der.crt) or use the HTTP Toolkit suite, which needs no such file.


9. Bypass Root and Frida Detection

Some apps refuse to run on a rooted device or terminate when they detect Frida. With root and Frida you defeat both.

Practice target — Allsafe, an intentionally vulnerable app with dedicated root-detection, Frida-detection, SSL-pinning, and insecure-storage challenges:

gh release download -R t0thkr1s/allsafe-android --pattern '*.apk' --dir ~/tools --clobber   # or download from the Releases page
adb install -r ~/tools/allsafe.apk        # package: infosecadventures.allsafe

Step 1 — Create a master root-detection bypass at ~/tools/root-bypass.js. This is not a one-app trick; it defeats the six common vectors at once — file checks (Java + native), command execution, PackageManager root-app detection, system properties (Java + native), the RootBeer library, and a fast app-only scan for custom isRooted()-style methods:

'use strict';
// MASTER Android root-detection bypass.  Spawn with:  frida -U -f <pkg> -l ~/tools/root-bypass.js
// For a stubborn custom flag, add "fully.qualified.Class.method" to CONFIG_EXTRA.
var CONFIG_EXTRA = [
  // 'com.example.security.RootUtil.isDeviceRooted',
];
var ROOT_TOKENS = ['su','busybox','magisk','magiskhide','magiskinit','superuser','supersu','daemonsu',
  'kingouser','kingoroot','xposed','substrate','riru','zygisk','topjohnwu','eu.chainfire',
  '/sbin/su','/system/xbin/su','/system/bin/su','/su/bin','which su'];
var ROOT_PKGS = ['com.topjohnwu.magisk','eu.chainfire.supersu','com.koushikdutta.superuser',
  'com.noshufou.android.su','com.thirdparty.superuser','com.yellowes.su','com.kingouser.com','me.weishu.kernelsu'];
var SECURE_PROPS = {'ro.debuggable':'0','ro.secure':'1','ro.build.type':'user','ro.build.tags':'release-keys',
  'ro.build.selinux':'1','service.adb.root':'0','ro.boot.veritymode':'enforcing'};
function rooty(s){ if(!s) return false; s=(''+s).toLowerCase(); for(var i=0;i<ROOT_TOKENS.length;i++) if(s.indexOf(ROOT_TOKENS[i])!==-1) return true; return false; }
var n = { file:0, exec:0, pm:0, prop:0, rootbeer:0, generic:0, native:0 };

Java.perform(function () {
  // 1) java.io.File checks
  try {
    var File = Java.use('java.io.File');
    ['exists','canRead','canExecute','isFile','isDirectory'].forEach(function(m){
      if (!File[m]) return;
      File[m].overload().implementation = function(){ if (rooty(this.getAbsolutePath())) { n.file++; return false; } return this[m](); };
    });
  } catch(e){}
  // 2) Runtime.exec / ProcessBuilder
  try {
    var RT = Java.use('java.lang.Runtime'); var IOE = Java.use('java.io.IOException');
    var blk = function(c){ if (rooty(c)) { n.exec++; throw IOE.$new('not found'); } };
    RT.exec.overload('java.lang.String').implementation = function(c){ blk(c); return this.exec(c); };
    RT.exec.overload('[Ljava.lang.String;').implementation = function(c){ blk(c.join(' ')); return this.exec(c); };
    RT.exec.overload('java.lang.String','[Ljava.lang.String;').implementation = function(c,e){ blk(c); return this.exec(c,e); };
    RT.exec.overload('[Ljava.lang.String;','[Ljava.lang.String;').implementation = function(c,e){ blk(c.join(' ')); return this.exec(c,e); };
    var PB = Java.use('java.lang.ProcessBuilder');
    PB.start.implementation = function(){
      try { if (rooty(this.command().toString())) { n.exec++; throw IOE.$new('not found'); } } catch(e){ if((''+e).indexOf('not found')!==-1) throw e; }
      return this.start();
    };
  } catch(e){}
  // 3) PackageManager — hide root-management apps
  try {
    var PM = Java.use('android.app.ApplicationPackageManager'); var NNFE = Java.use('android.content.pm.PackageManager$NameNotFoundException');
    ['getPackageInfo','getApplicationInfo'].forEach(function(m){
      if (!PM[m]) return;
      PM[m].overloads.forEach(function(ov){
        if (ov.argumentTypes.length && ov.argumentTypes[0].className === 'java.lang.String') {
          ov.implementation = function(){ if (ROOT_PKGS.indexOf(arguments[0])!==-1){ n.pm++; throw NNFE.$new(arguments[0]); } return ov.apply(this, arguments); };
        }
      });
    });
  } catch(e){}
  // 4) System.getProperty + Build.TAGS
  try { Java.use('java.lang.System').getProperty.overload('java.lang.String').implementation = function(k){ if (k==='ro.build.tags'){ n.prop++; return 'release-keys'; } return this.getProperty(k); }; } catch(e){}
  try { Java.use('android.os.Build').TAGS.value = 'release-keys'; } catch(e){}
  // 5) RootBeer — every detection method -> false
  try {
    var RB = Java.use('com.scottyab.rootbeer.RootBeer');
    ['isRooted','isRootedWithEmulatorCheck','isRootedWithBusyBoxCheck','isRootedWithoutBusyBoxCheck','checkForBinary',
     'checkForSuBinary','checkForBusyBoxBinary','checkSuExists','checkForRWPaths','checkForDangerousProps','checkForRootNative',
     'detectRootManagementApps','detectPotentiallyDangerousApps','detectRootCloakingApps','detectTestKeys','checkForMagiskBinary','isSelinuxFlagInEnabled']
      .forEach(function(m){ if (RB[m]) { RB[m].overloads.forEach(function(o){ o.implementation = function(){ return false; }; }); n.rootbeer++; } });
  } catch(e){}
  // explicit user overrides
  CONFIG_EXTRA.forEach(function(fq){ try { var d=fq.lastIndexOf('.'); Java.use(fq.substring(0,d))[fq.substring(d+1)].overloads.forEach(function(o){ o.implementation=function(){ return false; }; }); } catch(e){} });
  console.log('[*] Java hooks installed');
});

// 6) Native libc hooks (catches checks done in C/C++)
(function(){
  function lib(name){
    try { if (typeof Module.findExportByName==='function') return Module.findExportByName('libc.so', name); } catch(e){}
    try { if (typeof Module.getGlobalExportByName==='function') return Module.getGlobalExportByName(name); } catch(e){}
    try { return Process.getModuleByName('libc.so').findExportByName(name); } catch(e){}
    return null;
  }
  try {
    var bogus = Memory.allocUtf8String('/nonexistent_blocked_by_frida');
    ['fopen','open','openat','access','stat','lstat','stat64','lstat64'].forEach(function(fn){
      var p = lib(fn); if (!p) return; var idx = (fn==='openat') ? 1 : 0;
      Interceptor.attach(p, { onEnter: function(a){ try { if (rooty(a[idx].readUtf8String())) { a[idx]=bogus; n.native++; } } catch(e){} } });
    });
    var sysp = lib('system');
    if (sysp) Interceptor.attach(sysp, { onEnter: function(a){ try { if (rooty(a[0].readUtf8String())) { a[0]=Memory.allocUtf8String('exit 1'); n.native++; } } catch(e){} } });
    var spg = lib('__system_property_get');
    if (spg) Interceptor.attach(spg, {
      onEnter: function(a){ try { this.key=a[0].readUtf8String(); this.val=a[1]; } catch(e){} },
      onLeave: function(){ try { if (this.key && SECURE_PROPS[this.key]!==undefined){ this.val.writeUtf8String(SECURE_PROPS[this.key]); n.native++; } } catch(e){} }
    });
    console.log('[*] Native hooks installed');
  } catch(e){ console.log('[!] native: '+e); }
})();

// 7) Generic heuristic — custom isRooted()/isJailbroken()/isTampered() on APP classes only (fast)
setTimeout(function(){ Java.perform(function(){
  var TOK=/(rootbeer|rooted|rootcheck|rootdetect|rootutil|rootmanager|jailbreak|jailbroken|tamper|magisk|supersu)/;
  var FW=/^(android|androidx|java|javax|kotlin|com\.google|com\.android|dalvik|libcore|sun|jdk|org\.bouncycastle|okhttp|retrofit)/;
  var MN=/(isrooted|isdevicerooted|isrootavailable|checkroot|detectroot|isjailbroken|isjailbreak|istampered|checktamper|ismagisk)/;
  try { Java.enumerateLoadedClasses({ onMatch: function(cn){
    var simple = cn.substring(cn.lastIndexOf('.')+1).toLowerCase();
    if (cn.indexOf('[')!==-1 || FW.test(cn) || !TOK.test(simple)) return;
    try { var C = Java.use(cn); var ms = C.class.getDeclaredMethods();
      for (var i=0;i<ms.length;i++){ if (ms[i].getReturnType().getName()!=='boolean') continue;
        var mn = ms[i].getName(); if (!MN.test(mn.toLowerCase())) continue;
        try { C[mn].overloads.forEach(function(o){ o.implementation=function(){ n.generic++; return false; }; });
              console.log('[generic] forced false: '+cn+'.'+mn+'()'); } catch(e){} }
    } catch(e){}
  }, onComplete: function(){} }); } catch(e){}
  console.log('[+] MASTER root bypass active  ' + JSON.stringify(n));
}); }, 1500);

It covers the three classic vectors plus three more that defeat hardened apps: native (C/C++) checks via libc, PackageManager scans for Magisk/SuperSU, and property spoofing. For the rare app with a custom boolean flag, add its Class.method to CONFIG_EXTRA at the top.

Step 2 — Run it (spawn with -f so the hooks load before the checks execute):

frida -U -f infosecadventures.allsafe -l ~/tools/root-bypass.js

Verify — the console prints [*] Java hooks installed, [*] Native hooks installed, and [+] MASTER root bypass active {...}. Open the Root Detection challenge and tap Check: it reports "Congrats, root is not detected!". (Objection alternative: android root disable.)

Note. For Frida detection, spawn early with -f. If the app scans for the server, rename it and use a non-default port: mv frida-server fs; /data/local/tmp/fs -l 0.0.0.0:8888, then connect with frida -H 127.0.0.1:8888. The literal name frida-server and port 27042 are the first things apps check.


10. Intercept Hardened Apps

Objection's one-liner handles roughly 80% of apps. Hardened applications — banking, Google, and anything built to resist analysis — stack multiple defenses. You bypass them in layers.

Authorization. This applies only to your own account on an app you are explicitly authorized to test. Intercepting a bank's traffic outside that boundary is a criminal offense.

LayerMechanismCountermeasure
Java pinningOkHttp CertificatePinner, TrustManagerandroid-certificate-unpinning.js / Objection
Native pinningPins inside BoringSSL (libssl.so) — invisible to Java hooksnative-tls-hook.js (the key script)
HTTP/3 (QUIC)UDP/443 traffic a normal proxy cannot readBLOCK_HTTP3 = true (forces HTTP/2)
FlutterDart's own TLS stack, ignoring both Android storesandroid-disable-flutter-certificate-pinning.js
Root detectionRefuses to run on a rooted deviceandroid-disable-root-detection.js (Part 9)
Frida detectionScans for the server, port, or mapsSpawn -f, rename the server, custom port
Emulator detectionBlocks Genymotion / AVDUse a real rooted phone
Play Integrity / SafetyNetServer-side device attestationNot client-bypassable; may block the app

Run the suite. With config.js edited (CERT_PEM, PROXY_HOST=192.168.56.1, PROXY_PORT=8080, BLOCK_HTTP3=true), arm the lab and load config.js first, then the hooks you need:

~/tools/geny.sh                     # IPv6 off, frida-server up, proxy on
cd ~/tools/htk-unpin
frida -U -f com.target.app \
  -l config.js \
  -l native-tls-hook.js \
  -l android/android-certificate-unpinning.js \
  -l android/android-certificate-unpinning-fallback.js
# add  -l android/android-disable-root-detection.js              if it detects root
# add  -l android/android-disable-flutter-certificate-pinning.js  for Flutter apps
# add  -l native-connect-hook.js  (and enable Burp "Support invisible proxying")  for raw/gRPC sockets

Note. Keep Frida attached — closing the REPL removes the hooks and the app drops to "no connection." Always spawn with -f (not -n) so hooks install before the app's pinning and detection code runs, and always load config.js first, or you will see ReferenceError: 'DEBUG_MODE' is not defined.

If the app still will not launch:

  • Quits on a rooted device → root detection. Add android-disable-root-detection.js; on Magisk, also add the app to the DenyList.
  • Quits only with Frida running → Frida detection. Spawn -f, rename the server, use a custom port.
  • Blocks on the emulator entirely → use a real rooted phone (frida-server for arm64-v8a; everything else is identical).
  • Server rejects the rooted device → Play Integrity attestation, enforced server-side and not client-bypassable.

Verify — API calls appear in Burp → HTTP history, and the Frida console prints confirmations such as == Hooked native TLS lib libssl.so ==. If history stays empty while the app works, traffic is on an uncovered channel: confirm BLOCK_HTTP3 = true (QUIC) or add native-connect-hook.js (raw sockets).


11. Daily Quick-Start

Root, both certificates, the IPv6 fix, and the Firefox preference all persist. A fresh session is three steps:

# 1. Open Burp (bound to All interfaces) and start the Genymotion device.

# 2. Arm the lab — IPv6 off, frida-server up, proxy on:
~/tools/geny.sh

# 3. Browsers work immediately. For an app, disable pinning:
objection -g com.example.app explore --startup-command "android sslpinning disable"
#    (For a hardened app, use the Part 10 suite instead.)

# When finished:
proxy_off

Note. Browsers work the moment the proxy is on — no Frida required. Step 3 is only needed for apps, because of pinning.


Troubleshooting

SymptomLikely causeFix
adb devices emptyADB server stale / not connectedadb kill-server && adb start-server; adb connect 192.168.56.106:5555
su: not found after flashingWrong Magisk zipUse the Genymotion installer (Part 3), not a generic Magisk.zip
gmtool flash → "A license is required"Personal-edition CLI gateUse the drag-and-drop flash (Part 3)
Play Store stuck on "Downloading…"Broken bridged IPv6Disable IPv6 (Part 4)
All connections time out, even with the proxy offSame IPv6 issuePart 4
Proxy set but no trafficBurp on loopback / wrong host IPBind to All interfaces; use 192.168.56.1
Cert added via tmpfs is gone after restart / apps ignore itWrong mount namespace, not persistentUse the Magisk CA module (Part 5C); verify via /proc/<pid>/root/...
Chrome: ERR_CERT_AUTHORITY_INVALIDCA only in the system storeInstall into the user store (Part 5A)
Firefox: SEC_ERROR_UNKNOWN_ISSUERFirefox uses its own storeEnable security.enterprise_roots.enabled (Part 5B)
frida-ps errors / "unable to connect"Version mismatch or server died on rebootMatch frida-server to frida --version; re-run geny.sh
frida-server & dies immediatelyBare & over adb su -c exits with the shellStart with su -c 'setsid … </dev/null &' (Part 8)
ReferenceError: 'DEBUG_MODE'HTTP Toolkit hook run without config.jsLoad config.js first (Parts 9–10)
Hardened app crashes on launchRoot or Frida detectionApply Part 9; spawn -f; rename the server / use a custom port
App works but no traffic in BurpHTTP/3 (QUIC) or raw socketsSet BLOCK_HTTP3=true; add native-connect-hook.js with invisible proxying

Summary

You now have a complete, reproducible Android pentesting lab built from a clean image: a Genymotion Android 14 device rooted with Magisk, a working Play Store, Burp's CA trusted durably by browsers and apps, and Frida with Objection ready to defeat SSL pinning and root or Frida detection. The single command ~/tools/geny.sh re-arms everything each session.

Two principles carry the entire setup. Root is the foundation — on modern Genymotion you install it yourself with the Genymotion-specific Magisk build, after which it is permanent. And trust is not a single switch — browsers read the user store, apps read the system and APEX stores and pin on top, so you trust durably with a Magisk module and strip pinning with Frida. Apply the right method to each client and HTTPS interception becomes routine.

Related articles

Comments(0)

Sign in to join the conversation.

  • Be the first to comment.