我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

什么是 SSL Pinning?基于 OkHttp 的 Android 指南

使用 Network Security Config、OkHttp 和 Retrofit 在 Android 上实现 SSL 证书锁定,然后对其进行测试并安全地轮换锁定值。包含旧版库的示例。

Android 上的 SSL 证书锁定(证书锁定与公钥锁定)

保护 Android 应用与其后端之间的通信,首先要从 TLS 开始。TLS 通过加密连接、验证服务器身份以及防止敏感流量以明文发送来保护传输中的数据。在标准的 Android 配置中,应用依靠平台信任库和常规的证书验证来决定是否应信任某个服务器证书。

证书锁定在这一基础之上增加了一层额外的信任限制。应用不再接受任何能通过常规信任模型验证的证书链,而是只接受包含特定预期密钥材料的证书链。在某些场景下,这可以增加中间人攻击的难度,但同时也带来了运维风险:如果证书锁定过于僵化或管理不善,证书变更、CA 迁移或密钥轮换都可能导致合法连接中断。Android 关于证书锁定的指南在这一点上非常谨慎,指出对于大多数应用一般不建议使用证书锁定,因为除非客户端同步更新,否则服务器配置的变更可能会导致连接中断。

本指南将解释 Android 上的 SSL 证书锁定意味着什么、它在何时有帮助、它无法防护什么、主要的实现方式、如何安全地验证它,以及如何在生产环境中运维它,而不让证书轮换演变成一次服务中断。本文还包含 HttpsURLConnection、OkHttp、Retrofit、Volley 和 Picasso 的示例,供同时维护现代和旧版 Android 网络栈的团队参考。

Android 信任方式对比:默认 CA 信任接受多条证书链;锁定信任只接受包含被锁定密钥的证书链,拒绝其他证书链。

目录

  1. Android 上的 SSL 证书锁定意味着什么
  2. SSL 证书锁定、TLS 证书锁定与证书锁定
  3. 证书锁定无法防护什么
  4. 威胁模型:证书锁定何时有帮助
  5. Android 上的证书锁定方案
  6. 实现示例
  7. 如何验证 SSL 证书锁定是否生效
  8. 在生产环境中安全地运维证书锁定
  9. 相关阅读
  10. 常见问题解答

Android 上的 SSL 证书锁定意味着什么

在标准的 Android 配置中,应用信任能够通过平台信任库验证的服务器证书。SSL 证书锁定在该模型之上增加了一层额外限制:只有当证书链包含目标域名的特定预期密钥材料时,连接才会被接受。

在 Android 上,这通常通过锁定证书公钥的哈希值来实现,也称为 SPKI 锁定。应用不再信任任何以普遍受信任的证书颁发机构为锚点的有效证书链,而是将信任范围缩小到包含某个被锁定公钥的证书链。

实际上,这意味着应用不仅要检查证书是否有效,还要检查它是否符合应用自身定义的更具体的信任预期。OkHttp 的 CertificatePinner 遵循同样的思路,它会根据被锁定的公钥哈希值来验证服务器的证书链。

SSL 证书锁定、TLS 证书锁定与证书锁定

“SSL Pinning”仍是大多数人使用的说法,但实际上它通常指的是 TLS 证书锁定或公钥锁定。在 Android 当前的安全指南中,证书锁定是以公钥哈希值的形式来描述的。在本指南中,SSL 证书锁定、TLS 证书锁定、证书锁定和公钥锁定这几个术语的用法与从业者通常搜索和讨论该主题时的用法一致,同时保持技术含义的准确。

证书锁定无法防护什么

证书锁定不能替代正确的 TLS 验证。如果应用使用了不安全的 HostnameVerifier 或 unsafe X509TrustManager,它仍可能遭受冒充和拦截。Android 将这两者都明确记录为安全风险,因为它们可能导致应用接受恶意或无效的连接。

证书锁定也无法修复服务器端漏洞、泄露的凭据、存在缺陷的会话处理、不安全的 API,或应用其他地方的敏感数据泄露。它只是一项传输安全控制措施,而不是一套完整的移动应用安全策略。这也是应将证书锁定视为更广泛的 Android 安全模型的一部分,而不是一个孤立的加固勾选项的原因之一。

威胁模型:证书锁定何时有帮助

Android 的文档清楚地解释了核心场景:默认情况下,应用信任许多预装的证书颁发机构,如果其中某个 CA 签发了欺诈性证书,应用就可能暴露在路径上的攻击者面前。证书锁定通过要求证书链包含某个被锁定的公钥来减少这种信任。OkHttp 对同样的防护的描述是:防御针对证书颁发机构的攻击,以及防御用户已知或未知的中间人证书颁发机构。

因此,当团队希望更严格地控制某个特定后端可接受哪些信任链时,证书锁定就更有意义。通常,在后端稳定、证书生命周期受到严格管理、并且已经规划好轮换的运维负担时,最容易证明使用证书锁定的合理性。即便如此,证书锁定仍是一项高摩擦的控制措施,不应轻率引入。Android 将其视为一项在运维上很脆弱的措施,而 OkHttp 自己的文档更是直言不讳地将其标注为:“Certificate Pinning is Dangerous!(证书锁定很危险!)因为它限制了证书更新并增加了运维复杂性。

Android 上的证书锁定方案

在较新的 Android 代码库中,证书锁定更常通过 Android Network Security Configuration(参见下一节中的示例) 来处理,它在一个声明式配置文件中支持证书锁定、备用锁定值、锁定过期、自定义信任锚和调试覆盖。这样可以让信任决策保持集中,而不是把 TLS 处理分散到多个库中。如果应用直接使用 OkHttp,CertificatePinner 仍然是主要的客户端级方案。由于 Retrofit 构建在 HTTP 客户端之上,Retrofit 配置中的证书锁定行为取决于所配置的底层客户端,通常是 OkHttp。

在较旧的 Android 代码库中,证书锁定可能仍依赖于在 HttpsURLConnection、Volley 或 Picasso 集成中自定义的 TLS 处理。这些示例作为历史参考或维护旧版应用时仍然有用,但应将其视为较旧的实现模式,而不是新项目的默认选择。对于 Android 证书锁定,OWASP MASTG 将 Network Security Configuration 描述为首选和推荐的方法,同时警告基于自定义 TrustManager 的证书锁定是一种底层方法,如果实现不够谨慎,很容易出错

方案 是否支持证书锁定 维护负担 主要陷阱 最适合
Android Network Security Configuration 是 较低 需要仔细配置域名和调试设置 大多数现代 Android 应用
OkHttp CertificatePinner 是 中等 必须规划轮换和主机名覆盖范围 已直接使用 OkHttp 的应用
Retrofit 搭配已配置的 OkHttp 客户端 是 中等 Retrofit 继承客户端的行为;证书锁定并非独立存在 基于 Retrofit 的应用
HttpsURLConnection 自定义信任配置 可行 较高 容易过度自定义 TLS 处理 仅用于旧版维护
Volley 自定义 TLS 配置 可行 较高 更难保持集中管理 仅用于旧版维护
Picasso 自定义下载器配置 可行 较高 较旧的集成模式 仅用于旧版维护

此对比反映了当前的 Android 指南、OkHttp 的文档,以及声明式平台级配置与较旧的特定于库的信任自定义之间的实际差异。

实现示例

根据所使用的 Android 网络栈,证书锁定可以通过不同的方式实现。对于新的开发工作,请从 Network Security Configuration 或现代的 OkHttp 配置开始。下面的库示例最好被理解为旧版参考模式,供仍在维护较旧技术栈的团队使用。

在旧版示例之前:现代推荐做法

如果您今天要构建一个新的 Android 应用,请从 Android Network Security Configuration 开始,以实现声明式的信任和证书锁定;如果您的网络栈已经以 OkHttp 为核心,则可以使用 OkHttp CertificatePinner。Android 通过 pin-set 支持公钥锁定,支持备用锁定值,允许设置可选的过期日期,并提供仅限调试的覆盖配置,使团队能够在不削弱发布版本安全性的前提下安全地进行测试。

Android Network Security Configuration(NSC)证书锁定示例(声明式)

创建 res/xml/network_security_config.xml:

    <?xml version="1.0" encoding="utf-8"?>
<network-security-config>

    <!-- Apply rules to a specific domain (and optionally its subdomains) -->
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">example.com</domain>

        <!-- Public-key pins (SHA-256 of SPKI), with an expiration date -->
        <pin-set expiration="2027-12-31">
            <!-- Primary pin -->
            <pin digest="SHA-256">afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=</pin>

            <!-- Backup pin (rotate keys/certs safely) -->
            <pin digest="SHA-256">BASE64_SHA256_BACKUP_SPKI_PIN_HERE=</pin>
        </pin-set>

        <!-- Optional: customize which CAs you trust for this domain -->
        <trust-anchors>
            <certificates src="system" />
            <!-- If you also need a private CA bundled with the app: -->
            <!-- <certificates src="@raw/my_private_ca" /> -->
        </trust-anchors>
    </domain-config>

    <!-- Optional (debug builds): allow user-added / debug CAs without weakening release -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
            <!-- or a dedicated debug CA -->
            <!-- <certificates src="@raw/debug_ca" /> -->
        </trust-anchors>
    </debug-overrides>

</network-security-config>

要点:pin-set 支持多个锁定值(主锁定值 + 备用锁定值)以及一个过期日期(格式为 yyyy-MM-dd),如果您不更新配置,过期后证书锁定将被禁用。 在 AndroidManifest.xml 中启用它

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

使用 HttpsURLConnection 实现 Android 证书锁定

    CertificateFactory cf = CertificateFactory.getInstance("X.509");

    // Generate the certificate using the certificate file under res/raw/cert.cer
    InputStream caInput = new BufferedInputStream(getResources().openRawResource(R.raw.cert));
    Certificate ca = cf.generateCertificate(caInput);
    caInput.close();

    // Create a KeyStore containing our trusted CAs
    String keyStoreType = KeyStore.getDefaultType();
    KeyStore keyStore = KeyStore.getInstance(keyStoreType);
    keyStore.load(null, null);
    keyStore.setCertificateEntry("ca", ca);

    // Create a TrustManager that trusts the CAs in our KeyStore
    String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
    tmf.init(keyStore);

    // Create an SSLContext that uses our TrustManager
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, tmf.getTrustManagers(), null);

    // Tell the URLConnection to use a SocketFactory from our SSLContext
    URL url = new URL("https://example.com/faq/");
    HttpsURLConnection urlConnection = (HttpsURLConnection) url.openConnection();
    urlConnection.setSSLSocketFactory(context.getSocketFactory());
    InputStream in = urlConnection.getInputStream();
    theString = readInputStream(in);
    CertificateFactory cf = CertificateFactory.getInstance("X.509");

使用 OkHTTP 实现 Android 证书锁定

    //okhttp version 3.x

    public CertificatePinning()
    {
        // We add the public key of our trusted server hashed and encoded base64
        client = new OkHttpClient.Builder().certificatePinner(new CertificatePinner.Builder()
        .add("https://example.com", "sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=").build()).build();
    }

    public void run() throws Exception
    {
        Request request = new Request.Builder().url("https://example.com/faq").build();

        Response response = client.newCall(request).execute();
        if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

    /**
    * response.handshake() contains the TLS handshake of the connection that carried this response, or null if the response
    * was received without TLS.
    */
        if(response.handshake())
        {
            for (Certificate certificate : response.handshake().peerCertificates())
            {
                //Pin returns SHA-256 hash of the public key
                String caPin = CertificatePinner.pin(certificate);

                // then you we to check the hash value and apply the corresponding action
            }
        }
    }

使用 Retrofit 实现 Android 证书锁定

    // Use the steps above to create OkHttpClient and set the custom client when building adapter
    Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://example.com")
    .addConverterFactory(GsonConverterFactory.create())
    .client(client)
    .build();
  Volley:

    CertificateFactory cf = CertificateFactory.getInstance("X.509");

    // Generate the certificate using the certificate file under res/raw/cert.cer
    InputStream caInput = new BufferedInputStream(getResources().openRawResource(R.raw.cert));
    Certificate ca = cf.generateCertificate(caInput);
    caInput.close();

    // Create a KeyStore containing our trusted CAs
    String keyStoreType = KeyStore.getDefaultType();
    KeyStore trusted = KeyStore.getInstance(keyStoreType);
    trusted.load(null, null);
    trusted.setCertificateEntry("ca", ca);

    // Create a TrustManager that trusts the CAs in our KeyStore
    String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
    tmf.init(trusted);

    // Create an SSLContext that uses our TrustManager
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, tmf.getTrustManagers(), null);

    SSLSocketFactory sf = context.getSocketFactory();
    mRequestQueue = Volley.newRequestQueue(mCtx.getApplicationContext(), new HurlStack(null, sf));

使用 Picasso 实现 Android 证书锁定

    CertificateFactory cf = CertificateFactory.getInstance("X.509");

    // Certificate file is under res/raw/cert.cer
    InputStream caInput = new BufferedInputStream(getResources().openRawResource(R.raw.cert));
    Certificate ca = cf.generateCertificate(caInput);
    caInput.close();

    // Create a KeyStore containing our trusted CAs
    String keyStoreType = KeyStore.getDefaultType();
    KeyStore keyStore = KeyStore.getInstance(keyStoreType);
    keyStore.load(null, null);
    keyStore.setCertificateEntry("ca", ca);

    // Create a TrustManager that trusts the CAs in our KeyStore
    String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
    tmf.init(keyStore);

    // Create an SSLContext that uses our TrustManager
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, tmf.getTrustManagers(), null);

    OkHttpClient okHttpClient = new OkHttpClient();
    okHttpClient.setSslSocketFactory(sslContext.getSocketFactory());
    OkHttpDownloader okHttpDownloader = new OkHttpDownloader(okHttpClient);
    Picasso.Builder builder = new Picasso.Builder(context);
    builder.downloader(okHttpDownloader);
    Picasso sPicasso = builder.build();

如何验证 SSL 证书锁定是否生效(且不影响用户)

要验证 Android 和 iOS 应用上的 SSL 证书锁定,请设置 Burp Suite 或 mitmproxy 之类的代理,并确认即使应用信任了代理的 CA 证书,它仍能阻止流量拦截。这证明证书锁定能够防止中间人攻击。

前提条件:

  1. 安装 mitmproxy:pip install mitmproxy。
  2. 运行 mitmdump -p 8080(或使用带界面的 mitmproxy);记下您的 IP(例如使用 ifconfig 或 ip addr)。
  3. 建议使用已 Root 的 Android 或已越狱的 iOS 以获得完整信任;在模拟器上同样可行。

Android 步骤

  1. 将设备的 Wi-Fi 代理设置为 :8080;用浏览器访问 HTTPS 进行测试(流量应经过代理)。
  2. 在浏览器中访问 http://mitm.it/?mode=android;下载并安装用户 CA。
  3. 对于系统级信任(大多数应用都需要):openssl x509 -inform PEM -subject_hash_old -in ~/.mitmproxy/mitmproxy-ca-cert.pem | head -1 → .0;adb push .0 /system/etc/security/cacerts/;adb shell chmod 644 /system/etc/security/cacerts/.0;重启。
  4. 触发应用的网络调用——如果 mitmproxy/mitmdump 中没有任何流量,并且 adb logcat | grep -i ssl 中出现 SSL 错误——说明证书锁定处于生效状态。

验证:

在不使用代理时应用功能正常,即可确认问题出在证书锁定而非配置上。如果流量能被拦截,则说明没有证书锁定(或使用 objection 等绕过工具进行确认)。

请在应用启动时进行这项检查,并在启动之后继续检查,因为有些应用可能只在特定操作期间进行校验,之后便不再检查。

在生产环境中安全地运维证书锁定(轮换、备用锁定值、逐步发布)

证书锁定在生产环境中的主要风险是证书或密钥轮换。Android 明确建议始终包含一个备用密钥,这样当您需要切换密钥或更换 CA 时,应用的连接不会中断。它还支持为锁定值设置过期日期,这可以降低旧版本应用无限期失效的风险,但代价是过期的锁定值将不再保护连接。

OkHttp 对运维负担的说明同样直白:证书锁定限制了服务器团队更新证书以及在证书颁发机构之间迁移的能力。这就是为什么证书锁定应像生产功能一样逐步发布,而不是像代码清理那样直接合并。合理的发布路径是先发布内部构建版本,然后是 Beta 版,再到有限比例的生产环境,只有在证书行为、监控和回退方案都经过验证后才全面发布。这里的分阶段发布建议是基于 Android 和 OkHttp 所记录的限制而提出的运维建议。

当证书锁定在生产环境中失败时,应用应以一种可理解、可诊断的方式失败。至少应定义面向用户的提示信息、日志字段、告警阈值和支持分诊路径。证书锁定失败不同于一般的超时或离线状态,因此不应被归入同一个错误类别而被淹没。

企业 TLS 检查和强制门户也需要一个明确的策略决定。由于证书锁定将信任限制在预期的密钥材料上,替换服务器证书的环境必然会导致证书锁定失败。团队应在发布之前审慎地决定这种行为,而不是在部署过程中才发现。关于如何在不禁用证书锁定的情况下分析启用了证书锁定的环境中的流量,请参阅我们的技术示例文章 SSL Pinning 通用绕过……从原理到基于 LLDB 的完整可用 PoC。

常见问题解答

在 Android 上使用 SSL 证书锁定值得吗?

可能值得,但前提是后端、发布流程和证书生命周期都足够成熟,能够支撑它。Android 的官方指南明确提醒,对于许多应用一般不建议使用证书锁定,因为除非客户端同步更新,否则后端证书的变更可能会导致连接中断。当团队确实选择使用证书锁定时,Android 建议使用备用锁定值和较短的过期时间窗口。

证书锁定与公钥锁定有什么区别?

在 Android 当前的指南中,证书锁定是以证书公钥的哈希值来表示的,而不是对完整证书文件进行原始比对。OkHttp 的 CertificatePinner 同样记录了基于 SPKI 的锁定。 证书轮换时会出现什么问题? 如果新的证书链不再包含任何一个被锁定的密钥,那么在更新锁定值之前,应用可能会失去连接,除非事先已存在一个备用锁定值。这就是为什么 Android 建议使用备用密钥,并支持可选的锁定过期设置。

Android Network Security Configuration 是否支持证书锁定?

是的。Android 通过 Network Security Configuration 中的 pin-set entries 支持证书锁定,包括多个锁定值、备用锁定值、过期设置、自定义信任锚和仅限调试的覆盖配置。

Retrofit 本身会进行证书锁定吗?

不会。Retrofit 将您的 HTTP API 转换为 Java 或 Kotlin 接口,证书锁定行为来自所配置的底层客户端,例如 OkHttp。

如何在不削弱生产环境安全性的前提下测试证书锁定?

使用受控的预发布环境测试,同时验证成功路径和不匹配路径,并使用 Android 的调试配置支持,而不是削弱发布版本的验证逻辑。

Android 应用应如何应对企业 TLS 检查?

团队应在发布之前明确决定这一策略。由于证书锁定会验证特定的密钥材料,执行 TLS 检查的环境必然会导致失败,因此应将预期行为记录在案,而不是让用户在部署期间才发现。

对于新的 Android 应用,最现代的起点是什么?

对于大多数新应用,请从 Network Security Configuration 开始,实现集中式的声明式证书锁定;如果网络层已经以 OkHttp 为核心,则使用 OkHttp CertificatePinner。只有在维护旧版代码时才保留较旧的、特定于库的自定义 TLS 配置。