Skip to content

移动 WebView 桥接、协议与证书安全加固 ​

防御目标与威胁面 ​

移动应用 WebView 常见的系统性失陷链包括:

  1. JS Bridge 过度暴露:通过 addJavascriptInterface 向不可信页面暴露文件读取或返回 Context/SystemService 的方法(参考 CVE-2022-36916 链),或 iOS WKScriptMessageHandler 未校验 message.frameInfo.securityOrigin。
  2. Deeplink 参数直通 loadUrl:外部 intent:///myapp://open?url=... 未做协议与域名白名单校验即传入 webView.loadUrl(url),引发 javascript: XSS、file:// 读私有目录或钓鱼跳转。
  3. 文件协议与本地访问开启:setAllowFileAccess(true) 或 setAllowFileAccessFromFileURLs(true) 允许 file:// 页面跨域读取 /data/data/<pkg>/shared_prefs/ 与 databases/。
  4. SSL 校验静默放行:WebViewClient.onReceivedSslError 调用 handler.proceed()(哪怕仅包裹在误判的调试条件中)导致中间人劫持与 JS 注入。
  5. Cookie 与进程池污染:多个域或业务共享敏感会话 Cookie 或 iOS WKProcessPool 未隔离。

核心防御矩阵 ​

攻击向量根治控制措施关键实现约束
addJavascriptInterface 反射链 / 任意域调用改用 WebViewCompat.addWebMessageListener 并严格限定 allowedOriginRules禁止暴露返回 Context/文件流/通用命令的方法;minSdkVersion >= 17 且绝不配置 "*" 源
iOS WKScriptMessageHandler 跨域 iframe 触发在 userContentController 校验 message.frameInfo.isMainFrame 与 securityOrigin.host仅放行精确 HTTPS 白名单主源,不共享跨信任域 WKProcessPool
Deeplink 注入 javascript: / file: / 外部域统一使用 Uri 解析强制 scheme == "https" 且 host 命中精确白名单在 loadUrl 前和 shouldOverrideUrlLoading 双重校验,拒绝 intent:/data:/content:
file:// 与 content:// 私有文件读取显式关闭 allowFileAccess、allowContentAccess 及 allowFileAccessFromFileURLs本地静态资源统一迁移至 WebViewAssetLoader 通过 https://appassets.androidplatform.net/ 加载
onReceivedSslError 放行中间人证书强制重写 onReceivedSslError 仅调用 handler.cancel()禁止保留 handler.proceed(),并配合 network_security_config.xml 约束信任锚点

加固代码与静态核查示例 ​

1. Android Kotlin 加固 WebView 初始化与严格 Origin 校验 ​

kotlin
// HardenedWebViewFactory.kt — 关闭文件访问、强制 HTTPS 白名单与 Origin 绑定通信
package com.example.security

import android.net.Uri
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature

object HardenedWebViewFactory {
    private val ALLOWED_HOSTS = setOf("app.example.com", "appassets.androidplatform.net")

    fun isTrustedHttpsUri(rawUrl: String?): Boolean {
        if (rawUrl.isNullOrBlank()) return false
        val uri = runCatching { Uri.parse(rawUrl) }.getOrNull() ?: return false
        return uri.scheme == "https" && uri.host in ALLOWED_HOSTS
    }

    fun configure(webView: WebView) {
        val settings = webView.settings
        settings.javaScriptEnabled = true
        settings.allowFileAccess = false
        settings.allowContentAccess = false
        @Suppress("DEPRECATION")
        settings.allowFileAccessFromFileURLs = false
        @Suppress("DEPRECATION")
        settings.allowUniversalAccessFromFileURLs = false
        settings.setSupportMultipleWindows(false)

        val assetLoader = WebViewAssetLoader.Builder()
            .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(webView.context))
            .build()

        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                // 非白名单域名一律阻断,不交由 WebView 加载
                return !isTrustedHttpsUri(request.url.toString())
            }

            override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
                return assetLoader.shouldInterceptRequest(request.url)
            }

            override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
                handler.cancel()
            }
        }

        if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
            WebViewCompat.addWebMessageListener(
                webView,
                "secureBridge",
                setOf("https://app.example.com")
            ) { _, message, sourceOrigin, isMainFrame, _ ->
                if (!isMainFrame || sourceOrigin.toString() != "https://app.example.com") {
                    return@addWebMessageListener
                }
                // 仅处理严格 JSON schema 指令
                handleValidatedBridgeMessage(message.data)
            }
        }
    }

    private fun handleValidatedBridgeMessage(data: String?) {
        if (data.isNullOrEmpty()) return
        // 按最小权限业务白名单分发
    }
}

2. Python 静态扫描 Android WebView 危险配置反模式 ​

python
import re
from typing import Dict, List


WEBVIEW_ANTIPATTERNS: Dict[str, re.Pattern[str]] = {
    "ssl_proceed": re.compile(r"onReceivedSslError[\s\S]{0,200}?handler\.proceed\s*\("),
    "file_access_enabled": re.compile(r"setAllowFileAccess\s*\(\s*true\s*\)"),
    "universal_file_access": re.compile(r"setAllow(?:File|Universal)AccessFromFileURLs\s*\(\s*true\s*\)"),
    "legacy_js_interface": re.compile(r"addJavascriptInterface\s*\("),
    "raw_intent_loadurl": re.compile(r"getQueryParameter\([^)]+\)[\s\S]{0,120}?loadUrl\s*\("),
}


def scan_webview_source(source_code: str) -> List[str]:
    return [name for name, pat in WEBVIEW_ANTIPATTERNS.items() if pat.search(source_code)]


if __name__ == "__main__":
    vuln_snippet = """
    webView.getSettings().setAllowFileAccess(true);
    webView.addJavascriptInterface(new FileAccessor(), "bridge");
    String url = getIntent().getData().getQueryParameter("url");
    webView.loadUrl(url);
    public void onReceivedSslError(WebView v, SslErrorHandler handler, SslError e) {
        handler.proceed();
    }
    """
    hits = scan_webview_source(vuln_snippet)
    assert set(hits) == {"ssl_proceed", "file_access_enabled", "legacy_js_interface", "raw_intent_loadurl"}

    safe_snippet = """
    settings.allowFileAccess = false
    override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
        handler.cancel()
    }
    """
    assert scan_webview_source(safe_snippet) == []
    print("WebView defense pattern check passed:", hits)

防御工作流 ​

text
1. 清理文件协议:全量检索关闭 setAllowFileAccess/setAllowContentAccess,改用 WebViewAssetLoader 托管本地资源
2. 升级桥接机制:废弃任意域可调用的 addJavascriptInterface,迁移至限制 origin 与主帧的 WebMessageListener
3. 严控外部入口:对所有 Intent/Deeplink 参数先解析 Uri,强制仅接受白名单 HTTPS 域名,屏蔽 javascript:/file:/intent:
4. 消除 SSL 绕过:全库清理 handler.proceed(),统一执行 handler.cancel() 并搭配系统 Network Security Config
5. 隔离会话凭证:Cookie 启用 HttpOnly+Secure+SameSite,不跨业务线共享敏感态 WebView 容器或 WKProcessPool

验证与判定标准 ​

  • Deeplink 协议拦截测试:使用 adb shell am start 传入 ?url=javascript:alert(1)、?url=file:///data/data/... 及外部恶意域名时,WebView 必须拒绝加载且不得触发任何 JS 执行。
  • 桥接域隔离测试:在测试页面嵌入第三方域 iframe 尝试调用 secureBridge 时,消息监听器因 isMainFrame == false 或 sourceOrigin 不匹配必须静默丢弃。
  • 证书错误阻断测试:将代理自签名证书指向 WebView 请求时,必须触发 handler.cancel() 中止连接且不渲染代理注入内容。

MCP 工具映射 ​

防御步骤MCP 工具说明
检查应用 Manifest 与导出组件android_app_baseline识别接收 Deeplink 的导出 Activity 与应用包配置
验证 HTTPS 与证书错误行为android_http_observation_recipe核对代理场景下 WebView 是否严格拒绝异常证书
检索对应红蓝文档kb_router对照 01-webview-attacks.md 覆盖全部桥接与协议攻击面

Apache-2.0 · For authorized learning and defensive research only · Disclaimer