☰
jena.run
/
♡

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.

끝까지 읽었네… 메밀 승인 🐾
+