PAYATHON 2026

Android 中 3D Secure 支付的 WebView 安全问题

支付老李

结论

启用 JavaScript 不代表 WebView 一定不安全。风险通常来自加载不可信内容、错误放行 TLS 证书、暴露 JavaScript Bridge、允许任意跳转,或由应用自行处理和保存银行卡数据。

应优先使用支付服务商提供的官方 Android SDK 和 3D Secure 2(3DS2)流程。如果服务商明确要求使用 WebView,应将其限制为本次支付专用,并且只能访问指定的 HTTPS 域名。银行卡号、CVV 等信息应由通过 PCI DSS 合规验证的支付组件直接收集并令牌化,不能进入应用自己的 WebView、日志或服务器。

推荐的支付架构

较安全的支付流程如下:

  1. 应用后端向支付服务商创建支付或认证会话。
  2. 后端只向应用返回一次性会话 URL、transaction ID 或 client secret。
  3. 应用通过官方 SDK、Custom Tabs 或受限 WebView 打开认证页面。
  4. 用户在银行 ACS 页面完成验证后,页面跳转到预先登记的 HTTPS App Link 或回调地址。
  5. 应用后端根据服务商的服务器回调或查询接口确认最终结果。

不能仅凭 WebView 跳转到了“成功页面”就认定支付成功。客户端回调可能被伪造,最终状态必须由后端向支付服务商核实。

使用 3DS2 时,服务商的认证 SDK 通常会处理设备信息采集和 challenge UI,应用未必需要自行创建通用 WebView。具体支持哪些能力,取决于支付服务商的集成方案。

WebView 的最低安全配置

下面是一个基础示例。实际使用时,必须采用支付服务商提供的完整域名清单,不能直接照搬示例值。

class ThreeDSActivity : AppCompatActivity() {

    private lateinit var webView: WebView

    private val allowedHosts = setOf(
        "payments.example.com",
        "acs.example-bank.com"
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)

        webView = WebView(this)
        setContentView(webView)

        WebView.setWebContentsDebuggingEnabled(false)

        webView.settings.apply {
            javaScriptEnabled = true

            allowFileAccess = false
            allowContentAccess = false
            mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW

            javaScriptCanOpenWindowsAutomatically = false
            setSupportMultipleWindows(false)

            builtInZoomControls = false
            displayZoomControls = false

            // 是否需要 DOM Storage 应按支付服务商文档决定。
            domStorageEnabled = true
        }

        CookieManager.getInstance().setAcceptThirdPartyCookies(webView, false)

        webView.webChromeClient = WebChromeClient()

        webView.webViewClient = object : WebViewClient() {

            override fun shouldOverrideUrlLoading(
                view: WebView,
                request: WebResourceRequest
            ): Boolean {
                val uri = request.url

                if (isAllowedHttpsUrl(uri)) {
                    return false
                }

                if (isVerifiedReturnUrl(uri)) {
                    handleReturnUrl(uri)
                    finish()
                    return true
                }

                // 拒绝 WebView 内的未知跳转。
                return true
            }

            override fun onReceivedSslError(
                view: WebView,
                handler: SslErrorHandler,
                error: SslError
            ) {
                // 任何证书错误都必须终止,不能调用 handler.proceed()。
                handler.cancel()
                showPaymentError()
            }
        }

        val authenticationUrl =
            intent.getStringExtra("authentication_url")
                ?: return finish()

        val uri = Uri.parse(authenticationUrl)
        if (!isAllowedHttpsUrl(uri)) {
            showPaymentError()
            finish()
            return
        }

        webView.loadUrl(authenticationUrl)
    }

    private fun isAllowedHttpsUrl(uri: Uri): Boolean {
        if (uri.scheme != "https") return false

        val host = uri.host?.lowercase(Locale.US) ?: return false
        return host in allowedHosts
    }

    private fun isVerifiedReturnUrl(uri: Uri): Boolean {
        return uri.scheme == "https" &&
            uri.host == "pay.example.com" &&
            uri.path == "/3ds/return"
    }

    private fun handleReturnUrl(uri: Uri) {
        val transactionId = uri.getQueryParameter("transaction_id") ?: return

        // 这里只通知后端查询交易结果。
        // 不要直接相信 success=true 等客户端参数。
        verifyPaymentWithBackend(transactionId)
    }

    override fun onDestroy() {
        webView.apply {
            stopLoading()
            loadUrl("about:blank")
            clearHistory()
            removeAllViews()
            destroy()
        }

        super.onDestroy()
    }
}

校验域名时,必须比较解析后的 Uri.host,不能只判断字符串前缀。例如,下面的写法并不安全:

url.startsWith("https://example.com")

攻击者可以构造 https://example.com.attacker.invalid/。如果确实需要允许子域名,应校验域名边界:

private fun isHostOrSubdomain(host: String, domain: String): Boolean {
    return host == domain || host.endsWith(".$domain")
}

只有在支付服务商确认所有子域名均可信时,才能使用这种规则。银行 ACS 域名可能动态变化,遇到这种情况,应向服务商索取正式的跳转规则,不能自行猜测或使用范围过宽的通配符。

禁止绕过 TLS 错误

HTTPS 和正确的证书验证是防范中间人攻击的主要手段。为了“兼容部分银行页面”而忽略证书问题,是非常严重的安全错误:

override fun onReceivedSslError(
    view: WebView,
    handler: SslErrorHandler,
    error: SslError
) {
    handler.proceed() // 不安全
}

遇到证书错误时,必须调用 handler.cancel() 并终止支付。无论是证书过期、域名不匹配,还是证书链异常,应用都不能静默放行。

应用还应禁止明文流量:

<application
    android:usesCleartextTraffic="false"
    android:networkSecurityConfig="@xml/network_security_config">
</application>
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>

不建议自行对大量银行 ACS 域名实施证书锁定。3D Secure 流程可能跨越多个由不同机构管理的域名,证书也会正常轮换。如果锁定策略不完整,很容易导致正常支付失败。支付服务商若提供了明确、稳定且受支持的 pinning 方案,应严格按照其文档实施。

不要暴露 JavaScript Bridge

除非支付服务商的官方方案明确要求,否则不要调用:

webView.addJavascriptInterface(...)

addJavascriptInterface() 会将原生对象暴露给页面脚本。一旦 WebView 加载了遭到注入的页面,或意外跳转到其他页面,攻击面就会明显扩大。

也不要通过 JavaScript 向页面注入银行卡号、CVV、账户密码或访问令牌:

webView.evaluateJavascript(
    "fillCard('$cardNumber', '$cvv')",
    null
)

敏感数据进入页面的 JavaScript 上下文后,第三方脚本、日志、分析代码或遭篡改的页面都可能读取这些数据。

限制 WebView 能力

除 JavaScript 外,其他能力都应默认关闭。只有服务商明确要求时,才逐项开放:

  • 禁止访问 file:// 和 content://。
  • 禁止加载 HTTP 混合内容。
  • 不提供文件选择器、摄像头、麦克风和地理位置权限。
  • 不允许任意弹出新窗口。
  • 不加载由应用拼接的 HTML,也不使用 loadDataWithBaseURL() 注入支付页面。
  • 不允许用户在支付 WebView 中自由浏览或输入任意网址。
  • 不安装自定义 JavaScript,也不加载第三方广告、统计或客服脚本。
  • 在发布版本中关闭 WebView 调试。
  • 及时更新 Android System WebView 和 Chrome。

部分银行认证流程可能依赖 Cookie、第三方 Cookie、DOM Storage 或新窗口。如果关闭这些能力后认证失败,应先请支付服务商确认所需配置,再开放必要的选项。不能为了兼容性一次解除所有限制。

防止敏感数据泄露

支付页面之外,还应采取以下措施:

  • 使用支付服务商的托管输入页或原生令牌化组件,尽量避免让应用接触 PAN 和 CVV。
  • 不在日志中记录银行卡号、CVV、账户密码、认证结果或完整支付 URL。
  • 不把敏感参数放入 URL 查询字符串,因为 URL 可能出现在历史记录、代理日志或崩溃报告中。
  • 不将支付页面内容保存到 WebView 缓存、截图、剪贴板或持久化存储。
  • 使用 FLAG_SECURE 阻止普通截图和最近任务预览,但不能把它当作对已 root 设备的完整防护。
  • 在回调中使用不可预测的 state、nonce 或交易标识,并由后端校验,以防止回调替换和重放。
  • 为支付会话设置较短的有效期,并确保每个会话只能使用一次。
  • 认证完成后销毁专用 WebView,不与普通网页浏览功能共用同一个实例。
  • 对外部 App Link 启用 Android 域名验证,并由后端再次确认交易状态。

是否在流程结束后清除 Cookie,需要结合产品设计判断。全局调用 CookieManager.removeAllCookies() 会影响应用中的其他 WebView 会话。更稳妥的做法是使用独立支付进程、服务商 SDK,或只清理由本次支付流程创建的数据。

不能只靠 WebView 配置解决的问题

如果设备已经被 root、植入恶意系统证书,或受到动态注入框架、恶意辅助功能的完全控制,仅靠 WebView 加固无法提供保护。应用完整性检测可以作为风险信号,但不能取代 TLS、服务器端验证和支付服务商的风控。

银行卡数据处理通常还涉及 PCI DSS 等合规要求。应用是否接触、传输或保存持卡人数据,会影响具体的责任范围。降低风险最有效的方式,是使用合规支付服务商的托管页面或 SDK,让应用从一开始就不接触这些数据,而不是尝试在 WebView 中“更安全地处理银行卡号”。

备注:内容仅供参考。