Android's component model makes inter-app communication straightforward. That convenience becomes dangerous when an exported component accepts complex objects, trusts the URLs inside them, and hands those URLs to a credential-bearing HTTP client.
The affected product is described only as a popular Indian food delivery app. Company names, app names, package identifiers, domains, concrete class names, and endpoint paths have been replaced with neutral examples.
A component another app could start
The application declared a single-activity dispatcher as exported. An intent filter described a text-sharing use case, but explicit intents do not need to match that filter. Any installed peer app could address the activity directly.
<activity
android:name=".ExportedBridgeActivity"
android:exported="true">
<intent-filter>...</intent-filter>
</activity>
The activity read a string selecting which fragment to open and a serializable object containing the fragment's initialization data. One supported fragment represented a generic cart. Its object contained three URL fields.
val screen = intent.getStringExtra("screen")
val data = intent.getSerializableExtra("cart_data") as? CartData
if (screen == "generic_cart") {
openCart(data)
}
Because the receiver expected a Java-serializable object, the sender could reproduce the same class name, fields, and serialization identifiers in its own APK. Android then reconstructed the attacker-supplied object inside the target process.
The URL escaped the app's trust boundary
The cart view model retrieved a URL from the object and passed it into a Retrofit method using @Url. That annotation allows the caller to replace the request destination at runtime.
interface CartService {
@POST
suspend fun fetch(@Url url: String, @Body request: CartRequest)
}
Dynamic URLs are not automatically unsafe. The critical mistake was downstream: a global OkHttp interceptor added the user's live session context to every request without checking the destination host.
override fun intercept(chain: Chain): Response {
val request = chain.request().newBuilder()
.addHeader("X-Access-Token", session.accessToken)
.addHeader("X-Refresh-Token", session.refreshToken)
.addHeader("X-Device-Id", device.id)
// dozens of additional session and location headers
.build()
return chain.proceed(request) // destination host never checked
}
The full chain
The proof-of-concept app requested no Android permissions and displayed no interface. It created the matching serializable object, set all URL fields to a controlled HTTPS listener, and launched the exported activity explicitly.
Within seconds, the listener received a POST carrying roughly 59 headers. Among them were the live access token, refresh token, device identifier, application metadata, and location context.
The attacker app never read another app's storage. It persuaded the victim app to package and transmit its own secrets.
Why this was more than a token screenshot
Replaying the captured access token from a separate machine returned the test account's authenticated profile. The same client credentials reached account, collaboration, and payment-adjacent APIs. Depending on the endpoint, the session could expose personal information, initiate sensitive profile changes, act as the user, or delete the account.
The selected cart flow was not unique. Multiple reachable fragments accepted URL-bearing initialization models and fed the same HTTP client. That made the missing host restriction the root cause, with the exported activity serving as one reliable entry point.
Fixing all three trust decisions
1. Do not export internal dispatchers
If an activity is not a public integration surface, set android:exported="false". If trusted sibling apps require it, protect it with a signature-level permission and validate the caller.
2. Bind credentials to approved origins
The interceptor should attach session headers only when the scheme is HTTPS and the normalized host matches an explicit first-party allowlist. This closes the bug family even if another attacker-controlled URL reaches the client later.
3. Validate every dynamic URL before dispatch
URL-bearing models should be parsed once, rejected if malformed, and checked against the same centralized origin policy before they reach Retrofit.
When reviewing Android deep links and exported activities, follow untrusted extras beyond the component boundary. A URL sink may look like SSRF; an interceptor can quietly upgrade it into direct credential exfiltration.
Closing thought
The vulnerability came from three locally reasonable choices: an exported dispatcher, a flexible data model, and a convenient global interceptor. Security testing had to follow the object across all three layers to see the actual impact.
