我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

关于 WebView,每位移动安全从业者都应了解的 5 件事

本文介绍 WebViews,以及在 Android 和 iOS 中使用这些组件时需要牢记的安全概念。

WebView 是移动应用的重要组件,它使 Android 和 iOS 应用能够在移动应用内部渲染 Web 内容并执行 Javascript 代码。 WebView 的设计改变了 Web 的格局,需要格外注意,以避免在应用中留下安全漏洞。

通过 5 条简洁实用的建议,您将了解 WebViews 的用法,以及需要检查的常见安全问题。 Ostorlab 已经通过静态分析和动态分析自动检查所有这些问题。

告诉我您的系统版本,我就能告诉您用的是什么 WebView?

WebView 在 iOS 1 和 Android 2 中引入,并经历了重要的变化,以优化性能、增强安全性。

以下是 WebView 设计中最重要的几个里程碑:

  • iOS:

    • UIWebview 自 iOS 1 起提供,并在 iOS 8 中被弃用。它存在许多安全问题:
      • 您无法禁用 Javascript
      • 您无法禁用文件访问
      • 您无法为文件访问实施同源策略
      • 原生应用可以访问所有请求/响应,这对于敏感数据和外部身份验证而言并不理想
      • 渲染的内容与原生应用共享同一个进程
    • WKWebView 自 iOS 8 起提供,带来了多项性能和安全方面的改进:
      • 您可以禁用 Javascript
      • 您可以禁用文件访问
      • 您可以为文件访问实施同源策略
      • 原生应用可以访问所有请求/响应,这对于敏感数据和外部身份验证而言并不理想
      • 渲染的内容与原生应用运行在不同的进程中
    • SFSafariViewController 自 iOS 9 起提供,提供类似浏览器的体验;它主要用于应用只需显示 Web 内容的场景。
      • 您可以禁用 Javascript
      • 您可以禁用文件访问
      • 您可以为文件访问实施同源策略
      • 原生应用无法访问所有请求/响应,但您可以使用不同的实现方式来共享 Cookie 和存储的数据
      • 渲染的内容与原生应用运行在不同的进程中。
  • Android:

    • 2.x 到 3 上的 WebKit:
      • 您可以禁用 Javascript
      • 您无法禁用文件访问
      • 您无法为文件访问实施同源策略
      • 渲染的内容与原生应用共享同一个进程
    • 3 上的 WebKit:
      • 您可以禁用 Javascript
      • 您可以禁用文件访问
      • 您无法为文件访问实施同源策略
      • 渲染的内容与原生应用共享同一个进程
    • 4.4 上的 Chromium 30:
      • Webview 在 UI 线程中运行
      • 您可以禁用 Javascript
      • 您可以禁用文件访问
      • 您可以为文件访问实施同源策略
      • 渲染的内容与原生应用共享同一个进程
    • 5.0 上的 Chromium M37:
      • 引入了 PermissionRequest 类,用于授予 WebView 访问摄像头和麦克风等受保护资源的权限。
      • 支持通过 Google Play Store 更新 Chromium
    • 从 Android 7.0 到 Android:
      • 地理位置 API 只允许在安全来源(通过 HTTPS)上使用。
      • WebView 在一个独立的沙箱进程中运行 Web 内容
      • 新增 Safe Browsing API,当 WebView 尝试访问被 Google 归类为已知威胁的 URL 时发出通知。

在生产环境中启用 WebView 内容调试安全吗?

简短的回答是:不安全!

在本节中,我们将看到,如果启用了 WebContentsDebugging,恶意应用如何访问 WebView 中渲染的数据。

为了说明应用是如何启用 WebViews 内容调试的,我将使用 Ostorlab 的分析环境,搜索函数 setWebContentsDebuggingEnabled。

替代文本
setWebContentsDebuggingEnabled

为了查看调用树以及传递给该函数的参数,我切换到调用树选项卡:

替代文本
setWebContentsDebuggingEnabled 调用栈

可以看到,initWebView 调用了 setWebContentsDebuggingEnabled:

替代文本
init webview 调用栈

跳转到源代码,可以看到该参数是从方法 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;
    }

查看 isWebContentsDebuggingEnabled 的定义,可以看到它使用了 capacitor.config.json,并且该参数被设置为 true。

替代文本
debug webview 调用栈

下一步,我们在手机上安装并运行该应用。

WebView 调试使用 Chrome Debug Protocol,并通过一个抽象命名的 Unix 套接字对外暴露。该套接字的名称为 webview_devtools_remote 或 webview_devtools_remote_<pid>。

抽象套接字不使用文件系统权限来控制访问,因此设备上的所有应用 都可以访问它们。

我们可以使用 netstat 找到暴露的套接字:

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

现在,要利用该问题并读取套接字的内容,只需在手机上运行以下命令:

socat TCP-LISTEN:9999,fork ABSTRACT:webview_devtools_remote_2466

请注意,Java 没有用于访问抽象套接字的 API,执行此类攻击的攻击者很可能会 使用原生代码。

要访问该远程协议,可以使用 Chrome Debug Protocol 客户端,例如 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()

我们可以使用 DOM 对象检查页面的全部内容:

>>> 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': ''}}

从 Java 代码到 Javascript 的安全之旅:

可以使用 addJavascriptInterface 方法将 Java 对象注入 WebView 并暴露给 JavaScript。下面是一个简单的示例,说明如何实现:

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!"; 
    } 
}

在本例中,可以使用以下代码从 JavaScript 调用 helloSafeWorld() 方法:

var HelloWorld = window.safeBridge.helloSafeWorld();

一旦通过 addJavascriptInterface 向 WebView 注册了接口,该接口就成为全局可用的。加载到 WebView 中的所有页面都可以调用该接口,并访问该接口维护的同一份数据。这使得 来自某一来源的网页能够影响其他来源的网页

从 API 版本 17 开始,只有带有 @JavascriptInterface 注解的方法才能被 JavaScript 代码访问。 在 API 版本 17 之前,可以利用反射在设备上执行任意代码(参见 CVE-2012-6636)。

我们来看一个来自 com.microsoft.skydrive 应用的真实示例。首先,我们搜索函数 addJavascriptInterface

替代文本
添加 Java 接口

在调用栈选项卡中,可以看到有多个接口被暴露。

替代文本
添加 Java 接口的调用栈

我们将重点关注 com.microsoft.skydrive.reportabuse 包。

替代文本
com.microsoft.skydrive.reportabuse

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;
    }

该接口暴露了以下方法:

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();
}

实现这些方法时的一个关键点是验证每一项输入,避免创建可能被攻击者 用来获取或修改敏感数据的通用行为。

在下面的 reportClicked 实现中,以 null 值调用该函数会在调用 valueOf 时触发错误,从而导致意外行为。

 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);
            }
...

试试 iOS,它可能更安全!

在 iOS 7 之前,在 iOS 上实现原生桥接比在 Android 上稍微复杂一些:没有为此目的明确定义的 API 方法。

过去常见的做法是重载 URL 加载系统,以便将任意消息从 JavaScript 传递给原生 UIWebView 中的回调。

每当在 WebView 中加载 URL 时,都会调用 shouldStartLoadWithRequest 委托方法,该方法会拦截完整的 URL,包括所有参数。 通常利用 URL 的格式将消息从 JavaScript 传递给原生容器。

例如,可以使用以下代码在员工列表中查找某个名字:

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

随后,原生容器使用类似下面的代码实现 WebView 的 shouldStartLoadWithRequest 委托:

- (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 
    } 
}

shouldStartLoadWithRequest 方法通常会读取 URL,然后拆分并解析 URL 的各个组成部分,以确定应当执行哪些操作。

然而,URL 加载技术只能提供从 Web 层到原生容器的单向桥接。 可以使用 JavaScript 回调和 UIWebview 类的 stringByEvaluatingJavaScriptFromString 方法来创建双向通信通道。

例如,要从原生容器执行一个 JavaScript 方法,您可能会看到类似下面的代码:

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

这个简单的示例会执行 JavaScript 函数 addEmployee(),并将 NSString 对象 “firstname”和“job”传递给 JavaScript。与 shouldStartLoadWithRequest 结合使用时,这种技术 能够在原生层与 Web 层之间提供一个基本的桥接。

使用自定义 URI scheme 时需要格外小心,因为攻击者可以(通过电子邮件、聊天 或短信)分享恶意链接,恶意 JavaScript 或 HTML 代码将调用移动端原生功能并利用其数据。

注意:UIWebView 的 JavaScript 执行将总内存分配限制为 10MB,运行时间限制为 10 秒,一旦达到限制, 执行将被立即且无条件地终止。

为了克服 UIWebView 的限制,iOS 7 随附了 JavaScriptCore 框架,它全面支持 在原生 Objective-C 与 JavaScript 运行时之间进行桥接通信。该桥接通过新的 JSContext 全局对象创建,该对象提供对 JavaScript 虚拟机的访问以执行代码。Objective-C 运行时还可以通过 JSValue 对象获取对 JavaScript 值的强引用。

JSExport 协议允许应用将整个 Objective-C 类和实例暴露给 JavaScript,并像操作 JavaScript 对象一样操作它们。 在继承 JSExport 的协议中定义变量和方法,即向 JavaScriptCore1 表明这些元素可以从 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
}

在上面的示例中,JSExport 协议声明允许 Javascript 访问变量 model 和 year 以及函数 createWith。
既然 JavaScriptCore 已经知道 CarJSExports 协议,当您将其实例添加到 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);
}

采用这种实现后,向 JavaScriptCore 暴露对象变得非常容易,因此开发人员应确保 只暴露必要的数据,而不要照搬 JSExport 定义和初始数据模型。 例如,如果函数 fullDetail 可能包含敏感数据,就不应在 CarJSExports 协议中声明它,而应只在 Car 类定义中声明。

我能再多读一个文件吗?

在大多数 SDK 和 WebViews 组件中,默认允许从文件系统加载文件。当恶意应用 能够在另一个应用的 WebView 中打开本地文件时,这就会带来风险。这会使暴露的 WebView 面临大量漏洞利用技术,从滥用无障碍设置到绕过同源策略。

在 Android 上,您可以按如下方式禁止 WebView 访问文件系统:

webview.getSettings().setAllowFileAccess(false); 

这并不能阻止 WebView 使用 file:///android_res 和 file:///android_asset 加载应用 res 或 assets 文件夹中的文件。为了锁定 WebView,您不应允许从文件系统加载的 文件访问其他文件。这将限制所加载的页面窃取 私有文件。

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

此外,您可以使用以下设置,防止 WebView 访问设备上的 Content Provider:

webview.getSettings().setAllowContentAccess(false);

下面是一个存在漏洞的示例:攻击者利用恶意链接从应用目录中读取敏感数据。 该应用将明文密码写入 shared preferences:

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

并且,该应用使用 Webview 显示来自 Intent 或用户输入的 URL。

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);

在这种情况下,JavaScript 和 AllowFileAccessFromFileURLs 均已启用。恶意文件 test.html 会读取 shared preferences 文件 MainActivity.xml,并能够将其窃取出去。

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          
};

在应用内访问该 URL 后,可以看到恶意代码被执行,并且文件内容可以被访问:

替代文本
allow_file_access

总结

Webview 是移动应用的重要组件。它为访问外部资源并与之交互提供了很大的灵活性, 但同时也带来了诸多安全挑战。

总而言之,请确保使用最新版本的 SDK,避免使用已弃用的组件,并在启用 JavaScript 代码或实现原生与 JavaScript 之间的桥接时保持谨慎。功能应限制在所需的最小范围内。

希望本文对您有所帮助,欢迎在 Ostorlab 上测试您的应用。

标签:

android, ios, WebView