Dynamic Instrumentation — Android Penetration Testing

Category: Guides Difficulty: Advanced Date: 2026-05-02

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:

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:

  1. Version mismatch between host and server
  2. SELinux blocking the server — try adb shell setenforce 0
  3. 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 called com.bankingapp.crypto.AESHelper in jadx. It had a decrypt(String ciphertext, String key) static method. I could see encrypted values being stored in SharedPreferences. Using Java.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 noticed com.retailapp.checkout.PaymentActivity was being launched with an Intent extra called promo_code. By tracing getIntent().getExtras() in onCreate, 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 DashboardActivity with fragments. Activity tracing showed nothing interesting — but Fragment tracing revealed AdminSettingsFragment being instantiated with a Bundle containing admin_mode=false. I modified the hook to change that to true and 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-trace sweep. 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 method isLicenceValid() was declared native. nm showed the symbol Java_com_gameapp_protect_LicenceCheck_isLicenceValid. I hooked onLeave and forced retval.replace(1) (returning true = 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 found com.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 with true regardless of signature. Combined with a forged JWT with role: 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 custom CertificatePinner built with pinned SHA-256 hashes. Running the OkHttp3 bypass unblocked traffic in Burp. I then noticed the /api/v1/withdraw endpoint accepted a destination_wallet parameter 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.

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.