Skip to content

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:

pip install frida-tools objection

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.

  1. Download the correct frida-server binary from github.com/frida/frida/releases
  2. Match the version exactly to your installed tools: frida --version
  3. Pick the right arch: arm64 for modern physicals, x86_64 for most emulators

  4. 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:

  1. android hooking search classes auth — find auth-related classes by keyword
  2. android hooking list class_methods com.example.app.AuthManager — see every method on it
  3. android 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:

  1. frida-trace -U -f com.example.app -j 'com.example.app.AuthManager!*'
  2. Interact with the app — log in, navigate, trigger the target action
  3. Watch the terminal to see which methods fire and in what order
  4. 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:

  1. Search for a string or endpoint in jadx
  2. Open the surrounding class and copy its full class reference
  3. Hook that class even if the method names are short and ugly
  4. 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 call Java.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$1
  • x.y.z.a$b
  • x.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

var result = this.methodName(arg1, arg2); // calls the real implementation
Must use this.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

frida -U -f com.example.app --no-pause

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:

public boolean isPremium() {
    return this.userTier.equals("premium");
}

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:

if (getLicenseStatus() != 1) {
    showPaywall();
    return;
}

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:

var JavaString = Java.use("java.lang.String");
var jstr = JavaString.$new("my value");

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
If multiple bypasses are needed (e.g. root + SSL pinning), put them in the same file inside one 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. Use frida-trace -U -f com.example.app -i "SSL_CTX_set_verify" to confirm, then hook at the native level or use apk-mitm to 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, SHA256 to 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:

  1. Run this against the class you identified in jadx or objection
  2. Interact with the app — log in, navigate to the restricted screen, trigger the purchase flow
  3. Watch which methods fire and in what order
  4. 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, your Java.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 .so files
  • Search jadx for the native keyword — those are Java-declared methods whose bodies live in a .so
  • Search jadx for System.loadLibrary and System.load to 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:

memory list modules
frida -U -f com.example.app --no-pause
Process.enumerateModules().forEach(m => console.log(m.name, m.base));

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:

// What external functions does this library call?
Module.enumerateImports("libfoo.so").forEach(i => console.log(i.name, i.module));

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:

memory list exports libfoo.so
// All exported functions
Module.enumerateExports("libfoo.so").forEach(e => console.log(e.name, e.address));

// Filter by keyword
Module.enumerateExports("libfoo.so")
    .filter(e => e.name.includes("root") || e.name.includes("check"))
    .forEach(e => console.log(e.name, e.address));

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:

var base = Module.findBaseAddress("libfoo.so");
console.log(base); // e.g. 0x7b3a120000

// Hook an unexported function at offset 0x1a3c from the base
var target = base.add(0x1a3c);

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:

Java_com_example_app_Security_isRooted
Java_com_example_app_NativeBridge_checkLicense

Find them with:

memory list exports libfoo.so
Module.enumerateExports("libfoo.so")
    .filter(e => e.name.startsWith("Java_"))
    .forEach(e => console.log(e.name, e.address));

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.findExportByName can't find the function by any meaningful name
  • The only way to get the native address is to intercept the RegisterNatives call 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:

  1. Use jadx to find the Java class with the native method
  2. Hook the Java side to confirm when and how it's called
  3. Hook the native side to see what the .so actually does
  4. 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.

  1. Check the class path: in jadx, right-click the class → Copy reference → verify it matches exactly what you passed to Java.use()
  2. Check the method name: it's case-sensitive. Verify in jadx.
  3. 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.
  4. 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 call Java.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 root or frida-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*" or memory 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 name frida-server

TypeError: Java.use is not a function / Java is not defined

Your script is running outside Java.perform. Wrap everything inside:

Java.perform(function () {
    // all your Java.use() calls go here
});

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:

frida --codeshare <author>/<script-name> -U -f com.example.app
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


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