Android 中 3D Secure 支付的 WebView 安全问题
结论
启用 JavaScript 不代表 WebView 一定不安全。风险通常来自加载不可信内容、错误放行 TLS 证书、暴露 JavaScript Bridge、允许任意跳转,或由应用自行处理和保存银行卡数据。
应优先使用支付服务商提供的官方 Android SDK 和 3D Secure 2(3DS2)流程。如果服务商明确要求使用 WebView,应将其限制为本次支付专用,并且只能访问指定的 HTTPS 域名。银行卡号、CVV 等信息应由通过 PCI DSS 合规验证的支付组件直接收集并令牌化,不能进入应用自己的 WebView、日志或服务器。
推荐的支付架构
较安全的支付流程如下:
- 应用后端向支付服务商创建支付或认证会话。
- 后端只向应用返回一次性会话 URL、transaction ID 或 client secret。
- 应用通过官方 SDK、Custom Tabs 或受限 WebView 打开认证页面。
- 用户在银行 ACS 页面完成验证后,页面跳转到预先登记的 HTTPS App Link 或回调地址。
- 应用后端根据服务商的服务器回调或查询接口确认最终结果。
不能仅凭 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 中“更安全地处理银行卡号”。
备注:内容仅供参考。
