Frida⚓
Frida is a dynamic instrumentation framework. It injects JavaScript into a running process so you can hook functions, read memory, modify return values, and observe app behaviour at runtime — all without recompiling or patching the APK.
The Frida Toolchain⚓
| Tool | What it does |
|---|---|
frida |
Attach to or spawn an app, load a JS script |
frida-ps |
List processes on device |
frida-ls-devices |
List connected devices/emulators Frida can see |
frida-trace |
Auto-hook methods and log calls without writing a script |
frida-kill |
Kill a process on the device by PID or name |
objection |
CLI toolkit built on Frida — class listing, method watching, common bypasses |
frida-gadget |
Library injected into an APK — runs Frida without root |
Install everything on your host:
Installation & Root Requirements⚓
frida-server (Rooted Device or Emulator)⚓
The standard approach. A server binary runs as root on the device and exposes Frida's API over USB.
- Download the correct
frida-serverbinary from github.com/frida/frida/releases - Match the version exactly to your installed tools:
frida --version -
Pick the right arch:
arm64for modern physicals,x86_64for most emulators -
Push and start it:
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell "/data/local/tmp/frida-server &"
# Verify — should list device processes
frida-ps -U
| Environment | Root needed? | Notes |
|---|---|---|
| Physical device (Magisk root) | Yes | frida-server must run as root |
| Emulator — Google APIs AVD | Yes (adb root works) |
No Magisk needed |
| Emulator — Google Play AVD | Yes | adb root blocked; swap to Google APIs AVD or use gadget |
frida-gadget (No Root)⚓
For physical devices you can't root, or Google Play AVDs. You repack the APK, embedding libfrida-gadget.so which acts as the Frida endpoint.
# Easiest path — objection autopatcher
objection patchapk -s target.apk
# Install the patched APK
adb install target-patched.apk
The app pauses at launch waiting for a connection. Attach with frida -U -n Gadget -l script.js or any script.
Note
The patched APK is signed with a new key, so apps using Play Integrity / SafetyNet will detect tampering. Use a rooted device for those.
objection⚓
objection is a command-line shell built on top of Frida. It uses frida-server or gadget as its backend and gives you live exploration and bypass commands without writing any JavaScript. Install it with pip install objection.
Connecting⚓
# Attach to a running app (app must already be open)
objection -g com.example.app explore
# Execute a bypass the instant the app starts — catches pinning/root checks
# that run before you could type anything interactively
objection -g com.example.app explore --startup-command "android sslpinning disable"
Exploring Classes and Methods⚓
# List every class currently loaded in the app's JVM
android hooking list classes
# Search for classes by keyword (much faster than reading the full list)
android hooking search classes pinning
android hooking search classes root
android hooking search classes auth
# List every method on a specific class (copy the full name from above or from jadx)
android hooking list class_methods com.example.app.PinningManager
# Watch a method live — prints args and return value every time it fires
android hooking watch class_method com.example.app.PinningManager.validate --dump-args --dump-return
# Include a stack backtrace to see what code triggered the call
android hooking watch class_method com.example.app.PinningManager.validate --dump-args --dump-return --dump-backtrace
After running watch class_method, trigger the relevant action in the app — tap login, open a screen, make a purchase. Every call prints live in your terminal including the arguments passed in and what the method returned. No script needed.
Instant Bypasses⚓
android sslpinning disable # hooks all common SSL pinning implementations
android root disable # hooks common root detection checks
Data Access⚓
# Print all SharedPreferences files and their key-value contents
android shared_preferences print
# Pull the app's SQLite database to your machine
file download /data/data/com.example.app/databases/main.db ./main.db
# Print data dir, cache dir, external storage paths
env
Launching Activities and Services⚓
# Launch a hidden or unexported activity directly — bypasses any UI navigation guards
android intent launch_activity com.example.app.admin.AdminActivity
android intent launch_service com.example.app.SyncService
Memory⚓
memory list modules # all loaded .so libraries in the process
memory list exports libssl.so # exported function names from a .so
memory dump all out.bin # dump full process memory to a file
frida-trace⚓
frida-trace uses frida-server or gadget as its backend and automatically generates a JavaScript hook for every method matching your pattern. You don't write the scripts — frida-trace writes them, runs them, and logs every call. Useful for mapping call flow across a whole class or library before you write targeted hooks.
Usage⚓
# Trace all methods on a class — spawns the app fresh
frida-trace -U -f com.example.app -j 'com.example.app.AuthManager!*'
# Attach to an already-running app (don't restart it)
frida-trace -U -n "App Name" -j 'com.example.app.AuthManager!*'
# Wildcard across an entire package
frida-trace -U -f com.example.app -j 'com.example.app.*!*'
# Trace all okhttp3 calls (shows pinning and HTTP call entry points)
frida-trace -U -f com.example.app -j 'okhttp3.*!*'
# Trace native C functions by name pattern (for JNI / low-level TLS)
frida-trace -U -f com.example.app -i "SSL_*"
frida-trace -U -f com.example.app -i "Java_*"
-j = Java (JVM) methods. -i = native C functions in .so libraries. -f = spawn fresh. -n = attach to running.
Editing Handlers Live⚓
When frida-trace first runs it creates ./__handlers__/ClassName/method.js — one small JS file per matched method. Each file has onEnter and onLeave callbacks:
// __handlers__/com.example.app.AuthManager/validateToken.js
{
onEnter: function (log, args, state) {
log("validateToken(" + args[0] + ")");
},
onLeave: function (log, retval, state) {
log("-> " + retval);
retval.replace(1); // override the return value — 1 = true in Java
}
}
Save the file and the app picks up the change instantly — no restart, no re-attach. This is the fastest way to go from "observe" to "modify" without writing a full Frida script.
Discovering What to Hook⚓
Before writing any script, figure out what classes and methods are worth targeting.
Backend must be running first
frida-ps, objection, frida-trace, and frida are all host-side clients. None of them work unless frida-server or frida-gadget is running on the device — see Installation & Root Requirements.
1. Confirm the Connection⚓
frida-ls-devices # list all devices Frida can see
frida-ps -U # list all processes on the USB device
frida-ps -Ua # apps only, with package identifiers
frida-ps -Uai # all installed apps including non-running ones
If frida-ps -U hangs or errors: frida-server isn't running, or its version doesn't match your host tools (frida --version).
2. Explore with objection⚓
Use objection to browse classes and watch methods live — no script needed. See objection above for the full command reference.
Typical workflow:
android hooking search classes auth— find auth-related classes by keywordandroid hooking list class_methods com.example.app.AuthManager— see every method on itandroid hooking watch class_method com.example.app.AuthManager.login --dump-args --dump-return— watch it fire live
3. Auto-trace with frida-trace⚓
Use frida-trace when you want to watch call flow across a whole class or package at once. See frida-trace above.
Typical workflow:
frida-trace -U -f com.example.app -j 'com.example.app.AuthManager!*'- Interact with the app — log in, navigate, trigger the target action
- Watch the terminal to see which methods fire and in what order
- Edit the generated handler file to log args or change return values
4. Static Analysis with jadx⚓
The live tools only see currently loaded classes. If a method hasn't been called yet, it won't appear. Use jadx to read the full decompiled source before running the app.
Step 1 — Get the APK off the device:
# Find the package name
adb shell pm list packages | findstr <appname> # Windows
adb shell pm list packages | grep <appname> # macOS/Linux
# Find where the APK is stored on the device
adb shell pm path com.example.app
# Output: package:/data/app/com.example.app-xyz/base.apk
# Pull it to your machine
adb pull /data/app/com.example.app-xyz/base.apk app.apk
Then open app.apk in jadx-gui: File → Open File.
Step 2 — Search for what matters (Ctrl+Shift+F in jadx):
| What you want to find | Search term |
|---|---|
| Root / tamper detection | isRooted, isEmulator, checkIntegrity |
| SSL certificate pinning | CertificatePinner, checkServerTrusted, TrustManager |
| License / paywall gates | isPremium, isSubscribed, checkLicense |
| Feature flags / config | getBoolean, getString, getInt |
| Hidden activities | AndroidManifest.xml → look for activity tags with no intent-filter |
Step 3 — Get the full class path: right-click the class name in jadx → Copy reference. This gives you the exact string for Java.use().
Working with Obfuscated Apps⚓
Real apps rarely expose nice class names like AuthManager or PinningManager. ProGuard / R8 often collapse classes to a, b, c, or short package trees like x.y.z.a. Frida still works fine against these apps, but you need to find anchors that survive obfuscation.
Recognising Obfuscation⚓
In jadx, obfuscated apps usually show one or more of these signs:
- Many one-letter class and method names
- Huge packages full of classes like
a,a0,b1,c2 - Meaningful library namespaces (
okhttp3,retrofit2,androidx) mixed with meaningless app namespaces - String constants and manifest component names that still make the app's purpose obvious
The important point: component names, string literals, URLs, SQL table names, JSON keys, and third-party library classes usually remain usable anchors even when app code names do not.
Start from Anchors That Survive⚓
Instead of searching for pretty method names, search for:
- URLs and endpoints:
api,auth,login,graphql,oauth - Error strings:
invalid token,device rooted,premium required - JSON field names:
access_token,role,isAdmin,tier - Android components from the manifest: exported Activities, Services, Receivers, Providers
- Third-party libraries:
okhttp3,retrofit2,com.scottyab.rootbeer,android.webkit.WebView
Typical workflow:
- Search for a string or endpoint in jadx
- Open the surrounding class and copy its full class reference
- Hook that class even if the method names are short and ugly
- Log arguments and return values until the behaviour becomes clear
Enumerate Loaded Classes at Runtime⚓
If jadx shows several plausible candidates, enumerate what is actually loaded at runtime:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("okhttp") !== -1 ||
name.indexOf("auth") !== -1 ||
name.indexOf("root") !== -1) {
console.log(name);
}
},
onComplete: function () {
console.log("[*] Done");
}
});
});
For heavily obfuscated apps, search for the app's top-level package instead and inspect the resulting short class names one by one.
Hook by Behaviour, Not by Name⚓
When the names are useless, hook the code path you already understand:
- A launcher or deep-link Activity's
onCreate() - An exported Service's
onStartCommand() - A BroadcastReceiver's
onReceive() - A known library entry point like
okhttp3.OkHttpClient,retrofit2.Retrofit,android.webkit.WebView
Example: log every method call on an obfuscated class after you identified it from jadx:
Java.perform(function () {
var Target = Java.use("x.y.z.a");
Target.class.getDeclaredMethods().forEach(function (method) {
var name = method.getName();
try {
Target[name].overloads.forEach(function (overload) {
overload.implementation = function () {
console.log("[*] a." + name + " called with " + arguments.length + " args");
return overload.apply(this, arguments);
};
});
} catch (e) {}
});
});
This is noisy, but it quickly tells you which short method name is the one gating login, premium state, or root detection.
Find Late-Loaded Classes⚓
Some classes are loaded only after a screen opens or a feature is used. If Java.use() fails even though jadx shows the class exists:
- Use spawn mode:
frida -U -f com.example.app -l script.js --no-pause - Trigger the relevant screen or action first, then enumerate classes again
- Hook a stable early method such as an Activity's
onCreate()and only callJava.use()from inside that hook
Java.perform(function () {
var Activity = Java.use("android.app.Activity");
Activity.onCreate.overload("android.os.Bundle").implementation = function (bundle) {
var name = this.getClass().getName();
console.log("[*] Opened activity: " + name);
return this.onCreate(bundle);
};
});
This gives you a live map of which app classes appear as you navigate.
Kotlin, Inner Classes, and Synthetic Names⚓
Obfuscation plus Kotlin often produces names like:
com.example.app.LoginActivity$onCreate$1x.y.z.a$bx.y.z.a$Companion
Rules of thumb:
$means inner class, lambda, anonymous class, or Kotlin companion object- Hook the outer class first, then inspect its inner classes if needed
- Synthetic helper classes often carry the actual callback logic for clicks, network responses, and coroutines
If the app uses coroutines or reactive streams, the interesting logic may sit in generated callback classes rather than the main Activity or ViewModel.
Script Skeleton⚓
Every Frida script targeting Java code wraps inside Java.perform. Here's the skeleton with every part explained:
Java.perform(function () {
// ① Java.use() gets a reference to the class.
// The string must be the fully-qualified class name (package + class).
// Find it in jadx: right-click the class → Copy reference
var TargetClass = Java.use("com.example.app.ClassName");
// ② .implementation replaces the method body entirely.
// Your function runs instead of the original whenever the app calls this method.
TargetClass.methodName.implementation = function (arg1, arg2) {
// ③ 'this' is the object instance the method was called on.
// You can read its fields: this.someField.value
console.log("[*] method called on: " + this.toString());
// ④ Log the arguments to understand what the app is passing in
console.log(" arg1 = " + arg1);
console.log(" arg2 = " + JSON.stringify(arg2));
// ⑤ Call the real method by calling it on 'this' with the same name.
// Skip this line entirely if you want to block the original from running.
var result = this.methodName(arg1, arg2);
console.log(" returned = " + result);
// ⑥ Return a value. Must match the Java return type.
// Return 'result' to pass through unchanged, or return anything else.
return result;
};
});
What each part does⚓
Java.perform(callback)
Waits until the JVM bridge is ready before running your code. Always required — without it, Java.use() will fail because the Java runtime isn't set up from JavaScript's perspective yet.
Java.use("full.class.Name")
Returns a JavaScript object that represents the Java class. Use it to hook instance methods, call static methods, and read/write static fields. The class must already be loaded in the JVM when you call this — if it's lazy-loaded later, use spawn mode (-f) so your script runs at startup, or hook a method that you know runs before the target class loads.
.implementation = function (...) {}
Replaces the original method body. Every call to that method anywhere in the app now runs your function instead. The real implementation is completely bypassed unless you explicitly invoke it.
Class vs Instance
In Java, a class is the blueprint — it defines what fields and methods exist. An instance is one specific live object created from that blueprint. A UserSession class defines what a session looks like; every logged-in user is a separate UserSession instance in memory with its own userId, token, isAdmin values.
Java.use() gives you the class. this inside .implementation gives you the specific instance that the method was called on — the actual object in memory whose method fired.
this inside .implementation
The live instance the method was called on. You can:
- Read its fields:
this.fieldName.value - Write its fields:
this.fieldName.value = newValue - Call other methods on it:
this.otherMethod()
Calling the original
Must usethis.methodName() — not TargetClass.methodName(), which would recursively call your hook.
Running your script⚓
# Attach to a running app (app is already open)
frida -U -n "App Name" -l script.js
# Spawn fresh — Frida starts the app, injects before any app code runs, then resumes
# Use this to catch code that runs at startup: root checks, pinning setup, license validation
frida -U -f com.example.app -l script.js --no-pause
-f (spawn) vs -n (attach): use -f when the thing you want to hook runs during app startup. Use -n when the target is triggered by a user action after the app is already open.
--no-pause: by default -f pauses the app and waits for you to type %resume. --no-pause skips that and starts immediately.
Interactive REPL⚓
Drops you into a live JavaScript console — type Java.perform(function() { ... }) directly. Useful for quick one-off exploration without a script file.
Handling Overloads⚓
Java allows multiple methods with the same name but different parameter types (method overloading). When you try to set .implementation = on an overloaded method, Frida throws:
Error: ambiguous method; use overload(), e.g.:
.check.overload('java.lang.String', 'java.util.List')
.check.overload('java.lang.String', '[Ljava.security.cert.Certificate;')
Pick One Overload⚓
Pass the Java type strings to .overload() to target one specific signature:
Java.perform(function () {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
// Hook only the overload that takes (String, List)
CertificatePinner.check.overload("java.lang.String", "java.util.List")
.implementation = function (hostname, certs) {
console.log("[*] Pinning bypassed for: " + hostname);
// returning nothing (void) — the pin check is silently skipped
};
});
Hook All Overloads at Once⚓
When there are many overloads and you want to bypass all of them:
Java.perform(function () {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overloads.forEach(function (overload) {
overload.implementation = function () {
console.log("[*] check() bypassed, args: " + arguments.length);
// original not called — pinning disabled for all overloads
};
});
});
Finding the Right Type Strings⚓
From jadx: look at the method signature in decompiled code and map each parameter type:
| Java type in jadx | String to pass .overload() |
|---|---|
String |
"java.lang.String" |
int |
"int" |
boolean |
"boolean" |
long |
"long" |
byte[] |
"[B" |
List / List<...> |
"java.util.List" |
Certificate[] |
"[Ljava.security.cert.Certificate;" |
Context |
"android.content.Context" |
Object |
"java.lang.Object" |
From objection: android hooking list class_methods okhttp3.CertificatePinner — shows all signatures, copy the type names directly.
From Frida's error message: when Frida throws the overload error it lists every valid .overload(...) call in the error output — just copy-paste from there.
Modifying Arguments and Return Values⚓
The examples below each start with what you actually see in jadx — because that's the real starting point. You find something interesting in the decompiled code, then decide how to hook it.
The step before any of these is always the same: hook it and log first, confirm it fires, understand the values. Then modify.
How to run these scripts
Every .js snippet below is a script file you pass to the frida command with the -l flag:
# Save the JS to a file, e.g. hook.js, then run:
# Attach to an already-running app
frida -U -n "App Name" -l hook.js
# Or spawn the app fresh (catches startup-time checks)
frida -U -f com.example.app -l hook.js --no-pause
-U = USB device. -n = attach to running process by name. -f = spawn fresh by package name. -l = load this script file.
You find the app name for -n from frida-ps -Ua, and the package name for -f from the same output or from jadx's AndroidManifest.xml.
Scenario 1 — Gate method that returns boolean⚓
What you see in jadx:
This is a boolean gate. Return true and whatever it's guarding becomes accessible.
The hook:
Java.perform(function () {
// Step 1: copy the full class path from jadx (right-click the class → Copy reference)
var BillingManager = Java.use("com.example.app.billing.BillingManager");
BillingManager.isPremium.implementation = function () {
// Step 2: don't call the original — just return what you want
return true;
};
});
Apply this same pattern to anything returning boolean that gates a feature:
isSubscribed, checkLicense, isRooted, isDebuggerConnected, isPinningEnabled, hasFeature("x") — same shape every time.
Scenario 2 — Method that returns an int status code⚓
What you see in jadx:
public int getLicenseStatus() {
// 0 = invalid, 1 = valid, 2 = trial
return this.licenseApi.check(this.userId);
}
Or sometimes it's a check against a constant:
The hook:
Java.perform(function () {
var LicenseService = Java.use("com.example.app.LicenseService");
LicenseService.getLicenseStatus.implementation = function () {
return 1; // return the "valid" value you read from jadx
};
});
Look in jadx for what value the if statement compares against — that's what you return.
Scenario 3 — Method that takes a token/credential as an argument⚓
What you see in jadx:
public void validateToken(String token) {
if (!this.api.verify(token)) {
throw new AuthException("Invalid token");
}
}
You want to see what token value the app is actually sending. Intercept it and log it first.
The hook:
Java.perform(function () {
var AuthService = Java.use("com.example.app.auth.AuthService");
AuthService.validateToken.implementation = function (token) {
// Log what the app is sending — useful for credential theft or replay
console.log("[*] Token in flight: " + token);
// Option A: let it proceed normally (you just wanted to read the value)
return this.validateToken(token);
// Option B: skip the server check entirely and never throw
// return; // for void methods — just return nothing and the exception never throws
};
});
Why this matters in pentesting: you can extract a valid token from a logged-in session, replay it manually in Burp, test if it works for other users (IDOR), or check if it expires.
Scenario 4 — Method that sends arguments to the server⚓
What you see in jadx:
public void transferFunds(String toAccount, double amount) {
this.api.post("/transfer", new TransferRequest(toAccount, amount));
}
You want to change what gets sent — different account, different amount.
The hook:
Java.perform(function () {
var PaymentManager = Java.use("com.example.app.PaymentManager");
PaymentManager.transferFunds.implementation = function (toAccount, amount) {
console.log("[*] Transfer intercepted: to=" + toAccount + " amount=" + amount);
// Call the real method but with your own values
return this.transferFunds(toAccount, 0.01); // change amount
};
});
Scenario 5 — Method returns an object with role/permission fields⚓
What you see in jadx:
public class UserSession {
public String role; // "user", "admin", "moderator"
public boolean isAdmin;
public String tier; // "free", "premium"
}
public UserSession parseLoginResponse(String json) {
return gson.fromJson(json, UserSession.class);
}
The session object is built from the server's JSON response. Hook the parser and modify the object before the app uses it.
The hook:
Java.perform(function () {
var SessionManager = Java.use("com.example.app.auth.SessionManager");
SessionManager.parseLoginResponse.implementation = function (json) {
var session = this.parseLoginResponse(json); // get the real parsed object
// Now modify the fields you found in jadx before the app sees it
session.role.value = "admin";
session.isAdmin.value = true;
session.tier.value = "premium";
console.log("[*] Session tampered");
return session;
};
});
The general process: find the class that holds the permission/role fields in jadx → find the method that constructs or returns that object → hook that method → mutate the fields on the returned instance.
Scenario 6 — Logging a value you want to steal or replay⚓
What you see in jadx:
public void sendRequest(String endpoint, Map<String, String> headers) {
headers.put("Authorization", "Bearer " + this.token);
this.httpClient.execute(endpoint, headers);
}
You want to extract that token without modifying anything.
The hook:
Java.perform(function () {
var ApiClient = Java.use("com.example.app.network.ApiClient");
ApiClient.sendRequest.implementation = function (endpoint, headers) {
console.log("[*] Request to: " + endpoint);
// Call original first so the headers are populated
var result = this.sendRequest(endpoint, headers);
// Then read the Authorization header back out
var auth = headers.get("Authorization");
if (auth) {
console.log("[*] Auth header: " + auth);
}
return result;
};
});
Picking the right pattern⚓
| What you see in jadx | What to do |
|---|---|
boolean method that gates a feature |
Return true (or false), skip original |
int/String status check |
Return the "success" constant you see in the if statement |
void method that throws on failure |
Return nothing (return;), skip original |
| Method takes credentials/tokens as args | Log args — read the values in flight |
| Method builds a permission/role object | Hook it, call original, mutate fields before returning |
| Method sends data to a server | Replace args, call original with your values |
What return type do I use?⚓
Look at the method signature in jadx:
| jadx return type | Return from your hook |
|---|---|
boolean |
true or false |
int |
a number: 1, 0, -1 |
String |
a JS string: "admin" (Frida auto-converts) |
void |
nothing — just return; or omit the return line |
| Object type | a Java object — usually from this.originalMethod() with fields then mutated |
Creating a Java String explicitly⚓
Frida auto-converts JS strings to Java Strings in most cases. If a method specifically requires java.lang.String and auto-conversion fails:
Common Bypass Scripts⚓
How to run these
Save the JavaScript to a file (e.g. bypass.js) and run:
# Spawn the app fresh — use this for checks that run at startup
frida -U -f com.example.app -l bypass.js --no-pause
# Attach to already-running app — use this for checks triggered by a user action
frida -U -n "App Name" -l bypass.js
Java.perform block.
Root Detection⚓
Java.perform(function () {
// Hook a specific root detection library (RootBeer is common)
var RootBeer = Java.use("com.scottyab.rootbeer.RootBeer");
RootBeer.isRooted.implementation = function () { return false; };
RootBeer.isRootedWithoutBusyBoxCheck.implementation = function () { return false; };
// Hook the generic file check apps use to look for su/magisk binaries
var File = Java.use("java.io.File");
File.exists.implementation = function () {
var path = this.getAbsolutePath();
if (path.indexOf("su") !== -1 || path.indexOf("magisk") !== -1) {
console.log("[*] Hiding path: " + path);
return false;
}
return this.exists();
};
});
Or in objection: android root disable
When this isn't enough
If the app still detects root after this hook, the app uses a different detection method. Use frida-trace -U -f com.example.app -j '*!isRoot*' or android hooking search classes root in objection to find other classes. Some apps also check at the native layer (/proc/self/maps, fopen on su paths) — those need native hooks.
SSL Pinning — OkHttp CertificatePinner⚓
Java.perform(function () {
var CertificatePinner = Java.use("okhttp3.CertificatePinner");
CertificatePinner.check.overload("java.lang.String", "java.util.List")
.implementation = function (hostname, certs) {
console.log("[*] OkHttp pinning bypassed: " + hostname);
};
CertificatePinner.check.overload("java.lang.String", "[Ljava.security.cert.Certificate;")
.implementation = function (hostname, certs) {
console.log("[*] OkHttp pinning bypassed (cert[]): " + hostname);
};
});
SSL Pinning — Custom TrustManager⚓
Java.perform(function () {
var X509TrustManager = Java.use("javax.net.ssl.X509TrustManager");
var SSLContext = Java.use("javax.net.ssl.SSLContext");
// Register a permissive TrustManager that accepts any certificate
var BypassTM = Java.registerClass({
name: "com.bypass.TrustManagerBypass",
implements: [X509TrustManager],
methods: {
checkClientTrusted: function (chain, authType) {},
checkServerTrusted: function (chain, authType) {},
getAcceptedIssuers: function () { return []; }
}
});
var sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, [BypassTM.$new()], null);
var HttpsURLConnection = Java.use("javax.net.ssl.HttpsURLConnection");
HttpsURLConnection.setDefaultSSLSocketFactory(sslCtx.getSocketFactory());
});
Universal Pinning Bypass (quickest)⚓
# objection one-liner (attach to running app)
android sslpinning disable
# Codeshare — no local script file needed
frida --codeshare pcipolloni/universal-android-ssl-pinning-bypass-with-frida -U -f com.example.app
When pinning bypass doesn't work
If the script loads but HTTPS still fails or the app crashes:
- The pinning may be implemented in native code (C/C++ in a
.so) — the Java hooks won't touch it. Usefrida-trace -U -f com.example.app -i "SSL_CTX_set_verify"to confirm, then hook at the native level or useapk-mitmto patch the APK instead. - The app may be checking the certificate hash in a custom way not caught by generic hooks — open the APK in jadx and search for
sha256,pin,digest,SHA256to find the custom logic, then hook that specific method. - Try using spawn mode (
-f) — if you attached to a running app, the pinning setup may have already run before your hook loaded.
Emulator Detection⚓
Many apps read android.os.Build fields and compare against known emulator values:
Java.perform(function () {
var Build = Java.use("android.os.Build");
Build.FINGERPRINT.value = "google/blueline/blueline:10/QQ3A.200805.001/6578210:user/release-keys";
Build.MODEL.value = "Pixel 3";
Build.MANUFACTURER.value = "Google";
Build.BRAND.value = "google";
Build.PRODUCT.value = "blueline";
Build.DEVICE.value = "blueline";
Build.HARDWARE.value = "qcom";
});
License / Premium Check Bypass⚓
Find the method in jadx (look for isPremium, isSubscribed, checkLicense, getEntitlements), then:
Java.perform(function () {
var Billing = Java.use("com.example.app.BillingManager");
Billing.isPremium.implementation = function () {
console.log("[*] isPremium() -> true");
return true;
};
Billing.getLicenseStatus.implementation = function () {
return 1; // return whatever int/string the app treats as "valid"
};
});
Debug Detection Bypass⚓
Java.perform(function () {
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () { return false; };
// Clear the FLAG_DEBUGGABLE bit (value 2) from ApplicationInfo
var ApplicationInfo = Java.use("android.app.ApplicationInfo");
ApplicationInfo.flags.value = ApplicationInfo.flags.value & ~2;
});
Reading Internal State⚓
You don't always need to hook a method. Sometimes you just want to read or write a value stored on an object or as a static field — without waiting for a particular method to be called.
Static Fields⚓
Static fields belong to the class itself — there is one shared copy regardless of how many instances exist. Think of them as global config: Config.BASE_URL, Config.API_KEY, Build.MODEL. Every part of the app reads the same value.
Instance fields belong to each individual object. A UserSession class might have a hundred instances in memory (one per cached session) and each has its own userId, authToken, isAdmin values independently.
Access static fields directly via the class, using .value:
Java.perform(function () {
var Config = Java.use("com.example.app.network.Config");
// Read static fields
console.log("[*] API_KEY: " + Config.API_KEY.value);
console.log("[*] BASE_URL: " + Config.BASE_URL.value);
console.log("[*] DEBUG_MODE: " + Config.DEBUG_MODE.value);
// Write — every part of the app that reads this field gets the new value
Config.BASE_URL.value = "http://192.168.1.100:8080/";
Config.DEBUG_MODE.value = true;
});
Instance Fields with Java.choose⚓
Instance fields live on specific objects in memory. To read or write them you need a reference to a live instance — you can't go through the class the way you do with static fields.
The heap is the region of memory where all Java object instances live at runtime. Java.choose scans the heap and calls onMatch for every instance of a given class it finds:
Java.perform(function () {
Java.choose("com.example.app.UserSession", {
onMatch: function (instance) {
// 'instance' is a live reference to a real object in memory
console.log("[*] Session found:");
console.log(" userId: " + instance.userId.value);
console.log(" authToken: " + instance.authToken.value);
console.log(" isAdmin: " + instance.isAdmin.value);
},
onComplete: function () {
console.log("[*] Heap scan done");
}
});
});
You can also write to instance fields inside onMatch:
onMatch: function (instance) {
instance.isAdmin.value = true;
instance.tier.value = "premium";
console.log("[*] Promoted in memory");
}
The live object is modified immediately — the change is visible to the rest of the app without any restart or re-login.
When to use each approach⚓
| Situation | Approach |
|---|---|
| Value set at login, read later | Java.choose — scan heap after login completes |
| Value computed by a specific method | Hook that method, intercept as it's calculated |
| Static config the whole app reads | Write the static field directly with .value = |
Read All Fields on an Instance (when you don't know the names)⚓
Java.perform(function () {
Java.choose("com.example.app.UserSession", {
onMatch: function (instance) {
var fields = instance.class.getDeclaredFields();
fields.forEach(function (field) {
field.setAccessible(true);
try {
console.log(field.getName() + " = " + field.get(instance));
} catch (e) {}
});
},
onComplete: function () {}
});
});
Calling Methods Directly⚓
You can invoke methods yourself without waiting for the app to call them. Useful for calling decryption routines, generating tokens, or triggering logic the UI doesn't expose.
Static methods belong to the class — you call them directly on Java.use(...). Instance methods belong to a specific object — you need a live instance to call them on, since they read and write that object's fields. The distinction comes from the Java source: if the method is declared static, call it on the class; if not, you need an instance from the heap via Java.choose.
Static Methods⚓
Java.perform(function () {
var Crypto = Java.use("com.example.app.CryptoUtils");
// Call a static decryption method with your own input
var plaintext = Crypto.decrypt("aes", "base64encodedciphertext==", "hardcodedkey");
console.log("[*] Decrypted: " + plaintext);
// Generate a token the app would normally create at login
var token = Crypto.generateAuthToken("user123");
console.log("[*] Token: " + token);
});
Instance Methods (via Java.choose)⚓
Instance methods need an object to call them on. Get one from the heap:
Java.perform(function () {
Java.choose("com.example.app.CryptoManager", {
onMatch: function (instance) {
var key = instance.getDerivedKey("password", "salt");
console.log("[*] Derived key: " + key);
var result = instance.decryptPayload("encryptedbase64==");
console.log("[*] Plaintext: " + result);
},
onComplete: function () {}
});
});
Creating New Instances⚓
Use .$new() to construct an object as if you called new ClassName(args) in Java:
Java.perform(function () {
var Crypto = Java.use("com.example.app.Crypto");
var c = Crypto.$new("mykey", "myiv"); // calls the constructor
var ciphertext = c.encrypt("plaintext");
console.log("[*] Encrypted: " + ciphertext);
});
Log All Methods on a Class⚓
When you don't know which method does what, hook all of them at once and watch what fires as you interact with the app.
Java.perform(function () {
var Target = Java.use("com.example.app.AuthManager");
Target.class.getDeclaredMethods().forEach(function (method) {
var name = method.getName();
try {
Target[name].overloads.forEach(function (overload) {
overload.implementation = function () {
var args = Array.prototype.slice.call(arguments);
console.log("[*] " + name + "(" + args.join(", ") + ")");
var ret = overload.apply(this, arguments); // call original
console.log(" -> " + ret);
return ret;
};
});
} catch (e) { /* skip constructors and native methods */ }
});
});
How to use it:
- Run this against the class you identified in jadx or objection
- Interact with the app — log in, navigate to the restricted screen, trigger the purchase flow
- Watch which methods fire and in what order
- Pick the specific method you care about and write a precise hook for just that one
This does the same thing as android hooking watch class_method in objection, but in a script — useful when you need this alongside other hooks in the same file.
Reading SharedPreferences⚓
Java.perform(function () {
var ctx = Java.use("android.app.ActivityThread")
.currentApplication().getApplicationContext();
// Find the preference file name in jadx — look for getSharedPreferences("name", 0)
var prefs = ctx.getSharedPreferences("my_prefs", 0);
var all = prefs.getAll();
var keys = all.keySet().toArray();
for (var i = 0; i < keys.length; i++) {
console.log("[*] " + keys[i] + " = " + all.get(keys[i]));
}
});
Or in objection: android shared_preferences print
Hooking Native and JNI Code⚓
What Is This and Why Does It Matter?⚓
All the Java hooks covered so far work by intercepting code running inside the Java Virtual Machine (JVM) — the runtime that executes the app's Kotlin/Java bytecode. But Android apps can also bundle compiled native code: C or C++ compiled into .so (shared object) files that run directly on the CPU, outside the JVM entirely.
JNI (Java Native Interface) is the bridge between the two worlds. It's a standard API that lets Java/Kotlin code call into native C/C++ functions, and vice versa. An app might do this for performance (crypto, image processing), to reuse existing C libraries, or specifically to make reverse engineering harder — logic in a compiled .so is much harder to read than decompiled Java.
From a security testing perspective, this matters because:
- Java hooks don't touch native code. If root detection, certificate pinning, or licence checking is implemented in a
.so, yourJava.use()hooks won't fire at all. - The JVM still calls native functions through JNI, so there's always an entry point to hook — either the JNI boundary itself, or the underlying C functions it calls.
| What's usually in Java/Kotlin | What's often in native .so |
|---|---|
| UI logic, business logic, network calls | Root/integrity checks (su, /proc, ptrace) |
| OkHttp / Retrofit / Volley networking | Crypto key handling, decryption routines |
| Most certificate pinning | Anti-debugging (ptrace, prctl) |
| SharedPreferences, SQLite access | Game logic, licence verification |
If you hook the Java layer and get no output — or the app seems unaffected — the real logic is probably native.
Recon — Find the Native Surface First⚓
Static analysis:
- Check
lib/armeabi-v7a/,lib/arm64-v8a/,lib/x86_64/in the APK for.sofiles - Search jadx for the
nativekeyword — those are Java-declared methods whose bodies live in a.so - Search jadx for
System.loadLibraryandSystem.loadto see which libraries the app loads
# From a jadx decompile output folder
grep -ri "System.loadLibrary\|System.load" output/
grep -ri " native " output/
Spawn vs Attach for Native Hooks⚓
The same -f vs -n choice exists for native, but the stakes are higher — native checks run earlier and the window to hook them is narrower.
| Situation | Use |
|---|---|
Check runs at library load (JNI_OnLoad, RegisterNatives, early init) |
Spawn (-f) — attach will always miss this |
| Exported function called during a user action (login, purchase, etc.) | Either works — attach is fine |
| Anti-debug checks in native that run at startup | Spawn (-f) |
| Library loads lazily (not at startup) | Spawn + dlopen watcher (see Generic Script below) |
Why attach often misses native checks: System.loadLibrary("foo") runs inside a Java static {} block which fires when the class is first used — very early in the app lifecycle. By the time you attach, JNI_OnLoad has already run, RegisterNatives has already mapped all the function pointers, and any startup checks have already passed or failed.
With spawn mode, Frida injects before any app code runs, but the .so library isn't mapped yet either — Module.findBaseAddress will return null on startup. You need the dlopen watcher pattern to hook functions as the library loads. See the Generic Base Address Script section below.
# Spawn — catches everything at library load time
frida -U -f com.example.app -l hook.js --no-pause
# Attach — only works for hooks triggered by user actions after the library is loaded
frida -U -n "App Name" -l hook.js
At runtime — Step 1: list loaded libraries:
This gives you every .so mapped into the process and its base address. Identify the library you care about from this list — note the base address, you'll need it if its functions turn out to be unexported.
Step 2: check imports to understand what a library does:
Imports are the functions a .so calls from other libraries — libc, libssl, libart, etc. This is the fastest way to understand a library's behaviour before disassembling it. If you see ptrace, fopen, access in the imports, it's doing anti-debug or filesystem checks. If you see SSL_CTX_set_verify or X509_verify_cert, it's touching TLS certificate validation.
Step 3: check exports to find hookable functions:
Exports are the functions the .so makes visible by name — these are directly hookable with findExportByName. If a function you found in the imports list (e.g. ptrace imported by the app's .so) doesn't appear in the exports of libfoo.so itself, that means the app uses it internally but doesn't expose it — you hook it on libc.so instead (see Hook Common libc Checks).
Step 4: get the base address for offset-based hooks:
If a function you want isn't exported, you'll need to locate it in a disassembler (Ghidra, IDA) which gives you a relative offset. The base address from Step 1 lets you compute the runtime address:
The base address changes every run (ASLR), so you always compute it at runtime — never hardcode the full address.
Module APIs Reference⚓
All these can be typed directly in the Frida REPL or used inside a script.
| API | Returns | Use when |
|---|---|---|
Process.enumerateModules() |
Array of module objects | First step — list all loaded .so files |
Module.findBaseAddress("lib.so") |
NativePointer or null |
You have a Ghidra offset and need the runtime address |
Module.enumerateExports("lib.so") |
Array of {name, address} |
Finding hookable functions, confirming visibility |
Module.enumerateImports("lib.so") |
Array of {name, module} |
Understanding what libc/system calls the library makes |
Module.enumerateSymbols("lib.so") |
Array of {name, address} |
Debug builds — finds internal names stripped from exports |
Module.findExportByName("lib.so", "fn") |
NativePointer or null |
You know the name, want its address |
Module.findExportByName(null, "fn") |
NativePointer or null |
You know the name but not which .so contains it |
findExportByName only works for exported functions
If the function is unexported (compiled with visibility=hidden or stripped), findExportByName returns null and you cannot hook it by name. For unexported functions, get the relative offset from a disassembler (Ghidra, IDA) and compute the runtime address with Module.findBaseAddress("lib.so").add(offset).
System libraries (libc.so, libart.so) always export their public functions, so findExportByName(null, "fopen") and findExportByName(null, "RegisterNatives") always work. App-internal functions in custom .so files are often unexported.
Imports (enumerateImports) tell you what a library calls — if it imports ptrace and fopen, it's doing anti-debug and filesystem checks. Use this before disassembling to quickly understand intent.
Symbols (enumerateSymbols) are a superset of exports. In release builds they're stripped and return nothing extra. In debug or partially-stripped builds they include internal function names not visible in the exports table.
Choosing Your Hook Approach⚓
| Situation | Approach |
|---|---|
jadx shows native keyword on the method |
Java.perform + .implementation — easiest, proper Java types |
Function appears in enumerateExports |
Interceptor.attach with Module.findExportByName |
| Function not exported — offset from Ghidra | Interceptor.attach with base.add(offset) |
JNI function with Java_* symbol name |
Interceptor.attach or Interceptor.replace by name |
| Want to completely replace, not just wrap | Interceptor.replace with a NativeCallback |
JNI registered via RegisterNatives (no symbol) |
Hook RegisterNatives to capture the address at runtime |
| Want to block a libc check (root/anti-debug) | Hook fopen/access/ptrace directly |
Hook Option 1 — Java.perform (Java-declared native methods)⚓
If the native function has a native keyword in the Java/Kotlin source, hook it at the JNI boundary using Java.perform — you get proper Java types and no need to find addresses or offsets.
// What you see in jadx — native keyword means the body is in a .so
public native boolean isRooted();
public native String decryptPayload(String ciphertext);
Java.perform(function () {
var Security = Java.use("com.example.app.Security");
// Block the native call entirely — .so code never runs
Security.isRooted.implementation = function () {
console.log("[*] isRooted blocked at JNI boundary");
return false;
};
// Let it run but read both input and output
Security.decryptPayload.implementation = function (ciphertext) {
console.log("[*] ciphertext: " + ciphertext);
var result = this.decryptPayload(ciphertext); // calls into native
console.log("[*] plaintext: " + result);
return result;
};
});
When there is no Java declaration (pure native-to-native calls, RegisterNatives, or internal .so helpers), use one of the approaches below.
Hook Option 2 — Interceptor.attach (Observe and Modify)⚓
Interceptor.attach wraps a function — onEnter and onLeave fire at the call boundary, and you can read/modify arguments and return values. The original function still runs unless you explicitly replace or skip it.
Exported function — find by name:
var target = Module.findExportByName("libfoo.so", "do_root_check");
Interceptor.attach(target, {
onEnter: function (args) {
console.log("[*] do_root_check called");
// args[n] are raw NativePointers
// Read a C string argument:
console.log(" path = " + Memory.readUtf8String(args[0]));
// Read an integer argument:
console.log(" flags = " + args[1].toInt32());
},
onLeave: function (retval) {
console.log(" retval = " + retval.toInt32());
retval.replace(0); // overwrite the return value
}
});
Unexported function — find by offset from Ghidra/IDA:
var base = Module.findBaseAddress("libfoo.so");
var target = base.add(0x1a3c); // relative offset from your disassembler
Interceptor.attach(target, {
onEnter: function (args) {
console.log("[*] internal_helper: " + Memory.readUtf8String(args[0]));
},
onLeave: function (retval) {
retval.replace(-1);
}
});
Library not loaded yet?
If Module.findBaseAddress returns null when your script loads, the library isn't mapped yet. Wrap your hook inside Module.load or a dlopen watcher — see the Generic Base Address Script below.
Hook Option 3 — Interceptor.replace (Completely Overwrite)⚓
Interceptor.replace swaps out the entire function body. The original code never executes — your NativeCallback runs instead. Use this when you want total control with no chance of the original logic running.
// Signature must match the original C function
// int do_root_check() → NativeCallback(['int'], [])
var replacement = new NativeCallback(function () {
console.log("[*] do_root_check replaced — returning 0");
return 0; // always not-rooted
}, 'int', []);
var target = Module.findExportByName("libfoo.so", "do_root_check");
Interceptor.replace(target, replacement);
With arguments:
// int check_path(const char *path)
var replacement = new NativeCallback(function (pathPtr) {
var path = Memory.readUtf8String(pathPtr);
console.log("[*] check_path called with: " + path);
if (path.indexOf("su") !== -1 || path.indexOf("magisk") !== -1) {
return -1; // pretend path doesn't exist
}
// Call the original for non-suspicious paths
return originalCheckPath(pathPtr);
}, 'int', ['pointer']);
var target = Module.findExportByName("libfoo.so", "check_path");
// Save original before replacing it
var originalCheckPath = new NativeFunction(target, 'int', ['pointer']);
Interceptor.replace(target, replacement);
Interceptor.attach vs Interceptor.replace:
Interceptor.attach |
Interceptor.replace |
|
|---|---|---|
| Original runs? | Yes (unless you rewrite retval) | No — completely overwritten |
| Can call original? | Yes, it runs automatically | Only if you saved it as NativeFunction first |
| Best for | Logging, reading values, tweaking retval | Full bypass with custom logic |
Hook Option 4 — JNI Symbol Names (Java_*)⚓
When JNI methods are registered via the standard naming convention, they appear in the exports table with names like:
Find them with:
Hook them directly:
var jni = Module.findExportByName("libfoo.so", "Java_com_example_app_Security_isRooted");
Interceptor.attach(jni, {
onEnter: function (args) {
// args[0] = JNIEnv*, args[1] = jobject (this), args[2+] = method params
console.log("[*] JNI isRooted called");
},
onLeave: function (retval) {
retval.replace(0); // 0 = false in JNI boolean
}
});
Note
The first two arguments to every JNI function are always JNIEnv* (the JNI environment pointer) and jobject/jclass (the calling Java object or class). Your actual method parameters start at args[2].
Hook Option 5 — Dynamically Registered JNI (RegisterNatives)⚓
The standard JNI naming convention (Java_com_example_app_Security_isRooted) means the linker can resolve Java-to-native bindings automatically at load time — the name encodes the class and method. But some apps instead call RegisterNatives themselves at runtime, passing an explicit table of {method name, signature, function pointer} entries. This skips the naming convention entirely, meaning:
- There are no
Java_*symbols in the export table Module.findExportByNamecan't find the function by any meaningful name- The only way to get the native address is to intercept the
RegisterNativescall as it happens
RegisterNatives is a JNI API function — it lives in the Android runtime (libart.so) and is itself an export, so Frida can hook it with findExportByName(null, "RegisterNatives").
Step 1 — Hook RegisterNatives to log what's being registered:
var RegisterNatives = Module.findExportByName(null, "RegisterNatives");
Interceptor.attach(RegisterNatives, {
onEnter: function (args) {
// args[0] = JNIEnv*
// args[1] = jclass (the Java class being wired up)
// args[2] = pointer to array of JNINativeMethod structs
// args[3] = number of methods in the array
var count = args[3].toInt32();
for (var i = 0; i < count; i++) {
// Each JNINativeMethod struct: { const char* name, const char* signature, void* fnPtr }
// 3 consecutive pointer-sized fields
var entry = args[2].add(i * Process.pointerSize * 3);
var name = Memory.readUtf8String(Memory.readPointer(entry));
var sig = Memory.readUtf8String(Memory.readPointer(entry.add(Process.pointerSize)));
var fnPtr = Memory.readPointer(entry.add(Process.pointerSize * 2));
console.log("[*] RegisterNatives: " + name + sig + " -> " + fnPtr);
}
}
});
This prints output like:
[*] RegisterNatives: isRooted()Z -> 0x7b3a1234
[*] RegisterNatives: checkLicense(Ljava/lang/String;)Z -> 0x7b3a1a80
The signature format is standard JNI: ()Z = takes nothing, returns boolean. (Ljava/lang/String;)Z = takes a String, returns boolean.
Step 2 — Once you have the address, attach a hook:
// Use the address printed by the RegisterNatives hook above
Interceptor.attach(ptr("0x7b3a1234"), {
onEnter: function (args) {
// args[0] = JNIEnv*, args[1] = jobject — your method params start at args[2]
console.log("[*] isRooted called");
},
onLeave: function (retval) {
retval.replace(0); // 0 = false
}
});
Tip
Run the RegisterNatives logger first with spawn mode (-f) so you catch registrations that happen at startup, then use the printed addresses in your actual bypass script.
Hook Common libc Checks⚓
Native checks — regardless of how deep they're buried in wrapper functions — almost always end up calling the same handful of C standard library functions: fopen, access, stat, open, ptrace. These are exported from libc.so, which is always loaded in every Android process. Since Module.findExportByName(null, name) searches across all loaded libraries, you can hook them without knowing anything about the app's internal structure — without even having to find which .so does the check.
This is the approach to try first when you don't know where the check lives. Instead of reversing the wrapper chain, intercept the libc call at the bottom and spoof the result there.
Filesystem checks (root detection):
Root detectors call access, fopen, stat, or open on paths like /data/local/tmp/su, /system/bin/su, or /data/adb/magisk. Filter by path and return an error:
// fopen returns NULL on failure (file not found)
// access/stat return -1 on failure
// We spoof both by checking the path before the call returns
["fopen", "access", "stat", "statfs", "open"].forEach(function (fn) {
var addr = Module.findExportByName(null, fn);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function (args) {
this.path = Memory.readUtf8String(args[0]);
},
onLeave: function (retval) {
if (!this.path) return;
var p = this.path;
if (p.indexOf("/su") !== -1 ||
p.indexOf("magisk") !== -1 ||
p.indexOf("supersu") !== -1 ||
p.indexOf("frida") !== -1) {
console.log("[*] " + fn + "(\"" + p + "\") spoofed");
retval.replace(fn === "fopen" ? 0 : -1);
}
}
});
});
Anti-debug check (ptrace):
Apps call ptrace(PTRACE_TRACEME, ...) to detect if a debugger is attached — if a tracer is already present the call returns -1. Hook and clean that result:
var ptrace = Module.findExportByName(null, "ptrace");
Interceptor.attach(ptrace, {
onLeave: function (retval) {
if (retval.toInt32() === -1) {
console.log("[*] ptrace PTRACE_TRACEME spoofed");
retval.replace(0);
}
}
});
/proc reads:
Some checks read /proc/self/maps or /proc/self/status to look for Frida, Magisk, or debugger markers. These go through fopen/open above, but the sensitive information is in the file contents. For fine-grained control, hook fgets or read and filter the output — though in practice the path-based fopen hook is usually enough to deny access entirely.
Common JavaScript / Frida APIs⚓
These are the utility functions you'll reach for constantly when working with native code.
Reading memory:
Memory.readUtf8String(ptr) // read null-terminated UTF-8 string from a pointer
Memory.readCString(ptr) // same but stops at null byte
Memory.readByteArray(ptr, len) // raw bytes as ArrayBuffer
Memory.readPointer(ptr) // read a pointer-sized value (follow a pointer)
Memory.readS32(ptr) // signed 32-bit int at address
Memory.readU32(ptr) // unsigned 32-bit int
Memory.readU8(ptr) // single byte
// Example — read a string from args[0]
onEnter: function (args) {
console.log(Memory.readUtf8String(args[0]));
}
Writing memory:
Memory.writeUtf8String(ptr, "newvalue") // overwrite a string in memory
Memory.writeByteArray(ptr, [0x90, 0x90]) // write raw bytes (e.g. NOP patch)
Memory.writeU32(ptr, 1) // write integer
Memory.writePointer(ptr, otherPtr) // write a pointer value
Working with pointers:
ptr("0x7b3a1234") // create a NativePointer from a hex string
ptr(0x7b3a1234) // from a number
args[0].toInt32() // read args[n] as a signed integer
args[0].toUInt32() // read args[n] as unsigned integer
args[0].add(8) // pointer arithmetic — advance by 8 bytes
args[0].readPointer() // dereference (follow) the pointer
Calling a native function from JavaScript:
// Declare the signature so Frida knows how to call it
// NativeFunction(address, returnType, [argTypes])
var strlen = new NativeFunction(
Module.findExportByName(null, "strlen"),
'int', ['pointer']
);
var len = strlen(Memory.allocUtf8String("hello"));
console.log("strlen = " + len); // 5
Creating a native function from JavaScript (for Interceptor.replace):
// NativeCallback(jsFunction, returnType, [argTypes])
var fake = new NativeCallback(function (pathPtr) {
return -1; // always return ENOENT
}, 'int', ['pointer']);
Interceptor.replace(Module.findExportByName(null, "access"), fake);
Allocating memory:
var buf = Memory.alloc(64); // 64 bytes on the heap
var str = Memory.allocUtf8String("hello world"); // allocate and write a C string
Type strings used in NativeFunction / NativeCallback:
| C type | Frida type string |
|---|---|
void |
'void' |
int / int32_t |
'int' |
unsigned int |
'uint' |
long |
'long' |
size_t |
'size_t' |
char * / any pointer |
'pointer' |
bool |
'bool' |
float / double |
'float' / 'double' |
Generic Script — Resolving Base Address⚓
Use this skeleton when you need to hook unexported functions by offset. It handles the case where the library might not be loaded at script start:
var LIB = "libfoo.so";
function attachHooks() {
var base = Module.findBaseAddress(LIB);
// Exported function — by name
var exported = Module.findExportByName(LIB, "do_root_check");
Interceptor.attach(exported, {
onLeave: function (retval) { retval.replace(0); }
});
// Unexported function — by offset from Ghidra/IDA
Interceptor.attach(base.add(0x1a3c), {
onEnter: function (args) {
console.log("[*] internal_helper: " + Memory.readUtf8String(args[0]));
},
onLeave: function (retval) { retval.replace(-1); }
});
}
// Try immediately — library may already be mapped
if (Module.findBaseAddress(LIB)) {
attachHooks();
} else {
// Library loads later — watch for dlopen
var dlopen = Module.findExportByName(null, "android_dlopen_ext") ||
Module.findExportByName(null, "dlopen");
Interceptor.attach(dlopen, {
onEnter: function (args) {
this.lib = Memory.readUtf8String(args[0]);
},
onLeave: function () {
if (this.lib && this.lib.indexOf(LIB) !== -1) {
console.log("[*] " + LIB + " loaded — attaching hooks");
attachHooks();
}
}
});
}
Mix Java and Native Hooks Together⚓
For JNI-heavy apps, hook both layers at once to understand where the real decision happens:
// Java side — log when the method is called from Java and what args it receives
Java.perform(function () {
var Security = Java.use("com.example.app.Security");
Security.isRooted.implementation = function () {
console.log("[*] Java: isRooted() called");
return this.isRooted(); // let it fall through to native
};
});
// Native side — intercept the .so function that does the actual check
var target = Module.findExportByName("libfoo.so", "Java_com_example_app_Security_isRooted");
Interceptor.attach(target, {
onLeave: function (retval) {
console.log("[*] Native: isRooted returning " + retval.toInt32());
retval.replace(0); // block at the native layer
}
});
The general approach:
- Use jadx to find the Java class with the
nativemethod - Hook the Java side to confirm when and how it's called
- Hook the native side to see what the
.soactually does - Modify at the lowest stable layer that controls the outcome
Troubleshooting⚓
frida-ps -U hangs or gives a connection error⚓
frida-server isn't running on the device, or the version doesn't match.
# Check your host tools version
frida --version
# On the device — verify the server is running
adb shell ps | grep frida
# If it's not running, start it:
adb shell "/data/local/tmp/frida-server &"
If the version is wrong: go to github.com/frida/frida/releases, download the exact matching version for your arch, push again.
Failed to attach: unable to find process⚓
The name you gave -n doesn't match any running process.
# Find the exact process name
frida-ps -Ua
# The "Name" column is what you pass to -n
# If the app isn't listed, it's not running — open it first, then re-run
Alternatively use the PID instead: frida -U -p 1234 -l script.js
Script loads but nothing prints (hook never fires)⚓
The hook code ran but the method you targeted was never called — or the class/method name is wrong.
- Check the class path: in jadx, right-click the class → Copy reference → verify it matches exactly what you passed to
Java.use() - Check the method name: it's case-sensitive. Verify in jadx.
- Use spawn mode (
-f) — the method may run at startup before you attached. Spawn ensures your hook is in place before any app code runs. - The class might not be loaded yet:
Java.use()fails silently if the class isn't loaded when your script runs. Hook a method you know fires early (e.g.Activity.onCreate) and callJava.use()inside that hook.
Error: ambiguous method; use overload()⚓
See Handling Overloads. Frida lists the exact .overload(...) calls you need in the error message — copy one from there.
Hook fires but the bypass doesn't work (app still detects root / pinning still blocks)⚓
The check runs somewhere else, not just the method you hooked.
- Search for other implementations:
android hooking search classes rootorfrida-trace -U -n "App Name" -j '*!isRoot*' - Check if there's a native implementation (C/C++ in a
.so):frida-trace -U -f com.example.app -i "*root*"ormemory list exports libnative.so - For SSL pinning specifically: see the warning box in Common Bypass Scripts
App crashes after attaching with Frida⚓
Some apps detect Frida's presence (process name, memory maps, port 27042).
- Try using gadget mode instead of frida-server — it's harder to detect
- Use a Codeshare anti-detection script:
frida --codeshare dzonerzy/fridantiroot -U -f com.example.app - Try renaming the frida-server binary:
mv frida-server gadget-helper— some detection checks for the process namefrida-server
TypeError: Java.use is not a function / Java is not defined⚓
Your script is running outside Java.perform. Wrap everything inside:
adb root fails on the emulator⚓
You're using a Google Play AVD. Switch to a Google APIs AVD in the AVD Manager — adb root works on those. Google Play AVDs lock root to simulate a production device.
Finding Scripts⚓
Frida Codeshare⚓
Run community scripts directly without downloading a file:
| Script | What it does |
|---|---|
pcipolloni/universal-android-ssl-pinning-bypass-with-frida |
Universal SSL pinning bypass |
dzonerzy/fridantiroot |
Root detection bypass |
masbog/frida-android-unpinning-ssl |
OkHttp + TrustManager unpinning |
sowdust/universal-android-frida-root-bypass |
Broad root bypass |
11x256/frida-il2cpp-bridge |
Hook Unity IL2CPP methods |
Browse all at codeshare.frida.re
GitHub⚓
- github.com/NVISOsecurity/frida-codeShare — maintained collection of Android hooks
- github.com/iddoeldor/frida-snippets — large snippet library
- github.com/WithSecureLabs/drozer — complement to Frida for component attacks
- objection source at
site-packages/objection/console/commands/android/— read the built-in scripts to understand and adapt them
Root Requirements Summary⚓
| Approach | Physical device | Emulator |
|---|---|---|
| frida-server | Root required (Magisk) | adb root (Google APIs AVD only) |
| frida-gadget (repacked APK) | No root needed | No root needed |
| objection (frida-server mode) | Root required | adb root |
| objection patchapk (gadget) | No root needed | No root needed |
| frida-trace | Root required | adb root |