揭秘SSL Pinning在移动端的安全实践与避坑指南

UI柯佳 移动 阅读 880
赞 30 收藏
二维码
手机扫码查看
反馈

先看代码,再聊原理

上周上线前突然被安全团队叫住,说我们 App 的 HTTPS 请求没做证书固定(SSL Pinning),存在中间人攻击风险。我心想:不是用了 HTTPS 吗?怎么还不安全?

揭秘SSL Pinning在移动端的安全实践与避坑指南

但没办法,上线要紧。折腾了一下午,终于把 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 接口下发新的公钥指纹),那体验会好很多。不然每次换证书都得发版,运营同学能骂死你。

踩坑提醒:这三点一定注意

这几个问题我都遇到过,有的甚至上了生产才暴露:

  1. 不要只 pin 一个域名:如果你有多个子服务(比如 api.jztheme.com、pay.jztheme.com),要么分别 pin,要么确保它们共用同一张证书。不然某个子域换了证书,整个 App 就断网了。
  2. 测试环境要区分对待:我一开始在 debug 包也严格校验,结果 QA 用 Fiddler 抓包看日志根本连不上,最后只能临时注释代码。现在我是 define 一个 DEBUG_PINS_ALLOWED 列表,允许特定测试域名绕过。
  3. 证书快过期了没人通知你:我们去年就出过这事,证书还剩三天到期,没人管。后来我在 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,后续会继续分享这类博客。

总之,安全这事,宁可多花两天改代码,也别等到数据泄露后再后悔。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论