The Story of How I Took Apart an APK and Found a Messed-Up Login Callback
Account Takeover Resulting from Android Deep Link Hijacking and a Missing OAuth PKCE

After reverse-engineering the APK, I found that any app could receive the login callback
— Account Takeover Resulting from Android Deep Link Hijacking and a Missing OAuth PKCE
When I start penetration testing a mobile app, the first thing I usually do is dissect the APK.
I check the certificates and
examine exported components,
search for hardcoded secrets,
examine WebView settings,
and look for API endpoints.
In the process, you’ll come across things like this—intent-filter—quite often.
<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="acmeapp"
android:host="oauth" />
</intent-filter>
It doesn’t seem particularly unusual.
Once you’re logged in, the browser
acmeapp://oauth/callback
,
and the app receives that URI to complete the authentication.
This is very common in mobile apps.
But seeing this made me wonder about one thing.
acmeapp://Is there any guarantee that only this app can receive it?
The answer was no.
And because of that single question, what had been a routine login feature led to the possibility of account takeover.
It all started with static analysis of the APK.
Let’s assume the test subject is an Android-based B2C service.
Although the service supports email/password login,
most users use Google or the company’s SSO.
The authentication flow goes something like this.
Mobile App
|
v
System Browser
|
v
Authorization Server
|
v
OAuth Callback
|
v
Mobile App
Using a browser for mobile OAuth is not unusual in itself.
In fact, RFC 8252 recommends that native applications use an external user-agent—typically the system browser—for OAuth authentication requests.
I opened the APK with JADX and checked starting from `AndroidManifest.xml`.
jadx-gui target.apk
Then, I looked for strings related to URIs.
android.intent.action.VIEW
BROWSABLE
oauth
callback
redirect_uri
Among them, I found one Activity that handles the login callback.
<activity
android:name=".auth.OAuthCallbackActivity"
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="acmeapp"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
exported=true This is not a vulnerability in itself.
Since the callback must be received by an external browser, it naturally follows that the Activity must be executable from an external source.
The issue was which URI scheme it trusted.
Custom URI schemes lack proof of ownership.
When you think about it in the context of the web, it’s a bit strange.
https://login.example.com/callback
Not just anyone can claim a URL like that.
example.comYou must own it.
But custom URI schemes are different.
acmeapp://oauth/callback
There is no centralized ownership verification—like DNS—for the string “acmeapp” in a URL.
Other Android apps can also register the same scheme.
RFC 8252 also states that, as a limitation of private-use URI schemes, multiple apps can register the same scheme, and it explicitly mentions the possibility of other apps intercepting authorization codes.
The Android documentation also describes the fact that multiple apps can register to handle the same deep link URI as a security risk.
So I tested it right away.
First, I called the deep link directly
I used ADB to see how the callback Activity responds.
adb shell am start \
-a android.intent.action.VIEW \
-d "acmeapp://oauth/callback?code=TEST_CODE"
The app opens.
It worked as expected.
Next, I entered a slightly different value.
adb shell am start \
-a android.intent.action.VIEW \
-d "acmeapp://oauth/callback?error=access_denied"
I also verify the URI parsing process.
scheme
host
path
query parameters
The important thing here is not to prove the attack just yet.
This is the stage where you determine the extent to which the app trusts external input.
In mobile penetration testing, you shouldn’t immediately count the discovery of such a component as a finding.
Exported Activity
!=
Vulnerability
There must be an actual security impact.
Could other apps register the same scheme?
Create a single Android app for testing.
There is only one purpose:
To have it handle the same scheme.
<activity
android:name=".CallbackReceiver"
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="acmeapp"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
There’s no need to include malicious behavior.
For the PoC, it’s sufficient to simply check the incoming URI.
Received URI:
acmeapp://oauth/callback?code=...
With this test app installed on the same device, perform the OAuth login again.
Now, a significant problem arises.
Authorization Server
|
v
acmeapp://oauth/callback
|
+--------------------+
| |
v v
Original App Test App
From the operating system’s perspective, both apps have declared that they can handle that URI.
One of the reasons Android provides verified App Links is to mitigate this issue. App Links verify the ownership relationship between a web domain and an app through assetlinks.json and similar methods.
However, there is one more important question here
Suppose the authorization code was passed to a different app.
Does that immediately mean the account has been compromised?
No.
At this point, a senior pentester must take the OAuth flow one step further.
These days, OAuth in native applications typically requires PKCE.
A normal Authorization Code + PKCE flow works like this:
Client
|
| Generate code_verifier
|
| SHA256(code_verifier)
v
code_challenge
|
v
Authorization Server
<--- authorization_code ---
Client
|
| authorization_code
| code_verifier
v
Token Endpoint
When the login process begins, the app generates a random `code_verifier`.
It then uses that value to generate an `code_challenge` and sends it in the authorization request.
Later, when exchanging the authorization code for a token, the original `code_verifier` is required.
Therefore, even if another app intercepts just a single code,
Authorization Code
|
v
No code_verifier
|
v
Token Exchange Failed
it must be invalidated.
RFC 8252 specifies that PKCE must be used in public native apps, and one of its key purposes is to defend against precisely this type of authorization-code interception attack.
So, let’s look at an actual login request
I checked the authorization request via a proxy.
It looked something like this.
GET /authorize?
client_id=mobile-app
&response_type=code
&redirect_uri=acmeapp%3A%2F%2Foauth%2Fcallback
&scope=openid%20profile
&state=8d91...
Here’s what I was looking for.
code_challenge
code_challenge_method
But it wasn’t there.
client_id
response_type
redirect_uri
scope
state
No code_challenge
I tried logging in again.
It’s the same.
I also checked the OAuth client implementation inside the APK.
Sure enough, there’s no verifier generation process.
This is where the severity of the finding begins to change.
state"Isn't it okay since it's there?"
This is a question the development team asks very often.
“
state—we’re verifying that, right?”
stateThat’s important.
But the purpose is different.
Broadly speaking,
state
|
v
Request / Response Correlation
CSRF Protection
and
PKCE
|
v
Authorization Code
Interception Protection
and
Neither one replaces the other.
PortSwigger also treats callback and redirect_uri verification, as well as the possibility of authorization code theft, as separate attack surfaces within OAuth security.
Now, let’s create a minimal PoC
The key point is that we are not attacking actual user accounts.
Create a test account.
Account:
pentest-mobile@example.test
Then, on the test device,
Original App
Test Receiver App
on the test device.
In the Original App, start the login process using the test account.
Replicate the scenario where the callback is sent to the test Receiver after authentication is complete.
Configure the Receiver to log only the URI.
acmeapp://oauth/callback?code=REDACTED
At this point, code interception has been demonstrated.
Next, verify—within the scope of the authorized test account—whether the authorization server allows code redemption for public clients without PKCE.
If successful, the attack chain is complete.
Victim Starts Login
|
v
Authorization Server
|
v
Authorization Code
|
v
Custom URI Scheme
|
v
Attacker-controlled App
|
v
Code Interception
|
v
Token Exchange
|
v
Victim Session
At this point, we can first discuss the possibility of account takeover.
In fact, looking at the deep link alone, it appeared to be a “Medium” risk.
This was all that was evident from the initial static analysis.
Custom URI Scheme
We cannot assign a high severity rating based on this alone.
In the next step,
Scheme Collision
this was confirmed.
Still, this doesn’t mean an account has been compromised yet.
And finally,
PKCE Missing
these elements are combined.
Only then does the entire attack path emerge.
Custom URI Scheme
+
Scheme Hijacking
+
Missing PKCE
|
v
OAuth Code Interception
|
v
Account Takeover
This is what makes white-hat hacking so interesting in practice.
Findings that seem insignificant when viewed individually
but the moment they connect within the authentication protocol, their impact changes completely.
We also check WebView
When examining OAuth, you naturally end up looking at WebView as well.
Search for the following classes in the APK.
WebView
WebViewClient
loadUrl
setJavaScriptEnabled
addJavascriptInterface
shouldOverrideUrlLoading
In particular, check for code that passes the URL received via a deep link directly to WebView.
For example, if the structure looks like this, it should be reviewed.
External Deep Link
|
v
url Parameter
|
v
WebView.loadUrl()
If there is no URL allowlist or origin validation, it could create another attack surface.
Android also warns that if values passed via deep links aren’t strictly validated, host validation bypasses or other security issues may occur.
However, the key principle remains the same here.
Interesting Code
!=
Finding
You must verify whether it is actually possible to cross the trust boundary.
In mobile penetration testing, the server and the app must not be considered separately.
There is one common mistake that occurs in these cases.
The mobile team often says:
“OAuth is the server team’s responsibility.”
The server team says:
"The app handles the callback."
From a security perspective, this distinction is meaningless.
The attack path traverses the entire system.
Android
|
v
Browser
|
v
Identity Provider
|
v
Authorization Server
|
v
Deep Link
|
v
Android
|
v
Token API
An attacker doesn’t care about the organizational chart.
Therefore, relying on just one
Android Bug
is insufficient on its own.
To be precise,
OAuth Client Security
+
Android Platform Interaction
a problem.
The fix isn’t about making the Custom Scheme name more complicated—
Sometimes, modifications like this come up.
acmeapp://
Instead,
com.company.product.mobile.authentication://
it just makes the scheme longer, like this.
It looks a little better, but it’s not a fundamental solution.
An attacker can download the APK.
They can also view the manifest.
Secret URI Scheme
The concept itself is difficult to justify.
RFC 8252 recommends using a reverse-domain format when employing custom schemes, but at the same time, it explicitly considers the possibility of interception by other apps in the case of private-use schemes.
First Fix — PKCE
For native applications, PKCE should be properly implemented first.
Authorization Request
code_challenge=S256(...)
code_challenge_method=S256
And for token exchange,
authorization_code
code_verifier
require both.
This ensures that even if another app obtains the authorization code, it cannot be used.
Intercepted Code
|
v
Missing Verifier
|
v
Rejected
PKCE is not a feature that should be treated as optional in mobile OAuth. RFC 8252 requires public native clients to implement PKCE.
Second Change — Verified App Links
Whenever possible, use verified HTTPS App Links instead of custom schemes.
For example,
https://auth.example.com/mobile/callback
.
Enable domain verification in the Android Manifest.
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="auth.example.com"
android:pathPrefix="/mobile/callback" />
</intent-filter>
Then, the web server verifies the relationship between the app and the domain.
https://auth.example.com/.well-known/assetlinks.json
Since Android App Links use Digital Asset Links to verify the relationship between a website and an app, they are more resistant to link hijacking than standard custom schemes.
Third Modification — Verify the Redirect URI Accurately
This kind of lax verification by the Authorization Server is risky.
*.example.com
Alternatively,
StartsWith()
Contains()
method.
If possible, it is best to ensure an exact match with the pre-registered callback.
RFC 8252 also requires that the redirect URI for a native app client be registered, including the path, and that redirect URIs that do not exactly match be rejected in general cases.
This is a common pattern in security.
Flexible Validation
|
v
Unexpected Trust
A single wildcard created for operational convenience can break the authentication boundary.
In the retest, we repeat the attack exactly as before
We received the patched APK.
The development team explained it as follows:
"We’ve implemented PKCE and switched to App Links."
I didn’t just take their word for it and close the case.
I opened the APK again.
jadx-gui patched.apk
I checked the manifest.
https
android:autoVerify=true
Expected Host
Expected Path
I also verify it on an actual device.
adb shell pm get-app-links com.example.app
Test the link routing as well.
adb shell am start \
-a android.intent.action.VIEW \
-d "https://auth.example.com/mobile/callback?code=TEST"
Then, capture the OAuth request again.
This time, it’s visible.
code_challenge
code_challenge_method=S256
Finally, I try to reproduce the same attack flow in the Receiver app I used earlier.
It fails.
Deep Link Interception: Failed
Code Redemption: Failed
Unauthorized Session: Not Obtained
Only then do I mark the retest as PASS.
In mobile white-hat hacking, the most important thing isn’t just being able to dissect an APK well
In the beginning, it’s easy to think of mobile security this way.
APK
|
+-- Hardcoded Secrets
+-- Root Detection
+-- SSL Pinning
+-- WebView
+-- Exported Components
Of course, we look at everything.
But as time goes on, I’ve come to believe that what’s even more important is the boundary where the app interfaces with other systems.
App <-> Android OS
App <-> Browser
App <-> OAuth
App <-> Backend
App <-> Other Apps
Bugs often arise not within individual systems, but at the interfaces between systems.
That was the case here as well.
Android passed the URI according to the specification.
The OAuth server issued an authorization code according to the specification.
The app handled the callback according to the specification.
When viewed separately, everything appeared to be working normally.
But when put together, things changed.
Android trusts URI registration.
OAuth trusts redirect handling.
The app trusts the callback.
No component owns the entire trust chain.
That’s why one of the questions I ask most often when conducting mobile penetration testing is ultimately this:
“Whose hands did this data pass through right up until it arrived here?”
When I trace it back,
a single `intent-filter` in the APK that seemed insignificant
can suddenly turn out to be the starting point for an account takeover.