揭秘SSL Pinning在移动端的安全实践与避坑指南
先看代码,再聊原理
上周上线前突然被安全团队叫住,说我们 App 的 HTTPS 请求没做证书固定(SSL Pinning),存在中间人攻击风险。我心想:不是用了 HTTPS 吗?怎么还不安全?
但没办法,上线要紧。折腾了一下午,终于把 Android 和 iOS 都加上了 SSL Pinning。今天就把我踩的坑、亲测有效的方案写出来,省得你再像我一样通宵改代码。
直接上核心逻辑:我们要让 App 只信任特定的证书或公钥,而不是系统里随便一个 CA 都能冒充我们的服务器。比如访问 https://jztheme.com/api/data 的时候,不只是看证书是否有效,还要检查这个证书是不是我们预埋的那个。
Android 上这么搞最稳
用 OkHttp 的话,实现起来其实不复杂,关键是别抄网上那些过时的示例。我一开始照着某篇博客写的 X509TrustManager 自定义校验,结果调试阶段各种握手失败,后来才发现是没处理好链式证书。
现在我的做法是基于公钥哈希(SHA-256)来做 pinning,比直接 pin 证书更灵活,换中间证书也不用发版。
public class PinnedOkHttpClient {
private static final String PIN_SHA256 = "sha256/your-public-key-hash-here";
public static OkHttpClient createPinnedClient() {
try {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
InputStream certStream = AppContext.getContext().getAssets().open("server.cer");
X509Certificate cert = (X509Certificate) cf.generateCertificate(certStream);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] publicKeyBytes = cert.getPublicKey().getEncoded();
byte[] digest = md.digest(publicKeyBytes);
String actualPin = "sha256/" + Base64.encodeToString(digest, Base64.NO_WRAP);
if (!actualPin.equals(PIN_SHA256)) {
throw new IllegalStateException("证书公钥不匹配!");
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null);
X509TrustManager platformTrustManager = (X509TrustManager) tmf.getTrustManagers()[0];
X509TrustManager pinnedTrustManager = new X509TrustManager() {
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) {
throw new IllegalArgumentException("证书链为空");
}
// 先走系统校验
try {
platformTrustManager.checkServerTrusted(chain, authType);
} catch (CertificateException e) {
// 系统校验失败也别急着抛,我们自己比对下公钥
}
// 手动比对第一个证书的公钥哈希
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] encoded = chain[0].getPublicKey().getEncoded();
byte[] hash = digest.digest(encoded);
String pin = "sha256/" + Base64.encodeToString(hash, Base64.NO_WRAP);
if (!pin.equals(PIN_SHA256)) {
throw new CertificateException("证书固定失败: " + pin);
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return platformTrustManager.getAcceptedIssuers();
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
platformTrustManager.checkClientTrusted(chain, authType);
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{pinnedTrustManager}, new java.security.SecureRandom());
return new OkHttpClient.Builder()
.sslSocketFactory(sslContext.getSocketFactory(), pinnedTrustManager)
.hostnameVerifier((hostname, session) -> hostname.equals("jztheme.com"))
.build();
} catch (Exception e) {
throw new RuntimeException("创建 Pinned OkHttpClient 失败", e);
}
}
}
这里注意:我踩过好几次坑的是 hostnameVerifier 必须显式设置,否则即使做了 pinning,host 校验还是可能绕过。而且建议只在 release 包开启 strict 模式,debug 时可以放行某些测试域名,不然 Charles 抓包都抓不了,开发效率太低。
iOS 用 NSURLSession 原生支持就够了
说实话 iOS 这块比我想象中简单。用 URLSession 的 delegate 方法就能搞定,不用引入第三方库。
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let policy = SecPolicyCreateSSL(true, "jztheme.com" as CFString)
SecTrustSetPolicies(serverTrust, policy)
var result: OSStatus = errSecSuccess
var trustResult: SecTrustResultType = .invalid
result = SecTrustEvaluate(serverTrust, &trustResult)
if result != errSecSuccess {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 获取服务器证书链中的第一个证书(叶证书)
let certificateCount = SecTrustGetCertificateCount(serverTrust)
guard certificateCount > 0,
let leafCert = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let leafCertData = SecCertificateCopyData(leafCert) as Data
let pinHash = SHA256.hash(data: leafCertData).compactMap { String(format: "%02x", $0) }.joined()
// 这里是你预埋的哈希值(转小写十六进制字符串)
let expectedHash = "a1b2c3d4e5f6..." // 替换为你自己的
if pinHash == expectedHash {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
print("证书固定失败!期望: (expectedHash), 实际: (pinHash)")
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
启动请求时记得传入 delegate:
let configuration = URLSessionConfiguration.default
let session = URLSession(configuration: configuration, delegate: PinnedURLSessionDelegate(), delegateQueue: nil)
guard let url = URL(string: "https://jztheme.com/api/data") else { return }
var request = URLRequest(url: url)
request.httpMethod = "GET"
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
print("请求失败: $error.localizedDescription)")
return
}
if let data = data {
print("响应数据: $String(data: data, encoding: .utf8) ?? "")")
}
}
task.resume()
Swift 这边有个坑:SecTrustEvaluate 是同步的,没问题,但一定要注意 release 打包后不能打印明文 hash,不然反编译一下就全露了。建议把 expectedHash 加个简单的 obfuscation,比如拆成数组 runtime 拼接。
这个场景最好用
SSL Pinning 不是所有接口都要加。我建议只对以下几个关键接口启用:
- 登录、支付等涉及敏感信息的请求
- App 启动时拉取配置的接口(防止被劫持下发恶意配置)
- 调用私有 API 的所有端点
公共 CDN 资源或者第三方服务(比如友盟统计)就没必要 pin 了,人家证书一换你就跪。
另外,如果你的 App 支持动态更新证书哈希(比如通过非 pin 接口下发新的公钥指纹),那体验会好很多。不然每次换证书都得发版,运营同学能骂死你。
踩坑提醒:这三点一定注意
这几个问题我都遇到过,有的甚至上了生产才暴露:
- 不要只 pin 一个域名:如果你有多个子服务(比如 api.jztheme.com、pay.jztheme.com),要么分别 pin,要么确保它们共用同一张证书。不然某个子域换了证书,整个 App 就断网了。
- 测试环境要区分对待:我一开始在 debug 包也严格校验,结果 QA 用 Fiddler 抓包看日志根本连不上,最后只能临时注释代码。现在我是 define 一个 DEBUG_PINS_ALLOWED 列表,允许特定测试域名绕过。
- 证书快过期了没人通知你:我们去年就出过这事,证书还剩三天到期,没人管。后来我在 CI 脚本里加了个 cron job,自动检测本地预埋证书的有效期,提前一周告警。
还有一个冷知识:安卓 7.0+ 默认不再信任用户安装的 CA 证书,所以很多公司内网用的透明代理在新手机上直接失效。这也算变相增强了安全性,但对测试不太友好。
高级技巧:双证书兼容策略
最头疼的其实是证书轮换。你不能等到旧证书过期那天才上线新版本吧?万一审核卡住了呢?
我的解决方案是:预埋两个公钥哈希,只要任一匹配就算通过。这样你可以先上线带新旧两套 hash 的版本,等稳定后再发版去掉老的。
Android 示例片段:
private static final Set<String> EXPECTED_PINS = new HashSet<>(Arrays.asList(
"sha256/old-public-key-hash",
"sha256/new-public-key-hash"
));
校验的时候改成:
boolean matched = false;
for (String expectedPin : EXPECTED_PINS) {
if (pin.equals(expectedPin)) {
matched = true;
break;
}
}
if (!matched) {
throw new CertificateException("无匹配的证书固定规则");
}
等新证书完全生效后,再通过发版逐步回收旧 hash。整个过程零 downtime,用户体验无感。
最后说两句
SSL Pinning 看似是个安全功能,实际上更多是在对抗内网中间人和恶意代理。它不能解决所有问题,比如设备本身被 root 或越狱,内存抓包照样防不住。
但它确实能把攻击门槛提上去一大截。至少普通钓鱼 WiFi 下没法轻易窃取你的 token 了。
以上是我个人对这个技术的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合证书吊销列表(CRL)、OCSP Stapling,后续会继续分享这类博客。
总之,安全这事,宁可多花两天改代码,也别等到数据泄露后再后悔。

暂无评论