4. 客户端加固:证书固定、证书验证逻辑、安全传输层配置、回退攻击防护
客户端加固,说白了就是让 App 或浏览器在建立 TLS 连接时,不再盲目信任系统根证书库。系统根证书库里可能有几百个 CA,只要任何一个被攻破,攻击者就能签发假证书来中间人你。我见过太多团队只盯着服务端配 HTTPS,客户端却还是“裸奔”状态——嗯,这其实是最容易被忽略的短板。
4.1 证书固定(Certificate Pinning)
证书固定,就是把服务端证书或公钥的哈希值硬编码到客户端。每次握手时,客户端只认这个哈希值,其他证书一概拒绝。我个人习惯把这种机制叫做“白名单模式”——不在名单里的,直接拉黑。
4.1.1 固定方式对比
| 方式 | 固定对象 | 优点 | 缺点 |
|---|---|---|---|
| 证书固定 | 整个证书(含公钥+元数据) | 实现简单,直接比对证书 | 证书过期或更换时必须更新客户端 |
| 公钥固定 | 证书中的公钥(SPKI) | 证书续签时公钥不变,无需更新客户端 | 实现稍复杂,需要提取公钥哈希 |
| 哈希固定 | 证书或公钥的 SHA-256 哈希 | 存储空间小,比对速度快 | 哈希碰撞风险极低,但理论上存在 |
我在项目中更推荐公钥固定。为什么?因为证书可以换,但私钥对应的公钥通常不会变。你想想看,如果每次证书续签都要发版更新客户端,那运维成本就太高了。
4.1.2 Android 实现示例(OkHttp)
// 1. 计算服务端公钥的 SHA-256 哈希
// 命令:openssl s_client -connect example.com:443 < /dev/null 2>/dev/null \
// | openssl x509 -pubkey -noout \
// | openssl pkey -pubin -outform der \
// | openssl dgst -sha256 -binary \
// | openssl enc -base64
// 2. 在 OkHttp 中配置 CertificatePinner
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(
new CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // 备用
.build()
)
.build();
4.1.3 iOS 实现示例(URLSession)
// 在 URLSessionDelegate 中实现证书验证
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// 获取服务端证书
if let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) {
let serverPublicKey = SecCertificateCopyKey(serverCertificate)
// 提取公钥数据并计算 SHA-256
// 与本地硬编码的哈希值比对
// 匹配则通过,否则拒绝
}
completionHandler(.cancelAuthenticationChallenge, nil)
}
4.2 证书验证逻辑
证书验证不只是检查签名是否有效。完整的验证链条包括:有效期、域名匹配、吊销状态、信任链。很多开发者只做了前两步,后两步直接跳过——这其实很危险。
4.2.1 验证步骤清单
- 检查有效期:当前时间是否在 notBefore 和 notAfter 之间。
- 检查域名匹配:证书的 CN 或 SAN 是否与请求的域名一致。
- 检查信任链:证书是否由受信任的根 CA 签发,中间证书是否完整。
- 检查吊销状态:通过 CRL 或 OCSP 确认证书未被吊销。
- 检查扩展属性:如 Key Usage、Extended Key Usage 是否允许当前用途。
我个人习惯把第 4 步放在最后——因为 OCSP 请求可能超时,如果网络不好,可以降级为“软失败”(允许连接但记录日志)。但第 1-3 步是硬性要求,任何一步失败都必须拒绝连接。
4.2.2 常见验证漏洞
| 漏洞 | 表现 | 后果 |
|---|---|---|
| 跳过域名验证 | 只检查证书是否由合法 CA 签发 | 攻击者可用其他域名的合法证书进行中间人攻击 |
| 信任所有 CA | 未限制信任锚点 | 任意 CA 被攻破后均可签发假证书 |
| 忽略吊销状态 | 不检查 CRL/OCSP | 已泄露的私钥仍可被用于伪造身份 |
| 接受自签名证书 | 开发环境配置带到生产环境 | 中间人攻击完全无门槛 |
4.3 安全传输层配置
传输层配置,就是告诉客户端“你该用什么协议版本、什么密码套件”。默认配置往往不是最安全的——系统库为了兼容性,通常会保留一些老旧但不够安全的选项。
4.3.1 推荐配置
| 参数 | 推荐值 | 说明 |
|---|---|---|
| TLS 版本 | TLS 1.2 / 1.3 | 禁用 SSL 3.0、TLS 1.0、TLS 1.1 |
| 密码套件 | TLS_AES_128_GCM_SHA256 (TLS 1.3) ECDHE-RSA-AES128-GCM-SHA256 (TLS 1.2) |
优先使用 AEAD 套件,禁用 CBC 模式 |
| 椭圆曲线 | X25519 / P-256 | 禁用 P-224、P-384(性能差且安全性无优势) |
| 签名算法 | SHA-256 / SHA-384 | 禁用 SHA-1 签名 |
4.3.2 Android 配置示例
// 创建自定义 SSLContext,只允许 TLS 1.2 和 1.3
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, null, null);
// 创建 SSLSocketFactory,限制密码套件
SSLSocketFactory factory = sslContext.getSocketFactory();
String[] enabledCipherSuites = new String[] {
"TLS_AES_128_GCM_SHA256", // TLS 1.3
"TLS_AES_256_GCM_SHA384", // TLS 1.3
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", // TLS 1.2
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" // TLS 1.2
};
// 在 OkHttp 中应用
OkHttpClient client = new OkHttpClient.Builder()
.sslSocketFactory(new DelegatingSSLSocketFactory(factory) {
@Override
protected SSLSocket configureSocket(SSLSocket socket) {
socket.setEnabledProtocols(new String[]{"TLSv1.2", "TLSv1.3"});
socket.setEnabledCipherSuites(enabledCipherSuites);
return socket;
}
})
.build();
4.4 回退攻击防护
回退攻击,就是攻击者故意干扰 TLS 握手,让客户端和服务端“降级”到较弱的协议版本或密码套件,然后利用已知漏洞破解通信。最常见的两种手法是:协议版本回退和密码套件降级。
4.4.1 协议版本回退防护
TLS 1.3 引入了 supported_versions 扩展,客户端在 ClientHello 中明确列出支持的版本列表。服务端只能从列表中选择,不能回退到列表之外的版本。如果攻击者篡改 ClientHello 删除了 TLS 1.3,服务端会直接拒绝连接。
对于 TLS 1.2 及以下版本,依赖 SCSV(Signaling Cipher Suite Value) 机制。客户端在 ClientHello 中插入一个特殊的密码套件 {0x00, 0xFF},表示“我只接受我声明的最高版本”。服务端如果收到这个标记,就不会回退到更低版本。
4.4.2 密码套件降级防护
攻击者可能删除 ClientHello 中的强密码套件,只保留弱套件。防护方法很简单:客户端和服务端都维护一个“最低可接受”的密码套件列表。任何不在列表中的套件,即使对方支持,也直接拒绝。
// 客户端强制要求至少使用 AES-GCM 或 ChaCha20-Poly1305
List<String> allowedSuites = Arrays.asList(
"TLS_AES_128_GCM_SHA256",
"TLS_AES_256_GCM_SHA384",
"TLS_CHACHA20_POLY1305_SHA256"
);
// 握手完成后检查实际使用的套件
String negotiatedSuite = sslSession.getCipherSuite();
if (!allowedSuites.contains(negotiatedSuite)) {
throw new SSLException("不安全的密码套件: " + negotiatedSuite);
}
4.4.3 客户端回退攻击防护清单
- 固定 TLS 版本:客户端只发起 TLS 1.2 或 1.3 的 ClientHello,不兼容更低版本。
- 使用 supported_versions 扩展:明确告诉服务端你支持哪些版本。
- 检查协商结果:握手完成后,验证实际使用的协议版本和密码套件是否符合预期。
- 设置超时:如果握手时间过长(超过 5 秒),直接断开重连,防止攻击者拖延时间尝试降级。
- 记录日志:所有回退尝试都必须记录,便于事后分析。
嗯,到这里客户端加固的核心内容就讲完了。你可能会问:这四个方面哪个最重要?我个人觉得是证书固定——它直接从根源上杜绝了中间人攻击的可能性。但其他三个方面也不能忽视,因为攻击者往往会从最薄弱的环节入手。记住一句话:客户端的安全,取决于你做了多少,而不是系统帮你做了多少。