证书钉扎防御实战:防中间人攻击配置

适用场景

证书钉扎防御(Certificate Pinning)的做法是把服务端证书或公钥指纹硬编码进客户端信任清单,只认可清单内的证书,从而在系统 CA 被滥用时仍能挡住中间人攻击。它解决的是这样一个现实问题:设备出厂预置的数百个根证书里,只要有一个被企业合规代理、抓包工具或失陷 CA 签发,攻击者就能对 App 与 API 的通信实施解密、篡改。

本文适用于三类读者:自研 App 且需要保护登录与支付接口的开发者;为第三方 App 提供 API 的站长,需要在服务端配合做证书轮换预案;以及安全审计中需要验证“抓包工具能否解开自家 App 流量”的运维与测试人员。若你只运营纯浏览器访问的网站,请直接看第 5 节关于 Web 端边界的说明。

前置条件

  • 一个已启用 HTTPS 的域名,证书链完整,服务端支持 TLS 1.2 及以上。
  • 客户端工程:Android(API 24+,支持 network_security_config.xml)或 iOS 13+(支持 NSPinnedDomains)。
  • 工具:openssl 1.1.1 及以上(用于计算 SPKI 指纹)、mitmproxy 或 Charles(用于验证拦截是否生效)。
  • 运维要求:证书续期流程已自动化(如 Certbot、ACM),并明确证书轮换的提前期,钉扎策略必须与续期节奏联动。
  • 需要确认业务上是否存在“必须允许抓包”的测试环境,通常做法是发布版启用钉扎、调试版关闭。

原理说明

常规 HTTPS 的信任判定是链式的:客户端拿着服务端证书,向上逐级找到某个预置根 CA,只要链条能闭合就信任。这套模型的弱点在于信任面太大——系统里几百个根证书,任何一个被攻破、被滥用或被强制安装(如企业 MDM 下发根证书做流量审计),攻击者就能签发一张对 api.example.com 完全有效的证书,而客户端无法分辨。

钉扎把信任判定从“链式验证”改为“指纹比对”:客户端只认事先写入的那一个或多个公钥指纹。即使攻击者持有合法 CA 签发的证书,指纹对不上依然握手失败。

钉扎有三个层次,实践中推荐用中间层:

  • 叶证书钉扎(Pin 具体服务器证书)——最严格,但证书一续期就全部失效,除非你有提前发布新版本的强控能力,否则不建议。
  • 中间 CA 钉扎(Pin 签发该证书的中间证书公钥)——证书续期时只要还用同一家 CA,同一中间证书签发的新叶证书就能通过,兼顾安全与可维护性,是主流选择。
  • SPKI 公钥钉扎(Pin SubjectPublicKeyInfo 的哈希)——不依赖证书有效期,密钥不换就一直有效,Android 的 CertificatePinner 与 iOS 的 NSPinnedDomains 都基于这一机制。

需要特别说明:HPKP(HTTP Public Key Pinning)已被 Chrome 于 2018 年移除,浏览器端不再提供等价的头部机制。因此钉扎是一个“客户端 App 专属”的能力,网站无法通过响应头实现。这一点被大量教程混淆,务必区分。

操作步骤

步骤 1:计算服务端公钥的 SPKI 指纹

# 方式一:直接从线上服务取证书并计算 sha256 指纹(base64)
echo | openssl s_client -servername api.example.com -connect api.example.com:443 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

# 方式二:计算中间证书的指纹(推荐,抗续期)
echo | openssl s_client -servername api.example.com -connect api.example.com:443 -showcerts 2>/dev/null \
  > /tmp/chain.txt
grep -c 'BEGIN CERTIFICATE' /tmp/chain.txt          # 确认链上证书数量
csplit -z -f /tmp/cert- /tmp/chain.txt '/BEGIN CERTIFICATE/' '{*}' >/dev/null
for f in /tmp/cert-*; do
  echo "== $f"; openssl x509 -in "$f" -noout -subject -issuer -enddate
  openssl x509 -in "$f" -pubkey -noout | openssl pkey -pubin -outform der \
    | openssl dgst -sha256 -binary | openssl enc -base64
done

把输出中的指纹记录下来,注意至少保存两个:一个当前主用,一个来自备用 CA 或备用密钥(backup pin)。只写一个 pin 是运维事故的常见来源——证书一换、App 全量断网。

步骤 2:Android 使用网络安全配置做全局钉扎

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>

    <domain-config>
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2027-12-31">
            <!-- 主用:中间 CA 公钥 -->
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <!-- 备用:灾备证书/备用 CA 公钥 -->
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>
    </domain-config>

    <!-- 调试构建单独放行,避免影响测试 -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>

在 AndroidManifest.xml 的 <application> 标签上引用:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    android:usesCleartextTraffic="false">

其中 pin-set expiration 是安全网:到期后系统会忽略钉扎并回退到普通校验,避免因忘记更新导致业务长期不可用。debug-overrides 只在 debuggable 构建中生效,因此测试环境可以正常抓包,发布包则严格钉扎。

步骤 3:OkHttp 代码级钉扎(适用于 Retrofit 项目)

val pinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(pinner)
    .connectTimeout(10, TimeUnit.SECONDS)
    .build()

若指纹不匹配,OkHttp 会抛出 SSLPeerUnverifiedException,异常信息中会打印服务端实际的指纹。这一点在联调阶段非常有用:先在测试环境故意填错,从日志里读出真实指纹再回填,可以避免手工计算出错。

步骤 4:iOS 使用 NSPinnedDomains(iOS 14+)

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSPinnedDomains</key>
    <dict>
        <key>api.example.com</key>
        <dict>
            <key>NSIncludesSubdomains</key>
            <true/>
            <key>NSPinnedCAIdentities</key>
            <array>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</string>
                </dict>
                <dict>
                    <key>SPKI-SHA256-BASE64</key>
                    <string>BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</string>
                </dict>
            </array>
        </dict>
    </dict>
</dict>

NSPinnedCAIdentities 表示钉中间 CA,NSPinnedLeafIdentities 表示钉叶证书。生产环境优先使用前者,理由是证书续期不影响已发布版本。若使用自签证书,则必须用 NSPinnedLeafIdentities 并配合明确的轮换计划。

步骤 5:服务端侧的配合加固

钉扎保护的是客户端到服务端这一段,服务端自身仍需配套配置,否则攻击者可以绕过 App 直接打 API:

# Nginx:强制 HTTPS + HSTS + 完整证书链
server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_tickets off;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options nosniff always;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

fullchain.pem 必须包含中间证书。链不完整时,部分 Android 版本会因为找不到签发者而直接握手失败,与钉扎失败的表现非常相似,容易误判。

步骤 6:把指纹校验纳入自动化巡检

钉扎最大的运维风险是“证书换了但 pin 没换”。用下面的脚本在证书到期前 60 天自动比对并告警:

#!/bin/bash
# /usr/local/bin/check-pinning.sh
DOMAIN="api.example.com"
PIN_FILE="/etc/ssl/pinning/${DOMAIN}.pin"     # 存放当前已发布版本的 pin

NOW=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:443" 2>/dev/null \
      | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der \
      | openssl dgst -sha256 -binary | openssl enc -base64)

if ! grep -qF "$NOW" "$PIN_FILE"; then
    echo "[ALERT] $DOMAIN 当前公钥指纹与已发布 pin 不一致,请评估客户端影响" >&2
    echo "当前: $NOW"
    exit 1
fi
echo "[OK] $DOMAIN 指纹与已发布 pin 一致"

把该脚本加入 crontab,每天 09:00 执行一次,输出通过邮件或企业微信机器人推送。这样在证书轮换后的第一天就能发现问题,而不是等用户投诉。

配置验证

第一步,验证配置已生效。Android 上打开抓包工具设置系统代理,启动发布版 App 访问被钉扎域名。预期结果是接口请求失败,日志中出现 SSLPeerUnverifiedException 或 Certificate pinning failure。若抓包工具能看到明文请求,说明钉扎未生效,常见原因是 XML 未被 Manifest 引用、或域名与实际请求域名不一致(例如请求走了 CDN 的另一个域名)。

第二步,用 mitmproxy 做端到端对照:

mitmproxy --mode regular --listen-port 8080
# 手机上安装 mitmproxy 的 CA 证书并设为信任(仅测试机)
# 访问被钉扎的接口,预期:
#   Client TLS handshake failed -> 钉扎生效
#   能看到明文 JSON           -> 钉扎未生效,检查域名与配置

第三步,验证指纹算法正确:

# 与服务端实际下发证书比对
openssl s_client -servername api.example.com -connect api.example.com:443 </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256
# 检查证书链是否完整(应显示 2 张及以上证书)
echo | openssl s_client -servername api.example.com -connect api.example.com:443 -showcerts 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'

第四步,验证备用 pin 有效。把主用 pin 临时从配置中移除,仅保留备用 pin 重新打包,确认业务仍能正常访问。这一步能提前暴露备用 pin 写错却无人发现的问题。

常见问题

Q1:证书续期后 App 全部报证书错误,怎么紧急处理

这是钉扎最典型的故障。处置分线上与线下两条路:线上立即回滚证书,把服务端换回旧证书(旧证书未过期前仍可用),同时用同一公钥重新签发新证书(Let’s Encrypt 支持指定 key 签发,certbot --reuse-key),这样 SPKI 不变,已发布版本的 pin 无需更新即可继续工作;线下发新版本把新 pin 写入,并给 pin-set 设置合理的 expiration。这也解释了为什么推荐钉中间 CA 的公钥:同一中间 CA 签发的新叶证书,SPKI 会变但 CA 指纹不变,只有钉 CA 才能免去改客户端的麻烦。

Q2:一个域名配几个 pin 比较合适

建议两个,最多三个。一个是当前使用中的,一个是备用 CA 或备用密钥的;第三个只在有明确的证书切换计划(如从 A 品牌 CA 迁到 B 品牌 CA)时加入。pin 越多,被攻击者碰巧命中的概率越高,安全性反而下降。无论几个,都必须在配置中设置 expiration,让钉扎在陈旧失效时自动降级,避免形成永久性故障。

Q3:网站(浏览器访问)能不能做证书钉扎

不能。HPKP 被浏览器移除后,Web 端没有可靠的钉扎机制。网站能做的替代方案是:开启 HSTS 并提交 preload 列表,杜绝降级与首次访问劫持;对关键 JS 资源使用 SRI(子资源完整性)校验;在服务端做 OCSP Stapling 提升吊销状态检查效率;以及通过 CSP 限制脚本来源。如果业务对中间人极其敏感(如金融类 API),正确做法是把能力收敛到 App 侧并配合 mTLS 双向认证,而不是指望浏览器。

Q4:接入 CDN 后钉扎是否还能用

可以用,但要注意钉扎的对象是客户端实际连接的服务器证书。接入 CDN 后,客户端看到的是边缘节点的证书,此时需要钉 CDN 用于该域名的证书公钥(或 CDN 使用的中间 CA 公钥)。风险在于 CDN 侧证书由厂商自动轮换,建议优先钉中间 CA,并把 CDN 的证书轮换通知接入你的变更流程;若 CDN 不支持固定中间 CA,则需要缩短 expiration 并保持较高的客户端升级覆盖率,否则不建议开启钉扎。

总结

  • 钉扎的本质是把信任判定从“任意可信 CA 签发即可”收紧到“只有指定公钥可通行”,能有效抵御企业代理、抓包工具与失陷 CA 带来的中间人解密。
  • 优先选择中间 CA 钉扎(SPKI),它兼顾安全性与证书续期的可维护性;次优选择叶证书钉扎,只在自签场景下使用。
  • 永远配置至少一个备用 pin 并设置 expiration。单 pin 配置等于给业务埋了一颗只在证书轮换时引爆的定时炸弹。
  • 钉扎只覆盖客户端到服务端这一段,服务端仍需 HSTS、完整证书链、TLS 版本收敛等配套措施,两者不可互相替代。
  • 把指纹一致性校验脚本接入日常巡检,让“证书换了 pin 没换”在用户感知之前被发现。