Static analysis tells you what the code says. Dynamic analysis tells you what the code does. Frida is the bridge: it lets you hook Java and native methods at runtime, inspect arguments, modify return values, and bypass security controls while the app is running. This tutorial builds your Frida fundamentals from first hook to SSL pinning bypass.
How Frida Works
Frida is a client-server instrumentation framework. On Android, frida-server runs on the target device as a daemon. When you spawn or attach to an app, Frida injects its instrumentation agent into the target process and runs your JavaScript there. You control the whole flow from your host machine over ADB:
Host Machine Android Device
┌─────────────────┐ ┌─────────────────────────┐
│ │ │ │
│ frida CLI │ │ frida-server daemon │
│ frida-trace │── ADB/USB ──→│ on device │
│ objection │←─────────────│ │
│ Python scripts │ │ Target App Process │
│ │──────────────→│ ┌───────────────────┐ │
│ Your JS hooks │ │ │ injected agent │ │
│ │ │ │ Hooks Java/Native │ │
│ │ │ └───────────────────┘ │
└─────────────────┘ └─────────────────────────┘Frida supports two injection modes:
- Spawn (
-f): Frida starts the app and injects before any code runs. Use this when you need to hook early initialization. - Attach (
-nor-p): Frida injects into an already-running process. Use this when the app is already in the state you want to test.
Setting Up Frida Server
If you haven’t already set this up from Tutorial 0:
# Check your Frida version
frida --version
# 16.x.x
# Determine the device architecture first
adb shell getprop ro.product.cpu.abilist
# Example output:
# x86_64,arm64-v8a
# Download matching frida-server for your device architecture
FRIDA_VERSION=$(frida --version)
ARCH="x86_64" # Typical emulator value. Common physical-device value: arm64
wget "https://github.com/frida/frida/releases/download/${FRIDA_VERSION}/frida-server-${FRIDA_VERSION}-android-${ARCH}.xz"
xz -d frida-server-*.xz
# Push to device
# Emulator / userdebug builds:
adb root
adb push frida-server-* /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
adb shell "/data/local/tmp/frida-server >/dev/null 2>&1 &"
# Production devices usually reject `adb root`.
# On a rooted physical device, use `su -c` instead:
adb push frida-server-* /data/local/tmp/frida-server
adb shell "su -c chmod 755 /data/local/tmp/frida-server"
adb shell "su -c '/data/local/tmp/frida-server >/dev/null 2>&1 &'"Verify with:
frida-ps -UThis lists all processes on the USB-connected device. If you see a process list, Frida is ready.
Your First Hook: Java Methods
Every Frida script for Android starts with Java.perform(), which ensures the script runs on the Java VM thread:
// hook_equals.js
Java.perform(function() {
var StringClass = Java.use("java.lang.String");
var equals = StringClass.equals.overload("java.lang.Object");
equals.implementation = function(arg) {
if (arg && arg.toString().length < 50) {
console.log("[String.equals] " + this.toString() + " == " + arg);
}
return equals.call(this, arg);
};
});Run it:
# Spawn mode — start the app with hooks active
frida -U -f com.target.app -l hook_equals.js
# Attach mode — hook an already-running app
frida -U -n "Target App" -l hook_equals.jsThe -l flag loads your JavaScript file. Once attached, you’ll see a REPL where you can type additional Frida commands interactively. In spawn mode, Frida may pause the app before resuming execution; if the UI does not appear yet, type %resume.
Note
Java.use vs Java.choose
Java.use()gets a reference to a class so you can replace method implementations.Java.choose()finds existing instances of a class on the heap, useful when you need to call methods on objects that already exist in memory.
Hooking Overloaded Methods
Java methods can be overloaded: same name, different parameter types. Frida requires you to specify which overload you’re targeting:
Java.perform(function() {
var activity = Java.use("com.target.app.LoginActivity");
var authenticate = activity.authenticate.overload(
"java.lang.String",
"java.lang.String"
);
// Hook the specific overload that takes (String, String)
authenticate.implementation = function(username, password) {
console.log("[Login] User: " + username + " Pass: " + password);
return authenticate.call(this, username, password);
};
});If you’re not sure which overloads exist, list them:
Java.perform(function() {
var cls = Java.use("com.target.app.LoginActivity");
console.log(cls.authenticate.overloads.length + " overloads");
cls.authenticate.overloads.forEach(function(overload) {
console.log(" " + overload.returnType.name + " (" +
overload.argumentTypes.map(function(t) { return t.name; }).join(", ") + ")");
});
});Intercepting Encrypted Data
Apps encrypt data before sending it to the server. By hooking crypto APIs, you can see plaintext before encryption and after decryption:
// hook_crypto.js
Java.perform(function() {
var Cipher = Java.use("javax.crypto.Cipher");
var doFinalBytes = Cipher.doFinal.overload("[B");
doFinalBytes.implementation = function(input) {
var algorithm = this.getAlgorithm();
console.log("[Cipher.doFinal] Input (" + input.length + " bytes): " +
bytesToHex(input));
var result = doFinalBytes.call(this, input);
console.log("[Cipher.doFinal] Algorithm: " + algorithm);
console.log("[Cipher.doFinal] Output (" + result.length + " bytes): " +
bytesToHex(result));
return result;
};
// Helper: byte array to hex string
function bytesToHex(bytes) {
var hex = [];
for (var i = 0; i < Math.min(bytes.length, 64); i++) {
hex.push(('0' + (bytes[i] & 0xFF).toString(16)).slice(-2));
}
return hex.join(' ') + (bytes.length > 64 ? '...' : '');
}
});Similarly, hook MessageDigest.digest to see what’s being hashed:
Java.perform(function() {
var MessageDigest = Java.use("java.security.MessageDigest");
var digestBytes = MessageDigest.digest.overload("[B");
digestBytes.implementation = function(input) {
console.log("[Hash] Algorithm: " + this.getAlgorithm());
console.log("[Hash] Input: " + bytesToHex(input));
return digestBytes.call(this, input);
};
function bytesToHex(bytes) {
var hex = [];
for (var i = 0; i < Math.min(bytes.length, 64); i++) {
hex.push(('0' + (bytes[i] & 0xFF).toString(16)).slice(-2));
}
return hex.join(' ') + (bytes.length > 64 ? '...' : '');
}
});Bypassing SSL/TLS Certificate Pinning
On modern Android, most apps do not trust user-installed CA certificates by default, and the platform trust store is no longer trivially modifiable. Even before you encounter app-specific certificate pinning, you need a runtime bypass to intercept HTTPS traffic from third-party apps. The exact methods involved vary by Android version and HTTP stack, so brittle one-overload scripts often fail on newer releases. A better approach is to hook the current overloads that return trusted certificate chains and to skip pin checks where the framework exposes them.
Here’s a script that covers the common libraries:
// ssl_bypass.js
Java.perform(function() {
function makeTrustedList(chain) {
var ArrayList = Java.use("java.util.ArrayList");
var list = ArrayList.$new();
for (var i = 0; i < chain.length; i++) {
list.add(chain[i]);
}
return list;
}
// OkHttp CertificatePinner
try {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overloads.forEach(function(overload) {
overload.implementation = function() {
var hostname = arguments.length > 0 ? arguments[0] : "<unknown>";
console.log("[SSL] Bypassing OkHttp pin for: " + hostname);
return;
};
});
} catch(e) {}
// TrustManagerImpl (Android default — Conscrypt)
try {
var TrustManagerImpl = Java.use("com.android.org.conscrypt.TrustManagerImpl");
TrustManagerImpl.checkServerTrusted.overloads.forEach(function(overload) {
overload.implementation = function() {
var chain = arguments[0];
var host = arguments.length >= 3 && typeof arguments[arguments.length - 1] === "string"
? arguments[arguments.length - 1]
: "<unknown>";
console.log("[SSL] Bypassing TrustManagerImpl for: " + host);
if (overload.returnType && overload.returnType.name === "java.util.List") {
return makeTrustedList(chain);
}
return;
};
});
} catch(e) {}
// Network Security Config pinning
try {
var NetworkSecurityTrustManager = Java.use(
"android.security.net.config.NetworkSecurityTrustManager");
NetworkSecurityTrustManager.checkServerTrusted.overloads.forEach(function(overload) {
overload.implementation = function() {
var chain = arguments[0];
var host = arguments.length >= 3 && typeof arguments[arguments.length - 1] === "string"
? arguments[arguments.length - 1]
: "<unknown>";
console.log("[SSL] Bypassing NetworkSecurityTrustManager for: " + host);
if (overload.returnType && overload.returnType.name === "java.util.List") {
return makeTrustedList(chain);
}
return;
};
});
if (NetworkSecurityTrustManager.checkPins) {
NetworkSecurityTrustManager.checkPins.overload("java.util.List")
.implementation = function(chain) {
console.log("[SSL] Skipping Network Security Config pin check");
return;
};
}
} catch(e) {}
});For a quicker approach, use objection:
objection -g com.target.app explore --startup-command "android sslpinning disable"After running either method, HTTPS traffic from the app should appear in Burp Suite, assuming the app is not using a custom native trust path or an additional out-of-band transport.
Warning
Pinning Bypass Limitations
Some apps use custom pinning implementations, certificate transparency checks, or native-level pinning. The script above is intentionally more version-tolerant than a single-overload hook, but you may still need app-specific hooks for hardened targets.
Hooking Retrofit / OkHttp interceptors directly
When the bypass above sees no traffic in Burp at all, the app is usually running a custom okhttp3.Interceptor (or the Retrofit-bundled equivalent) that performs its own pinning, request signing, or anti-tamper check before the request even reaches CertificatePinner. Hook the interceptor chain to short-circuit those checks and to dump request/response bodies in cleartext, which is often more useful than fighting the pin:
// retrofit_okhttp_interceptor.js
Java.perform(function() {
var Interceptor = Java.use("okhttp3.Interceptor");
var Buffer = Java.use("okio.Buffer");
// Replace any class implementing Interceptor whose name contains "Pin" or "Cert"
// with a passthrough that logs the request URL and forwards the chain.
Java.enumerateLoadedClasses({
onMatch: function(name) {
if (!/Pin|Cert|Tamper|Integrity/.test(name)) return;
try {
var cls = Java.use(name);
if (!cls.intercept) return;
cls.intercept.overloads.forEach(function(overload) {
overload.implementation = function(chain) {
var req = chain.request();
console.log("[OkHttp] " + req.method() + " " + req.url());
return chain.proceed(req);
};
});
console.log("[+] Neutered interceptor: " + name);
} catch (e) {}
},
onComplete: function() {}
});
// Body dump: hook RealCall.execute to log the response after the chain completes.
try {
var RealCall = Java.use("okhttp3.internal.connection.RealCall");
RealCall.execute.implementation = function() {
var resp = this.execute();
try {
var body = resp.peekBody(1024 * 1024);
console.log("[OkHttp] <- " + resp.code() + " " + body.string());
} catch (e) {}
return resp;
};
} catch (e) {}
});This pattern is more robust than chasing platform trust stores because it operates one layer up, in the app’s own HTTP plumbing, where pin checks, mTLS client certificates, request signatures, and anti-tamper headers all converge. If the app is built with R8 minification enabled, search the deobfuscated class names from the static-analysis tutorial for intercept(Lokhttp3/Interceptor$Chain;) and hook those directly.
Tracing Method Calls
frida-trace auto-generates hook handlers for matching methods:
# Trace all methods in the app's API package
frida-trace -U -f com.target.app -j 'com.target.app.api.*!*'This creates handler files in __handlers__/ that you can customize. Each file contains onEnter and onLeave stubs:
// Auto-generated handler
{
onEnter(log, args, state) {
log(`com.target.app.api.AuthService.login(${args[0]}, ${args[1]})`);
},
onLeave(log, retval, state) {
log(` => ${retval}`);
}
}Trace patterns support wildcards:
# Trace all methods in all classes containing "Auth"
frida-trace -U -f com.target.app -j '*Auth*!*'
# Trace a specific native function
frida-trace -U -f com.target.app -i 'strcmp'Hooking Native Functions
For native libraries (.so files), use Interceptor.attach with the function address:
// Hook a native function by export name
var strcmp = Module.findExportByName("libc.so", "strcmp");
Interceptor.attach(strcmp, {
onEnter: function(args) {
var s1 = Memory.readUtf8String(args[0]);
var s2 = Memory.readUtf8String(args[1]);
if (s1 && s1.indexOf("com.target") !== -1) {
console.log("[strcmp] " + s1 + " vs " + s2);
}
}
});
// Hook a function in a specific library
var decrypt = Module.findExportByName("libnative.so", "decrypt_token");
if (decrypt) {
Interceptor.attach(decrypt, {
onEnter: function(args) {
console.log("[decrypt] Input: " + Memory.readUtf8String(args[0]));
},
onLeave: function(retval) {
console.log("[decrypt] Output: " + Memory.readUtf8String(retval));
}
});
}We’ll go much deeper into native code analysis in Tutorial 5.
Modifying Return Values
Changing return values lets you bypass client-side checks:
Java.perform(function() {
// Bypass root detection
var RootCheck = Java.use("com.target.app.security.RootDetector");
RootCheck.isDeviceRooted.implementation = function() {
console.log("[Bypass] Root detection → false");
return false;
};
// Bypass debugger detection
var DebugCheck = Java.use("android.os.Debug");
DebugCheck.isDebuggerConnected.implementation = function() {
console.log("[Bypass] Debugger detection → false");
return false;
};
// Bypass emulator detection
var EmulatorCheck = Java.use("com.target.app.security.EmulatorDetector");
EmulatorCheck.isEmulator.implementation = function() {
console.log("[Bypass] Emulator detection → false");
return false;
};
});Note
Finding Check Methods
Use jadx (Tutorial 1) to find the class and method names for security checks. Search for strings like “rooted”, “debugger”, “emulator”, “su”, or “Superuser” in the decompiled source.
Monitoring SharedPreferences
SharedPreferences is Android’s key-value storage: apps store tokens, settings, and sometimes secrets here:
Java.perform(function() {
var SharedPreferences = Java.use("android.app.SharedPreferencesImpl");
var getString = SharedPreferences.getString.overload(
"java.lang.String",
"java.lang.String"
);
getString.implementation = function(key, defValue) {
var value = getString.call(this, key, defValue);
console.log("[SharedPrefs.get] " + key + " = " + value);
return value;
};
var Editor = Java.use("android.app.SharedPreferencesImpl$EditorImpl");
var putString = Editor.putString.overload(
"java.lang.String",
"java.lang.String"
);
putString.implementation = function(key, value) {
console.log("[SharedPrefs.put] " + key + " = " + value);
return putString.call(this, key, value);
};
});Exercises
-
Capture credentials. Hook the login method of InsecureBankv2 (or DIVA) and capture the username and password as they’re submitted. Log them to the Frida console.
-
Bypass root detection. Find and bypass the root detection in a sample app. First use jadx to identify the root-checking method, then write a Frida script that forces it to return false.
-
Map API calls on launch. Use
frida-traceto trace all network-related method calls when a target app starts. Identify the API endpoints it contacts and what data it sends. -
Monitor local storage. Write a Frida script that hooks
SharedPreferences.getStringandSharedPreferences.getIntto monitor everything the app reads from local storage during a login flow.
What’s next
In the next tutorial, Attack Surface Mapping, we’ll systematically enumerate an app’s exposed components (intents, content providers, broadcast receivers, and deep links) to find entry points that don’t require any authentication.