Android App Pentesting: Intercepting and Patching an APK
A hands-on Android app pentesting walkthrough: build an emulator lab, route traffic through Burp, beat certificate pinning with Frida, read the app with jadx and apktool, then patch smali and rebuild a signed APK.
Android app pentesting is the practice of pulling a mobile app apart to see what it trusts the phone to do, and this walkthrough runs the full loop on a lab app you own: intercept its traffic, read its code, patch it, and put it back together. The app runs on your device, so you control the device, the network, and the certificate store. That control is the whole game. Anything the app decides on the client side, you can watch, change, or rip out, which is exactly why real security has to live on the server.
Work through it once in order to build the method, then keep it open as a checklist. Every command here targets a practice app in an emulator or a rooted test device you own. The example backend is our own lab host, lab.0xpriv.dev.
Only test apps you built, apps you have written permission to assess, or deliberately vulnerable practice apps such as DIVA and InsecureBankv2 running in your own emulator. Decompiling or tampering with someone else's app or Play Store software without authorization is illegal.
01What Android app pentesting actually tests
Android app pentesting tests one assumption above all others: can the app trust the phone it runs on. The answer is almost always no. An APK is a signed zip of compiled Dalvik bytecode, resources, and a manifest, and once it sits on a device you own, every secret inside it, every network call it makes, and every check it performs is visible and editable. The job is to prove that and to show which of those checks the backend wrongly depends on.
The work splits into two halves that feed each other. Dynamic analysis watches the running app: its HTTPS traffic, its files, its runtime behavior. Static analysis reads the shipped code: decompiled classes, resources, and the smali bytecode you can patch. You pivot between them constantly, using traffic to find an endpoint and the code to understand it, then patching the code to defeat a control the traffic revealed.
02Setting up the lab
Setting up the lab means getting a controllable Android target and a vulnerable app onto it. The two sane options are the Android Studio emulator, which is free and resettable, or a spare rooted test device. For interception to work smoothly, pick an emulator image without Google Play, because a Google APIs or AOSP image lets you gain root on the device and write to the system partition, which Play images block.
# 0xpriv.dev/labs # list the emulator images, then boot one with a writable system partition sdkmanager --list | grep "system-images;android-30" emulator -avd Pixel_API_30 -writable-system -no-snapshot # confirm the device and grab root on a non-Play image adb devices adb root adb shell getprop ro.build.version.sdk
Now side-load a deliberately vulnerable app. DIVA (Damn Insecure and Vulnerable App) and InsecureBankv2 are the classics: both ship hardcoded secrets, insecure storage, and weak client-side checks on purpose, so you can practice every technique below without touching anything real.
$ adb install insecurebankv2.apk Success $ adb shell pm list packages | grep bank package:com.android.insecurebankv2 $ adb shell pm path com.android.insecurebankv2 package:/data/app/~~a1/com.android.insecurebankv2-1/base.apk
You do not always start with the APK file. adb shell pm path <package> prints its location, then adb pull <path> base.apk copies it to your workstation for static analysis.
03Routing traffic through Burp
Routing traffic through Burp lets you read and edit every request the app makes, but modern Android fights you on HTTPS. Since apps targeting API level 24 and above, the app ignores user-added CA certificates by default. Dropping the Burp certificate in the user store is no longer enough, so you install it as a system certificate instead.
First set the emulator's proxy to your Burp listener, export the Burp CA, and convert it into the hashed filename Android expects in its system trust store.
# point the device at Burp (host machine on the emulator is 10.0.2.2) adb shell settings put global http_proxy 10.0.2.2:8080 # export the Burp CA from the Proxy settings as DER, then convert and hash it openssl x509 -inform DER -in cacert.der -out burp.pem HASH=$(openssl x509 -inform PEM -subject_hash_old -noout -in burp.pem) cp burp.pem "${HASH}.0"
Then push the hashed certificate into the system store, fix its permissions, and reboot so the app picks it up as a trusted root.
$ adb root && adb remount remount succeeded $ adb push 9a5ba575.0 /system/etc/security/cacerts/ $ adb shell chmod 644 /system/etc/security/cacerts/9a5ba575.0 $ adb reboot
With the CA trusted system-wide, most apps now flow through Burp in clear text and you can read every endpoint, header, and body. The apps that still refuse are pinning their certificate, which is the next wall.
04Bypassing certificate pinning with Frida
Certificate pinning is the reason your traffic sometimes stays dark even after the CA is trusted. A pinned app ships a copy of the expected server certificate or public key and refuses any connection that presents a different one, so Burp's re-signed certificate triggers a handshake failure and the app shows a network error. You defeat it at runtime with Frida, a dynamic instrumentation toolkit that hooks into the live process and rewrites the pinning check as the app runs.
The friendliest front end is objection, which wraps Frida and ships a ready pinning bypass. Start the Frida server on the device, then let objection disable the check.
# run the matching frida-server on the device (as root), then hook the app adb push frida-server /data/local/tmp/ && adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &" # objection spawns the app and disables SSL pinning in one command objection -g com.android.insecurebankv2 explore # then at the objection prompt: # android sslpinning disable # or hook it directly with a Frida unpinning script frida -U -f com.android.insecurebankv2 -l unpinning.js
$ objection -g com.android.insecurebankv2 explore com.android.insecurebankv2 on (google: 11) [usb] # android sslpinning disable [+] Hooking OkHTTP 3.x CertificatePinner.check() [+] Hooking TrustManagerImpl.verifyChain() [+] Hooking javax.net.ssl.SSLContext.init() [+] SSL pinning disabled, traffic now visible in Burp
The frida-server binary on the device must match your host Frida client version and the device CPU (arm64, x86_64). A version mismatch is the usual reason the hook never attaches.
05Decompiling with jadx and apktool
Decompiling turns the shipped APK back into something you can read and something you can edit, and the two jobs want two different tools. jadx reconstructs readable Java-like source, which is perfect for understanding logic quickly. apktool decodes resources and the manifest and produces smali, the human-readable form of the app's Dalvik bytecode, which is what you patch and rebuild.
# jadx: readable source for understanding (CLI or the GUI) jadx -d jadx_out base.apk jadx-gui base.apk # searchable, cross-referenced, easiest for reading # apktool: decode resources + smali, the form you can rebuild apktool d base.apk -o app_src ls app_src # AndroidManifest.xml apktool.yml res/ smali/ smali_classes2/ assets/
Use jadx first to find the class and method you care about, then open the matching smali file under the apktool output to make the change. The two line up one to one, because they are two views of the same bytecode.
06Reading the static code for secrets and storage
Reading the static code is where most quick wins live, because developers hide secrets in the APK and assume nobody looks. A grep through the decompiled source turns up hardcoded API keys, backend endpoints, and credentials faster than any dynamic test. The resources are just as talkative: res/values/strings.xml often holds URLs and keys in plain text.
$ grep -rniE "api[_-]?key|secret|password|token|bearer" jadx_out/sources | head DefaultConfig.java:14: String API_KEY = "AIzaSyB-labkey-do-not-ship-9f2"; CryptoClass.java:9: byte[] key = "This1sMyK3y01234".getBytes(); $ grep -rniE "https?://" jadx_out/sources | grep -i lab ApiService.java:21: String BASE = "https://lab.0xpriv.dev/api/";
Insecure storage is the next pass, and it runs on the device. Android apps keep private files under /data/data/<package>/, and the sloppy ones park passwords, tokens, or whole SQLite databases there in clear text. SharedPreferences XML and unencrypted databases are the usual offenders.
# read what the app stored on disk (device must be rooted) adb shell "run-as com.android.insecurebankv2 cat /data/data/com.android.insecurebankv2/shared_prefs/mySharedPreferences.xml" # superusername / superpassword stored base64-encoded, which is not encryption adb shell "ls /data/data/com.android.insecurebankv2/databases/" adb pull /data/data/com.android.insecurebankv2/databases/mydb.db
| Where to look | What turns up |
|---|---|
res/values/strings.xml | Endpoints, API keys, feature flags left in plain text |
shared_prefs/*.xml | Session tokens, saved credentials, often only base64 encoded |
databases/*.db | Unencrypted SQLite holding user and message data |
Decompiled sources/ | Hardcoded keys, signing checks, client-side validation logic |
07Patching smali to disable a control
Patching smali is how you remove a client-side control the app depends on, such as root detection or certificate pinning. You find the method that returns the decision, then rewrite its bytecode so it always returns the answer you want. Root detection is the cleanest example: a method that returns a boolean, which you force to false.
Say jadx shows a method isDeviceRooted() that returns true when it spots su binaries. Open the matching smali and make it return false unconditionally.
# 0xpriv.dev/labs -- force the root check to always return false .method public isDeviceRooted()Z .locals 1 const/4 v0, 0x0 return v0 .end method
The same move defeats in-app pinning when you would rather patch than hook: find the pinning verify method and make it return early, or strip the checks and let the verification pass. For features gated behind a flag, patch the getter the same way. A method like isPremium()Z patched to return 0x1 unlocks every premium screen the client was hiding, which is a fast way to prove the gate was never enforced on the server.
08Rebuilding, signing, and reinstalling
Rebuilding takes your edited smali back to an installable APK, but Android will not install an app whose signature is broken, so you resign it with your own key. The order matters: build, align, sign, then install. A single workflow covers it every time.
Rebuild the APK
Repack the edited sources with apktool b app_src -o patched.apk. apktool recompiles the smali and reassembles resources.
Align it
Run zipalign -p -f 4 patched.apk patched-aligned.apk so the archive meets Android's alignment rule before signing.
Sign with your own key
Create a throwaway keystore once with keytool, then sign the aligned APK with apksigner. The original developer signature is gone, which is fine on a device you own.
Reinstall and run
Uninstall the old copy, then adb install the signed build and launch it. Your patch is now live on the device.
# build, align, key, sign, verify, install apktool b app_src -o patched.apk zipalign -p -f 4 patched.apk patched-aligned.apk keytool -genkeypair -v -keystore lab.keystore -alias lab \ -keyalg RSA -keysize 2048 -validity 10000 -storepass labpass -keypass labpass -dname "CN=lab" apksigner sign --ks lab.keystore --ks-pass pass:labpass --out patched-signed.apk patched-aligned.apk apksigner verify patched-signed.apk && adb install -r patched-signed.apk
09A worked example: tampering a request and a client-side check
A worked example ties the pieces together: beat a client-side limit two ways and show the server never checked. The lab banking app enforces a transfer cap in the UI, graying out amounts above a limit for non-premium users, and it sends the transfer to the backend as a simple request. Because the cap lives in the app, both the interception path and the patch path break it.
The fast path is Burp. With pinning off, catch the transfer request and edit the amount past the client limit before it leaves. If the backend accepts it, the cap was never server-side.
$ POST /api/transfer HTTP/1.1 Host: lab.0xpriv.dev Authorization: Bearer eyJhbGci... Content-Type: application/json {"to":"acct-204","amount":50000} # client capped this at 1000 $ # server responds 200 OK, balance moved: authorization was client-side only
The durable path is the patch. Flip the isPremium() getter to return true in smali, rebuild, and reinstall, and the app now offers the full transfer range on its own. One proves the request is forgeable, the other proves the gate was cosmetic. Both point at the same root cause: the server trusted the client to enforce a rule.
# reproduce the raw request against the lab backend for the report curl -s -X POST https://lab.0xpriv.dev/api/transfer \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"to":"acct-204","amount":50000}'
10Detecting tampered clients
Detecting a tampered client is something the backend can do even though it cannot trust the device. The signal that matters is server-side: requests that break the app's own stated rules, such as a transfer above a limit the official client enforces, or a feature used by an account that should not have it. Those anomalies show up in application logs regardless of what the client was patched to do, which is why server-side validation doubles as detection.
On the integrity side, the Play Integrity API returns a verdict about whether the app binary and the device look genuine, and a resigned or patched build fails the app-recognition check. Treat that verdict as one input, logged and scored, not as a hard gate, because a determined tester on their own device can still probe the API. The point is to raise the cost and to notice the pattern, not to assume the client can police itself.
11Building an app that resists this
Building an app that resists this walkthrough comes down to moving trust off the device and making the client's job purely cosmetic. Every control you bypassed above fell because it ran where the attacker holds the keys. The fixes put the real decisions back on the server and make the client-side hurdles expensive rather than decisive.
strings.xml.A secure config helps too. A pinned network security configuration and a release build with code shrinking and obfuscation make the static reading harder, without pretending to be a substitute for server-side authorization.
<!-- res/xml/network_security_config.xml: pin the backend, with a backup -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">lab.0xpriv.dev</domain>
<pin-set>
<pin digest="SHA-256">base64primarypin=</pin>
<pin digest="SHA-256">base64backuppin=</pin>
</pin-set>
</domain-config>
</network-security-config>12FAQ
Is Android app pentesting legal?FAQ
It is legal against apps you own, apps you have written authorization to test, and deliberately vulnerable practice apps like DIVA or InsecureBankv2 in your own emulator. Decompiling, intercepting, or tampering with someone else's app or Play Store software without permission is not. Keep everything in a lab you control.
Why can't Burp see the app's HTTPS traffic even after I installed the certificate?FAQ
Two common reasons. Apps targeting API 24 and later ignore user-added certificates, so the Burp CA has to go in the system store, not the user store. If it is already a system cert and traffic is still dark, the app is pinning its certificate, which you bypass at runtime with Frida or objection, or by patching the pinning check in smali.
Do I need a rooted device?FAQ
For the full workflow, yes. Root lets you write the CA to the system store, read private app storage, and run frida-server. The easiest route is an emulator image without Google Play booted with a writable system partition, which gives you root without touching a physical phone. Pure static analysis with jadx and apktool needs no device at all.
What is the difference between jadx and apktool?FAQ
jadx decompiles the app into readable Java-like source, which is best for understanding logic and searching for secrets. apktool decodes the app into smali bytecode and resources, which is the form you can edit and rebuild. Read with jadx, patch with apktool.
Does certificate pinning make an app secure?FAQ
No. Pinning stops passive interception and casual proxying, which is worth doing, but a tester on a device they control can hook or patch it out. It is a speed bump. Real security comes from server-side authorization that rejects a forged request no matter what the client was modified to send.
references
Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.