TLS Certificate Pinning (证书钉扎) 的实现原理,以及在移动端 JavaScript 环境下如何绕过。

各位观众,晚上好!我是今晚的主讲人,江湖人称“代码老司机”,今天咱们聊点刺激的—— TLS Certificate Pinning (证书钉扎) 的原理和绕过,重点是移动端 JavaScript 环境下。各位坐稳扶好,准备发车!

一、什么是 TLS Certificate Pinning?为啥要钉它?

首先,我们得搞清楚啥是 TLS Certificate Pinning。简单来说,就是客户端(比如你的 App)在与服务器建立 TLS 连接时,除了验证证书链的合法性之外,还会检查服务器返回的证书(或者证书链中的某个证书)是否与客户端预先存储的“钉子”匹配。这个“钉子”可以是证书本身、证书的公钥,或者证书的哈希值。

你可能会问,为啥要这么折腾?直接验证证书链不就完了吗?

是滴,正常情况下,验证证书链就足够了。但是,理想很丰满,现实很骨感。黑客们可不是吃素的,他们会利用各种手段攻击证书体系,比如:

  • CA 沦陷: 证书颁发机构 (CA) 被攻破,黑客可以冒充你的服务器申请到合法的证书。
  • 中间人攻击 (MITM): 黑客拦截客户端和服务器之间的流量,用伪造的证书欺骗客户端。

如果你只依赖证书链验证,一旦出现上述情况,你的 App 就可能连接到伪造的服务器,数据泄露、账号被盗什么的,就不是啥新鲜事儿了。

Certificate Pinning 的目的,就是为了增强安全性,防止上述攻击。它就像给你的 App 加上了一道额外的保险,只有拿着正确的“钥匙”(钉子),才能打开服务器的大门。

二、证书钉扎的几种姿势

Certificate Pinning 的实现方式有很多种,常见的主要有以下三种:

钉扎方式 描述 安全性 维护成本
钉扎证书 直接将服务器证书存储在客户端。 最高 最高
钉扎公钥 将服务器证书的公钥存储在客户端。 较高 较高
钉扎中间证书 将证书链中的某个中间证书存储在客户端。这种方式比钉扎根证书安全,又比钉扎叶子证书灵活。 中等 中等
  • 钉扎证书 (Certificate Pinning): 这是最严格的方式,直接将服务器的证书(通常是叶子证书)完整地存储在客户端。客户端在建立 TLS 连接时,必须确保服务器返回的证书与存储的证书完全一致。
    • 优点: 安全性最高。
    • 缺点: 维护成本最高。一旦证书过期或者更换,必须更新客户端,否则 App 将无法连接服务器。
  • 钉扎公钥 (Public Key Pinning): 客户端存储服务器证书的公钥,而不是完整的证书。在建立 TLS 连接时,客户端会提取服务器证书的公钥,并与存储的公钥进行比较。
    • 优点: 比钉扎证书更灵活,更换证书只需要更换公钥即可。
    • 缺点: 仍然需要定期更新公钥。
  • 钉扎中间证书 (Intermediate Certificate Pinning): 客户端存储证书链中的某个中间证书。在建立 TLS 连接时,客户端会验证服务器返回的证书链中是否包含该中间证书。
    • 优点: 比钉扎叶子证书更灵活,当叶子证书过期更换,如果中间证书没有变化,则客户端不需要更新。
    • 缺点: 安全性相对较低,因为中间证书的颁发机构仍然可能被攻破。

三、移动端 JavaScript 环境下的证书钉扎

在移动端 JavaScript 环境下(例如:React Native、Ionic、Weex 等),实现证书钉扎通常有两种方式:

  1. Native Module (原生模块): 通过编写 Native Module,调用原生 API (例如:Android 的 TrustManager、iOS 的 SecTrustEvaluateWithError) 来实现证书钉扎。
  2. Network Interceptor (网络拦截器): 利用网络请求库(例如:fetchaxios)提供的拦截器机制,在请求发送前或响应返回后,对证书进行校验。

3.1 Native Module 实现证书钉扎

这种方式的优点是安全性高,因为证书校验是在原生层进行的,JavaScript 层很难绕过。缺点是需要编写原生代码,增加了开发复杂度。

Android 示例 (Java):

import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;

public class CertificatePinningTrustManager implements X509TrustManager {

    private final String pinnedCertificateHash;

    public CertificatePinningTrustManager(String pinnedCertificateHash) {
        this.pinnedCertificateHash = pinnedCertificateHash;
    }

    @Override
    public void checkClientTrusted(X509Certificate[] chain, String authType) {
        // 不需要客户端证书验证
    }

    @Override
    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        if (chain == null || chain.length == 0) {
            throw new IllegalArgumentException("Certificate chain is empty");
        }

        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256"); // 使用 SHA-256 哈希
            byte[] encodedCert = chain[0].getEncoded();
            byte[] certHash = md.digest(encodedCert);
            String hexCertHash = bytesToHex(certHash);

            if (!hexCertHash.equalsIgnoreCase(pinnedCertificateHash)) {
                throw new CertificateException("Certificate pinning failed: " + hexCertHash);
            }
        } catch (NoSuchAlgorithmException | CertificateEncodingException e) {
            throw new CertificateException(e);
        }
    }

    @Override
    public X509Certificate[] getAcceptedIssuers() {
        return new X509Certificate[0]; // 不返回任何受信任的颁发者
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }
}

// 在 OkHttp 中使用
OkHttpClient.Builder client = new OkHttpClient.Builder();
try {
    CertificatePinningTrustManager trustManager = new CertificatePinningTrustManager("YOUR_CERTIFICATE_SHA256_HASH");
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, new TrustManager[]{trustManager}, null);
    client.sslSocketFactory(sslContext.getSocketFactory(), trustManager); //需要传入trustManager
} catch (Exception e) {
    e.printStackTrace();
}

iOS 示例 (Objective-C):

#import <Foundation/Foundation.h>
#import <Security/Security.h>

@interface CertificatePinning : NSObject

+ (BOOL)verifyCertificateChain:(NSArray *)certificateChain pinnedCertificateHash:(NSString *)pinnedCertificateHash;

@end

@implementation CertificatePinning

+ (BOOL)verifyCertificateChain:(NSArray *)certificateChain pinnedCertificateHash:(NSString *)pinnedCertificateHash {
    if (certificateChain == nil || certificateChain.count == 0) {
        return NO;
    }

    NSData *certificateData = [certificateChain[0] representativeData]; // 获取证书数据

    unsigned char digest[CC_SHA256_DIGEST_LENGTH];
    CC_SHA256(certificateData.bytes, (CC_LONG)certificateData.length, digest);

    NSMutableString *hashString = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
    for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) {
        [hashString appendFormat:@"%02x", digest[i]];
    }

    if (![hashString isEqualToString:pinnedCertificateHash]) {
        return NO;
    }

    return YES;
}

@end

// 在 NSURLSession 中使用 (示例)
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
configuration.delegate = self; // CertificatePinningDelegate 必须实现 NSURLSessionDelegate

// 实现 NSURLSessionDelegate 的方法
- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
 completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler {
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
        SecTrustRef trust = challenge.protectionSpace.serverTrust;
        NSArray *certificateChain = (__bridge_transfer NSArray *)SecTrustCopyCertificateChain(trust);

        // 假设 pinnedCertificateHash 是你在 App 中预先存储的证书哈希
        NSString *pinnedCertificateHash = @"YOUR_CERTIFICATE_SHA256_HASH";

        if ([CertificatePinning verifyCertificateChain:certificateChain pinnedCertificateHash:pinnedCertificateHash]) {
            completionHandler(NSURLSessionAuthChallengeUseCredential, [[NSURLCredential alloc] initWithTrust:trust]);
        } else {
            completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil);
        }
    } else {
        completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
    }
}

React Native JavaScript 代码:

// 假设你已经创建了一个名为 "CertificatePinningModule" 的 Native Module
const CertificatePinningModule = NativeModules.CertificatePinningModule;

fetch('https://your-api.com/data')
  .then(response => {
    if (response.ok) {
      return response.json();
    } else {
      throw new Error('Network response was not ok.');
    }
  })
  .then(data => {
    console.log('Data:', data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

3.2 Network Interceptor 实现证书钉扎

这种方式的优点是简单易用,只需要编写 JavaScript 代码即可实现证书钉扎。缺点是安全性相对较低,容易被绕过,因为 JavaScript 代码运行在客户端,容易被修改。

Axios 示例:

import axios from 'axios';

const instance = axios.create({
  baseURL: 'https://your-api.com',
  validateStatus: (status) => status >= 200 && status < 500 // 允许非 2xx 状态码,以便处理证书校验失败的情况
});

instance.interceptors.request.use(config => {
  // 在请求发送前进行证书校验
  return config;
}, error => {
  return Promise.reject(error);
});

instance.interceptors.response.use(response => {
  // 在响应返回后进行证书校验
  if (response.request && response.request.responseURL && response.request.responseURL.startsWith('https://')) {
    const certificate = response.request.connection.getPeerCertificate(); // 获取证书对象

    if (certificate) {
      const pem = certificate.raw.toString('base64');  //获取证书的pem格式
      const sha256Fingerprint =  crypto.createHash('sha256').update(certificate.raw).digest('hex'); // 计算证书的 SHA-256 哈希值
      const pinnedCertificateHash = 'YOUR_CERTIFICATE_SHA256_HASH'; // 你的钉子

      if (sha256Fingerprint !== pinnedCertificateHash) {
        console.error('Certificate pinning failed!');
        return Promise.reject(new Error('Certificate pinning failed!'));
      }
    } else {
      console.error('Could not retrieve certificate.');
      return Promise.reject(new Error('Could not retrieve certificate.'));
    }
  }
  return response;
}, error => {
  return Promise.reject(error);
});

instance.get('/data')
  .then(response => {
    console.log('Data:', response.data);
  })
  .catch(error => {
    console.error('Error:', error);
  });

四、绕过证书钉扎的几种姿势 (咳咳,仅供学习研究)

注意:以下内容仅供安全研究和学习之用,请勿用于非法用途。

虽然证书钉扎可以提高安全性,但也不是坚不可摧的。黑客们会想尽办法绕过它。常见的绕过方式有:

  1. Root/Jailbreak + Hook: 在 Root/Jailbreak 的设备上,可以使用 Hook 技术 (例如:Frida、Xposed) 修改 App 的代码,绕过证书校验逻辑。
  2. 中间人攻击 (MITM): 虽然证书钉扎可以防止一般的 MITM 攻击,但如果黑客能够控制客户端的网络环境,或者使用更高级的 MITM 技术,仍然有可能绕过证书钉扎。
  3. 动态调试: 使用调试器 (例如:GDB、LLDB) 动态调试 App,找到证书校验的代码,并修改其行为。
  4. 反编译 + 修改: 将 App 反编译,修改证书校验的代码,然后重新打包签名。

4.1 Hook 绕过证书钉扎 (以 Frida 为例)

Frida 是一个强大的动态插桩工具,可以用来 Hook 各种平台的应用程序。

// Frida 脚本 (Android)
Java.perform(function() {
  var CertificatePinningTrustManager = Java.use("com.example.CertificatePinningTrustManager"); // 替换为你的 TrustManager 类名
  CertificatePinningTrustManager.checkServerTrusted.implementation = function(chain, authType) {
    console.log("Certificate pinning bypassed!");
    // 直接返回,跳过证书校验
  };
});

// Frida 脚本 (iOS)
Interceptor.attach(ObjC.classes.CertificatePinning["+ verifyCertificateChain:pinnedCertificateHash:"].implementation, {
  onEnter: function(args) {
    console.log("Certificate pinning bypassed!");
    // 修改返回值,使其始终返回 YES
    args[0] = 1; // 修改第一个参数 (self) 的值为 1 (表示 YES)
  },
  onLeave: function(retval) {
    retval.replace(1); // 修改返回值
  }
});

4.2 修改 JavaScript 代码绕过

如果使用 Network Interceptor 实现证书钉扎,那么绕过就更加容易了。只需要修改 JavaScript 代码,就可以跳过证书校验逻辑。

// 修改后的代码
instance.interceptors.response.use(response => {
  // 注释掉证书校验代码
  // if (response.request && response.request.responseURL && response.request.responseURL.startsWith('https://')) {
  //   const certificate = response.request.connection.getPeerCertificate();
  //   if (certificate) {
  //     const sha256Fingerprint = crypto.createHash('sha256').update(certificate.raw).digest('hex');
  //     const pinnedCertificateHash = 'YOUR_CERTIFICATE_SHA256_HASH';

  //     if (sha256Fingerprint !== pinnedCertificateHash) {
  //       console.error('Certificate pinning failed!');
  //       return Promise.reject(new Error('Certificate pinning failed!'));
  //     }
  //   } else {
  //     console.error('Could not retrieve certificate.');
  //     return Promise.reject(new Error('Could not retrieve certificate.'));
  //   }
  // }
  return response;
}, error => {
  return Promise.reject(error);
});

五、如何防止证书钉扎被绕过?

虽然证书钉扎不是万能的,但我们可以采取一些措施来提高其安全性:

  1. 使用 Native Module 实现证书钉扎: 尽量使用 Native Module 实现证书钉扎,因为原生代码更难被修改。
  2. 代码混淆: 对 App 的代码进行混淆,增加逆向工程的难度。
  3. 完整性校验: 对 App 的代码进行完整性校验,防止代码被篡改。
  4. 定期更新证书: 定期更新服务器证书,并更新客户端的钉子。
  5. 多重验证: 结合其他安全措施,例如:双向认证、设备指纹等,提高整体安全性。
  6. 服务器端校验: 将部分校验逻辑放在服务器端,例如,在服务器端校验客户端的版本号,如果版本号过低,则拒绝服务。

六、总结

Certificate Pinning 是一种有效的安全措施,可以防止中间人攻击和 CA 沦陷等安全问题。但在移动端 JavaScript 环境下,证书钉扎也面临着被绕过的风险。我们需要采取多种措施,提高证书钉扎的安全性,并结合其他安全措施,构建更安全的应用程序。

记住,安全是一个持续的过程,没有绝对的安全。我们需要不断学习新的攻击技术和防御手段,才能更好地保护我们的应用程序。

好了,今天的讲座就到这里,谢谢大家的观看!希望大家有所收获,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注