SSL 固定(证书固定)是一种安全技术,使移动应用验证服务器的 SSL 证书是否与预先嵌入的值匹配,而不是信任系统中的任何 CA。这有效地防止了中间人(MITM)攻击。
SSL 固定如何工作
通常,当应用连接到服务器时,OS 检查证书是否由受信任的 CA 颁发。如果攻击者拥有来自合法 CA 的欺诈证书,他们可以拦截流量。
SSL 固定通过让应用将服务器的证书或公钥与硬编码值进行比较来解决这个问题。如果它们不匹配,连接会立即被拒绝。
SSL 固定的类型
1. 证书固定
在应用中嵌入完整证书(DER 格式)。更新证书时需要应用更新。最适合证书很少更改的应用。
2. 公钥固定(推荐)
仅嵌入公钥哈希。只要保留相同的私钥,就可以在不更新应用的情况下更新证书。
3. CA 固定
固定 CA 的公钥 — 更灵活但安全性较低,因为 CA 可能会向其他人颁发证书。
优缺点
- 优点:即使攻击者拥有有效的 CA 证书,也能防止中间人攻击
- 优点:阻止 Burp Suite 和 Charles 等代理工具拦截流量
- 缺点:证书固定每次更新证书时都需要应用更新
- 缺点:使调试和渗透测试变得更加困难
- 缺点:可以被拥有设备根访问权限的攻击者绕过
iOS 实现(Swift)
// URLSessionDelegate
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let pinnedCertData = // Load cert from app bundle
let serverCertData = SecCertificateCopyData(certificate) as Data
if serverCertData == pinnedCertData {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Android 实现(OkHttp)
val certificatePinner = CertificatePinner.Builder()
.add("api.yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
最佳实践:使用公钥固定而不是证书固定,以避免在续期时应用更新。始终固定至少 2 个密钥(1 个主密钥 + 1 个备用密钥)以防止锁定。
如何提取用于固定的公钥哈希
在实现 SSL 固定之前,需要从要固定的证书中提取公钥哈希。最可靠的方法使用 OpenSSL,在 macOS 和 Linux 上可用。针对您的实时 API 端点或本地证书文件运行以下命令:
# Extract hash directly from a live server
openssl s_client -connect api.yourdomain.com:443 -servername api.yourdomain.com 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64
# Extract hash from a local .pem file
openssl x509 -in certificate.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64
生成的 Base64 字符串是要嵌入应用的固定值。始终在需要之前提取并存储当前证书和至少一个备用证书的哈希。这可以防止在意外需要轮换证书时发生紧急锁定。
使用代理工具测试 SSL 固定
实现 SSL 固定后,验证它在真实条件下是否正常工作至关重要。安全工程师和 QA 团队通常使用代理工具来确认固定按预期阻止拦截。下表总结了常见的测试场景和预期结果:
| 场景 | 预期结果 | 风险等级 |
|---|---|---|
| 标准(非 root)设备上的 Charles 代理 | 连接立即被拒绝 | 低 |
| root/越狱设备上的 Frida | 如果实现不够强化,可能会被绕过 | 中–高 |
| 具有恶意证书颁发机构的网络 | 由于哈希不匹配,连接被拒绝 | 非常低 |
Burp Suite 社区版也可用于相同的拦截测试。在所有情况下,当证书与嵌入的固定不匹配时,正确固定的应用应显示网络错误,而不是成功的服务器响应。
证书轮换和防止用户锁定
SSL 固定最常见的操作风险是证书轮换。如果证书过期或被替换,但未更新应用的嵌入固定,则旧应用版本的每个用户都会立即失去连接。仔细规划轮换与实现本身一样重要。
安全轮换的关键策略
- 预先嵌入备用固定:在续期当前固定之前,将下一个证书的公钥哈希添加到应用中,为自己提供安全的滚动窗口。
- 设置 60 天更新提醒:证书过期是可以预测的 — 自动化警报,以便您的团队有时间在旧固定变为无效之前发布应用更新。
- 使用 Android 过期属性:Android 网络安全配置中的
expiration字段在指定日期后禁用严格固定,作为安全回退,防止永久锁定。 - 动态固定(高级):某些团队在运行时从已签名的 API 端点获取固定值,允许无需完整应用发布即可更新固定。这需要仔细签名和签名验证,以避免创建新的攻击面。
Android 网络安全配置用于固定
Android 7.0(Nougat)及更高版本通过 XML 配置文件支持声明式固定方法,这比编写自定义 TrustManager 代码不容易出错。系统会自动处理固定匹配,并且错误配置更难悄悄引入。
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.yourdomain.com</domain>
<pin-set expiration="2027-01-01">
<!-- 主要固定 -->
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- 备用固定 -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
在您的 AndroidManifest.xml 中引用此文件,方法是将 android:networkSecurityConfig="@xml/network_security_config" 添加到应用程序元素。expiration 日期充当安全阀:一旦该日期过去,OS 将停止强制固定并回退到标准 CA 验证,这样即使您的团队错过了更新周期,用户也不会被永久锁定。