Dynamic Instrumentation — Android Penetration Testing
Perspective: This note is written from the lens of a seasoned bug bounty hunter. Every section assumes you're working on a real engagement — rooted device in hand, jadx open on a second monitor, and a target app that doesn't want you inside it. Everything here connects. By the end, you should be able to walk into any Android app and instrument it at will.
What Is Dynamic Instrumentation?
Dynamic instrumentation is the practice of injecting logic into a running process to observe or modify its behaviour — without modifying the binary on disk (or optionally with minimal patching). In Android penetration testing, this means you attach to a live app process and rewrite how its methods behave in real time.
Compare the two paradigms:
| Approach | What You Do | Limitation |
|---|---|---|
| Static Analysis | Decompile APK, read code in jadx | You see code, not execution. Obfuscation hides logic. |
| Dynamic Instrumentation | Hook live methods, intercept calls, read memory | Requires a running process. Needs root or a patched APK. |
The real power is that dynamic instrumentation sees the truth. Obfuscated class names, encrypted strings, runtime-generated keys — none of that matters once you're hooked into the process and watching methods execute live.
The Mental Model
Think of it as AOP (Aspect-Oriented Programming) but adversarial. You're inserting cross-cutting logic — logging, interception, return value overrides — around method calls you don't own. The app's JVM (or ART runtime on Android) doesn't know the difference. From the runtime's perspective, your hook is the method.
Why It Matters in Bug Bounty
In a real engagement, dynamic instrumentation is how you:
- Break SSL pinning — override the certificate validation logic at runtime instead of fighting the compiled bytecode
- Bypass root detection — make
RootBeer.isRooted()returnfalsewithout touching smali - Read decrypted secrets — hook the decryption method and log the plaintext after the app decrypts it for you
- Understand business logic — trace which methods fire during a money transfer, a privilege check, or an OTP verification
- Forge authentication tokens — intercept the signing method and inject your own payload
The framework that enables all of this on Android is Frida. Objection is a pre-built Frida toolkit that accelerates common tasks.
Module 1 — Setup
Installing Frida and Objection
Frida has two parts: the Python tooling on your host machine, and the frida-server binary that runs on the Android device.
Host setup (your Kali / macOS machine):
# Install frida tools and objection
pip3 install frida-tools objection
# Verify
frida --version
objection --version
Download frida-server for the device:
The server binary must exactly match your host frida version and the device's CPU architecture. A mismatch will cause a silent connection failure.
# Check your frida version
frida --version
# e.g. 16.2.1
# Check device architecture
adb shell getprop ro.product.cpu.abi
# e.g. arm64-v8a
# Download from GitHub releases — match version + arch
# https://github.com/frida/frida/releases
# Filename pattern: frida-server-16.2.1-android-arm64.xz
xz -d frida-server-16.2.1-android-arm64.xz
adb push frida-server-16.2.1-android-arm64 /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
Running the Frida Server
# On the device (as root)
adb shell
su
/data/local/tmp/frida-server &
# Verify from host — you should see a list of running processes
frida-ps -U
If frida-ps -U times out, check:
- Version mismatch between host and server
- SELinux blocking the server — try
adb shell setenforce 0 - Another frida-server instance still running —
pkill frida-server
Real-world note: On hardened devices (e.g. Samsung Knox), SELinux enforcement is aggressive. In those cases, prefer the gadget injection method (patching the APK) rather than relying on frida-server with root.
Patching APKs with Frida Gadget
When you don't have root (common on physical devices from a private bug bounty program), you inject the Frida Gadget — a shared library that loads Frida's runtime inside the app itself.
Step 1: Decompile the APK
apktool d target-app.apk -o target-app
Step 2: Download the correct gadget for the device arch
# From https://github.com/frida/frida/releases
# e.g. frida-gadget-16.2.1-android-arm64.so.xz
xz -d frida-gadget-16.2.1-android-arm64.so.xz
mv frida-gadget-16.2.1-android-arm64.so target-app/lib/arm64-v8a/libfrida-gadget.so
Step 3: Inject the load call into smali
Find the main Activity's onCreate method in smali. Add a System.loadLibrary("frida-gadget") call at the very top:
# In smali, the first instruction of onCreate:
const-string v0, "frida-gadget"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
Step 4: Repackage and sign
apktool b target-app -o target-app-patched.apk
# Generate a debug keystore if needed
keytool -genkey -v -keystore debug.keystore -alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000
# Sign
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 \
-keystore debug.keystore target-app-patched.apk androiddebugkey
# Zipalign
zipalign -v 4 target-app-patched.apk target-app-final.apk
# Install
adb install target-app-final.apk
When you launch the patched app, it will pause at startup waiting for a Frida connection — this is the gadget's default behaviour. Connect with:
frida -U gadget
Real-world scenario: I was testing a fintech app (
com.payfast.mobile) from a private program. The device was a non-rooted Samsung Galaxy — no root, no frida-server. I patched the APK with the gadget, installed it over the original (after backing up data), and had full Frida access within 10 minutes. The app had anti-root checks that were completely irrelevant because the gadget bypasses them by loading before those checks run.
Module 2 — Frida Basics
Java.perform — The Foundation of Everything
Before you can touch any Java class or method, you must wrap your code inside Java.perform(). This callback executes in the context of the app's Java runtime (ART). Without it, Java.use() will throw.
Java.perform(function () {
// All Java hooking lives here
var MyClass = Java.use('com.example.app.MyClass');
console.log('Class loaded: ' + MyClass);
});
Why does this exist? Frida runs in a native thread. ART (Android Runtime) requires that Java calls happen on an ART-managed thread. Java.perform marshals your callback onto the correct thread.
Instantiating Objects and Calling Methods
This is one of the most underutilised techniques in bug bounty. You don't have to hook a method to call it — you can instantiate objects and invoke methods directly from your Frida script.
Calling a static method:
Java.perform(function () {
var Utils = Java.use('com.example.app.Utils');
// Call a static method directly
var result = Utils.decryptString('encryptedPayload123', 'secretKey');
console.log('Decrypted: ' + result);
});
Instantiating an object and calling an instance method:
Java.perform(function () {
var TokenManager = Java.use('com.example.app.auth.TokenManager');
// $new() calls the constructor
var instance = TokenManager.$new();
var token = instance.generateAdminToken('user_id_123');
console.log('Token: ' + token);
});
Overloaded methods — when a class has multiple methods with the same name but different signatures, you must use .overload():
Java.perform(function () {
var Crypto = Java.use('com.example.app.CryptoHelper');
// If decrypt() has two versions: decrypt(String) and decrypt(String, String)
var result = Crypto.decrypt.overload('java.lang.String', 'java.lang.String')
.call(Crypto, 'ciphertext', 'iv_value');
console.log(result);
});
Real-world scenario: During a bug bounty on
com.bankingapp.android, I found a class calledcom.bankingapp.crypto.AESHelperin jadx. It had adecrypt(String ciphertext, String key)static method. I could see encrypted values being stored in SharedPreferences. UsingJava.use+.call(), I invoked the decryption method directly with the stored ciphertext and recovered the hardcoded AES key. No need to hook anything — I just called the app's own decryption function. This led to a P2 hardcoded cryptographic key finding.
Mixing Static and Dynamic Analysis
The real workflow of a senior tester is a feedback loop between jadx and Frida — not sequential steps.
jadx → find interesting class → Frida hook → observe runtime values → back to jadx with new context
Example workflow:
You open jadx and find this in com.vulnapp.auth.SessionManager:
public String getAuthHeader() {
return "Bearer " + this.f2847a.b(this.mContext);
}
The method b() is obfuscated. You don't know what it does statically. So you hook it:
Java.perform(function () {
// Hook the obfuscated method
var a = Java.use('com.vulnapp.auth.a'); // obfuscated class
a.b.implementation = function (context) {
var result = this.b(context);
console.log('[+] Auth token: ' + result);
return result;
};
});
Now you trigger a network call in the app and you see the live JWT being printed. You take that JWT to jwt.io, decode it, and discover the role claim is set to "user" but the server doesn't validate it — that's your privilege escalation.
Tracing Activities
Activities are the entry points of Android UI flows. Tracing which Activity launches when gives you a map of the app's attack surface — especially exported Activities that skip authentication.
Java.perform(function () {
var Activity = Java.use('android.app.Activity');
Activity.onCreate.overload('android.os.Bundle').implementation = function (bundle) {
console.log('[Activity] onCreate: ' + this.getClass().getName());
this.onCreate(bundle);
};
Activity.onResume.implementation = function () {
console.log('[Activity] onResume: ' + this.getClass().getName());
this.onResume();
};
Activity.onStart.implementation = function () {
console.log('[Activity] onStart: ' + this.getClass().getName());
this.onStart();
};
});
Finding exported Activities you can launch directly:
# From the manifest
grep -i 'exported="true"' AndroidManifest.xml
# Launch an exported Activity directly (deeplink/intent abuse)
adb shell am start -n com.vulnapp/.ui.AdminPanelActivity
Real-world scenario: On a private bug bounty target (
com.retailapp.android), I traced Activities and noticedcom.retailapp.checkout.PaymentActivitywas being launched with an Intent extra calledpromo_code. By tracinggetIntent().getExtras()inonCreate, I could see the app read this value and apply it without server-side validation. I launched the Activity directly via ADB with an arbitrary promo code and got 100% discount — a P1 business logic vulnerability.
Tracing Fragments
Modern Android apps use Fragments extensively — a single Activity might host dozens of Fragments that represent different screens. Tracing Fragments reveals navigation that Activity tracing misses entirely.
Java.perform(function () {
var Fragment = Java.use('androidx.fragment.app.Fragment');
Fragment.onCreateView.overload(
'android.view.LayoutInflater',
'android.view.ViewGroup',
'android.os.Bundle'
).implementation = function (inflater, container, savedInstanceState) {
console.log('[Fragment] onCreateView: ' + this.getClass().getName());
return this.onCreateView(inflater, container, savedInstanceState);
};
Fragment.onResume.implementation = function () {
console.log('[Fragment] onResume: ' + this.getClass().getName());
this.onResume();
};
});
Tracing Fragment arguments (the data passed in):
Java.perform(function () {
var Fragment = Java.use('androidx.fragment.app.Fragment');
Fragment.setArguments.implementation = function (bundle) {
if (bundle !== null) {
console.log('[Fragment] setArguments on: ' + this.getClass().getName());
// Iterate bundle keys
var keySet = bundle.keySet();
var iterator = keySet.iterator();
while (iterator.hasNext()) {
var key = iterator.next().toString();
console.log(' key=' + key + ' value=' + bundle.get(key));
}
}
this.setArguments(bundle);
};
});
Real-world scenario: A banking app used a single
DashboardActivitywith fragments. Activity tracing showed nothing interesting — but Fragment tracing revealedAdminSettingsFragmentbeing instantiated with a Bundle containingadmin_mode=false. I modified the hook to change that totrueand watched the Fragment render hidden admin UI controls.
Module 3 — Tracing with Frida
frida-trace
frida-trace is Frida's built-in method tracer. It auto-generates JavaScript hooks for every method matching a pattern and prints call logs — no script writing required for initial recon.
Trace all methods in a Java class:
# -U = USB device, -f = spawn app, -j = Java method pattern
frida-trace -U -f com.vulnapp.android -j 'com.vulnapp.auth.TokenManager!*'
This generates handler files in __handlers__/ which you can edit. The output looks like:
3842 ms TokenManager.generateToken("user_id_999", "STANDARD")
3843 ms <= "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoidXNlciJ9.xxx"
Trace by method name pattern across all classes:
# Trace any method named 'decrypt' in any class
frida-trace -U -f com.vulnapp.android -j '*!decrypt*'
# Trace any method named 'verify' — useful for auth bypass hunting
frida-trace -U -f com.vulnapp.android -j '*!verify*'
frida-trace -U -f com.vulnapp.android -j '*!isValid*'
frida-trace -U -f com.vulnapp.android -j '*!checkPin*'
Customising the auto-generated handler:
After running frida-trace, edit __handlers__/com.vulnapp.auth.TokenManager/generateToken.js:
{
onEnter: function (log, args, state) {
log('generateToken called');
log(' userId: ' + args[0]);
log(' role: ' + args[1]);
},
onLeave: function (log, retval, state) {
log(' => token: ' + retval);
}
}
Tip: Start every engagement with a broad
frida-tracesweep. It maps the attack surface faster than reading code.
Tracing into JNI (Native Libraries)
When an app moves sensitive logic into a .so native library (common for licence checks, crypto, and anti-tampering), Java-level hooking doesn't reach it. You hook at the native layer using Frida's Interceptor.
Step 1: Identify JNI methods in jadx
public native String nativeDecrypt(String ciphertext, String key);
public native boolean nativeCheckLicence();
Step 2: Find the symbol in the .so
# Extract the so from the APK
unzip target.apk lib/arm64-v8a/libnative.so -d extracted/
# List exported symbols
nm -D extracted/lib/arm64-v8a/libnative.so | grep -i decrypt
# or
readelf -W --syms extracted/lib/arm64-v8a/libnative.so | grep Java_
Step 3: Hook the native function with Frida Interceptor
// Hook a JNI native function
var moduleName = 'libnative.so';
var funcName = 'Java_com_vulnapp_crypto_CryptoHelper_nativeDecrypt';
var nativeDecrypt = Module.findExportByName(moduleName, funcName);
if (nativeDecrypt) {
Interceptor.attach(nativeDecrypt, {
onEnter: function (args) {
// JNI args: args[0] = JNIEnv*, args[1] = jobject (this)
// args[2] onwards = actual method parameters
var env = Java.vm.getEnv();
var ciphertext = env.getStringUtfChars(args[2], null).readUtf8String();
var key = env.getStringUtfChars(args[3], null).readUtf8String();
console.log('[JNI] nativeDecrypt called');
console.log(' ciphertext: ' + ciphertext);
console.log(' key: ' + key);
},
onLeave: function (retval) {
// retval is a jstring — read it
var env = Java.vm.getEnv();
var result = env.getStringUtfChars(retval, null).readUtf8String();
console.log(' decrypted: ' + result);
}
});
} else {
console.log('[-] Symbol not found — may be stripped');
}
When symbols are stripped (common in production apps):
// Find the function by scanning module memory
var base = Module.findBaseAddress('libnative.so');
// Use the offset from Ghidra analysis
var targetFunc = base.add(0x4A28); // offset from Ghidra
Interceptor.attach(targetFunc, {
onEnter: function (args) {
console.log('[*] Hit stripped function at offset 0x4A28');
// Log raw pointer args
console.log(' arg0: ' + args[0]);
console.log(' arg1: ' + args[1]);
}
});
Real-world scenario: A gaming app had its licence check in
libprotect.so. The Java methodisLicenceValid()was declarednative.nmshowed the symbolJava_com_gameapp_protect_LicenceCheck_isLicenceValid. I hookedonLeaveand forcedretval.replace(1)(returningtrue= 1). The paywall disappeared. This confirmed a P1 licence bypass and I could then analyse the actual native function in Ghidra to understand how the check worked for the full write-up.
Module 4 — Intercepting Arguments and Return Values
Frida Interception Basics
Interception is the core of what makes Frida powerful in offensive security. The pattern is always the same: replace a method's implementation, optionally call the original, and manipulate inputs or outputs.
Basic interception skeleton:
Java.perform(function () {
var TargetClass = Java.use('com.vulnapp.auth.AuthManager');
TargetClass.isAdmin.implementation = function () {
console.log('[*] isAdmin() called — forcing true');
return true; // Override return value
};
});
Intercepting and logging arguments:
Java.perform(function () {
var PinVerifier = Java.use('com.vulnapp.auth.PinVerifier');
PinVerifier.verifyPin.implementation = function (userPin, storedHash) {
console.log('[*] verifyPin called');
console.log(' userPin: ' + userPin);
console.log(' storedHash: ' + storedHash);
// Call the original and log the result
var result = this.verifyPin(userPin, storedHash);
console.log(' result: ' + result);
return result;
};
});
Modifying arguments before the call:
Java.perform(function () {
var PaymentService = Java.use('com.vulnapp.payment.PaymentService');
PaymentService.processPayment.implementation = function (amount, currency, userId) {
console.log('[*] processPayment intercepted');
console.log(' original amount: ' + amount);
// Tamper: change the amount to 0.01
var tamperedAmount = Java.use('java.math.BigDecimal').$new('0.01');
console.log(' tampered amount: ' + tamperedAmount);
return this.processPayment(tamperedAmount, currency, userId);
};
});
Intercepting methods that throw exceptions (for auth bypass):
Java.perform(function () {
var CertPinner = Java.use('com.vulnapp.network.CertificatePinner');
// Some SSL pinning implementations throw on failure instead of returning false
CertPinner.check.overload('java.lang.String', 'java.util.List').implementation = function (hostname, peerCertificates) {
console.log('[*] CertPinner.check called for: ' + hostname);
// Do nothing — swallow the check, don't call original
// This prevents the exception from being thrown
return;
};
});
Full argument dump helper — useful for unknown methods:
Java.perform(function () {
var Target = Java.use('com.vulnapp.api.ApiClient');
Target.sendRequest.implementation = function () {
console.log('[*] sendRequest called with ' + arguments.length + ' args:');
for (var i = 0; i < arguments.length; i++) {
console.log(' arg[' + i + '] = ' + arguments[i]);
}
return this.sendRequest.apply(this, arguments);
};
});
Real-world scenario: On
com.vulnbank.android(an intentionally vulnerable banking app), I foundcom.vulnerablebankapp.auth.JWTManager.verifyToken(String token). Hooking this and logging the arguments revealed the app was passing the raw JWT to a local verification method before sending it to the server. I then overrode the implementation to accept any JWT — replacing the return withtrueregardless of signature. Combined with a forged JWT withrole: admin, I accessed admin endpoints directly. The server trusted the client's verification result. P1 authentication bypass.
Module 5 — SSL Validation Bypasses
SSL pinning is the most common defensive control you'll face in mobile bug bounty. The app pins the server's certificate or public key and refuses connections if the presented cert doesn't match — this blocks your Burp proxy. Your job is to destroy this check at runtime.
SSLContext and Network-Security-Config Bypass
Bypass approach — replace TrustManager with one that trusts everything:
Java.perform(function () {
// Bypass TrustManagerImpl — the most common SSLContext pinning path
var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl');
TrustManagerImpl.verifyChain.implementation = function (untrustedChain, trustAnchorChain, host, clientAuth, ocspData, tlsSctData) {
console.log('[*] TrustManagerImpl.verifyChain bypassed for: ' + host);
return untrustedChain; // Accept whatever chain is presented
};
// Find all implementations of X509TrustManager and nuke checkServerTrusted
Java.enumerateLoadedClasses({
onMatch: function (className) {
try {
if (className.indexOf('TrustManager') !== -1 ||
className.indexOf('trustmanager') !== -1) {
var clazz = Java.use(className);
// Try to override checkServerTrusted
if (clazz.checkServerTrusted) {
clazz.checkServerTrusted.overload(
'[Ljava.security.cert.X509Certificate;',
'java.lang.String'
).implementation = function (chain, authType) {
console.log('[*] checkServerTrusted bypassed on: ' + className);
};
}
}
} catch(e) {}
},
onComplete: function () {}
});
});
OkHttp3 Bypass
Java.perform(function () {
// Method 1: Hook the check method directly
var CertificatePinner = Java.use('okhttp3.CertificatePinner');
CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function (hostname, peerCertificates) {
console.log('[*] OkHttp3 CertificatePinner.check bypassed for: ' + hostname);
return;
};
// Method 3: Hook the OkHttpClient.Builder to replace the CertificatePinner with an empty one
var Builder = Java.use('okhttp3.OkHttpClient$Builder');
var EmptyPinner = CertificatePinner.DEFAULT.value;
Builder.certificatePinner.implementation = function (pinner) {
console.log('[*] OkHttpClient.Builder.certificatePinner → injecting empty pinner');
return this.certificatePinner(EmptyPinner);
};
});
Real-world scenario: A crypto exchange app (
com.cryptotrader.android) used OkHttp3 with a customCertificatePinnerbuilt with pinned SHA-256 hashes. Running the OkHttp3 bypass unblocked traffic in Burp. I then noticed the/api/v1/withdrawendpoint accepted adestination_walletparameter that wasn't validated server-side. Via Burp, I changed the wallet address. P1 improper input validation on a withdrawal endpoint. The OkHttp bypass was the gate — without it, I'd never have seen the request.
Bypassing SSL Pinning with Objection
Objection is your speed tool. It wraps the common bypass scripts so you don't have to write them each time.
# Attach to a running app
objection -g com.vulnapp.android explore
# In the objection REPL:
android sslpinning disable
android root disable
Full engagement one-liner:
objection -g com.vulnapp.android explore --startup-command 'android sslpinning disable'
How It All Comes Together — Full Engagement Walkthrough
Here's how these modules work as a unified workflow in a real bug bounty scenario.
Target: com.financeapp.android — a private bug bounty fintech application.
- Step 1 — Static recon (jadx): Identify class structure, find
JWTService,ApiClient(OkHttp3),AESHelper(native). - Step 2 — Setup: Push frida-server, verify with
frida-ps -U. - Step 3 — Kill SSL pinning (Module 5): Run OkHttp3 bypass. Confirm traffic visible in Burp.
- Step 4 — Activity/Fragment tracing (Module 2): Discover
AdminPanelFragmentin trace output — it's never accessible via normal UI flow but the code exists. - Step 5 — frida-trace sweep (Module 3): Run
frida-trace -j '*!verify*'and'*!checkRole*'. FindJWTService.checkRolefiring on every API call. - Step 6 — Intercept and override (Module 4): Hook
checkRoleto always returntrue. Launch the hidden Admin fragment via ADB Intent. Admin UI renders. - Step 7 — Decrypt native secrets (Module 3): Hook
nativeDecryptto log the AES key used for local storage encryption. Recover encrypted user data from SQLite and decrypt it. - Step 8 — Document: Every hook is a PoC. Every
console.logoutput is evidence. Write up with CVSS scores.
This is not a hypothetical flow — it is the actual rhythm of a serious Android engagement. Frida is the thread that weaves every other tool together.