Summary
On a public Android client I call com.vulnapp.portal (lab build 5.4.4), the OAuth 2.0 authorization-code flow returned to a custom scheme: vulnauth://oauth-callback/app. Android does not prove who owns a custom scheme. A second package on the same device can register the same scheme and receive the returning intent.
The authorization request carried no code_challenge. Without PKCE, an authorization code is not bound to the client that started the login. That combination is the finding. Names, hosts, and tokens from the original program are not published here.
Lab frame
Reconstructed from the live session. Original vendor chrome removed. Prompt is mitsec@com.vulnapp.portal.
VulnApp account
Sign in to use portal services on this device.
38 Response tamper (client-trust) 39 OAuth + PKCE observer ★ 40 WebSocket interceptor D NATIVE & RUNTIME 48 FULL AUTO (surface-aware) ★ E DEFENSE 50 TLS pinning observer ★ ★ start-here for this app 48 surface-aware loadout 39 OAuth + PKCE observer 14 intent / deeplink capture mitsec@com.vulnapp.portal:
What AndroScope saw
Rootless session. Intent hook on the lab process while the operator tapped Sign in. Two Intent.getData rows mattered:
- An HTTPS authorize URL on the IdP, with
response_type=code, astate, andredirect_uri=vulnauth://oauth-callback/app. Nocode_challenge/code_challenge_method. - The custom-scheme return:
vulnauth://oauth-callback/app?code=[REDACTED]&state=[REDACTED].
No Digital Asset Links check for that scheme. Tokens did not appear in the app sandbox before the code came back — the code is the portable secret in this flow.
Why the two bugs stack
Custom schemes are an intent filter. They are not App Links. Any package can claim vulnauth. When the browser hands the redirect to Android, the resolver may show a chooser — or deliver to a default handler.
PKCE (RFC 7636) is the control that still saves a public client if that intent is stolen. The client keeps a code_verifier. The server stored a challenge. The token request must prove the verifier. No challenge in the authorize URL means the code is not bound to the app that started login.
This page does not include a second APK, a receiver activity, or a token-endpoint request. Those belong in a private report, not a public writeup.
Impact (class)
If the token endpoint issues an access token (and a refresh token) for that code without a verifier, the holder of the code holds the user’s portal session. Scope depends on what the IdP actually grants — profile, documents, payments. Treat it as full account takeover of that identity, not a cosmetic deeplink bug.
Fix
- Require PKCE S256 on the authorization server for every public client. Reject token requests with no matching verifier.
- Move the redirect to a verified Android App Link (HTTPS + Digital Asset Links). Do not keep an unverified custom scheme as the primary callback.
- Allowlist
redirect_uriexactly. Do not accept a cousin scheme. - Re-test the mobile authorize URL until
code_challengeis visible in the session transcript.
References
RFC 8252 (OAuth 2.0 for Native Apps), RFC 7636 (PKCE), RFC 6749, Android App Links. Testing notes: Android 13, unrooted, AndroScope gadget-in-target. Original program identifiers removed under disclosure rules.