各位观众,晚上好!我是今晚的主讲人,江湖人称“代码老司机”,今天咱们聊点刺激的—— 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 等),实现证书钉扎通常有两种方式:
- Native Module (原生模块): 通过编写 Native Module,调用原生 API (例如:Android 的
TrustManager
、iOS 的SecTrustEvaluateWithError
) 来实现证书钉扎。 - Network Interceptor (网络拦截器): 利用网络请求库(例如:
fetch
、axios
)提供的拦截器机制,在请求发送前或响应返回后,对证书进行校验。
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);
});
四、绕过证书钉扎的几种姿势 (咳咳,仅供学习研究)
注意:以下内容仅供安全研究和学习之用,请勿用于非法用途。
虽然证书钉扎可以提高安全性,但也不是坚不可摧的。黑客们会想尽办法绕过它。常见的绕过方式有:
- Root/Jailbreak + Hook: 在 Root/Jailbreak 的设备上,可以使用 Hook 技术 (例如:Frida、Xposed) 修改 App 的代码,绕过证书校验逻辑。
- 中间人攻击 (MITM): 虽然证书钉扎可以防止一般的 MITM 攻击,但如果黑客能够控制客户端的网络环境,或者使用更高级的 MITM 技术,仍然有可能绕过证书钉扎。
- 动态调试: 使用调试器 (例如:GDB、LLDB) 动态调试 App,找到证书校验的代码,并修改其行为。
- 反编译 + 修改: 将 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);
});
五、如何防止证书钉扎被绕过?
虽然证书钉扎不是万能的,但我们可以采取一些措施来提高其安全性:
- 使用 Native Module 实现证书钉扎: 尽量使用 Native Module 实现证书钉扎,因为原生代码更难被修改。
- 代码混淆: 对 App 的代码进行混淆,增加逆向工程的难度。
- 完整性校验: 对 App 的代码进行完整性校验,防止代码被篡改。
- 定期更新证书: 定期更新服务器证书,并更新客户端的钉子。
- 多重验证: 结合其他安全措施,例如:双向认证、设备指纹等,提高整体安全性。
- 服务器端校验: 将部分校验逻辑放在服务器端,例如,在服务器端校验客户端的版本号,如果版本号过低,则拒绝服务。
六、总结
Certificate Pinning 是一种有效的安全措施,可以防止中间人攻击和 CA 沦陷等安全问题。但在移动端 JavaScript 环境下,证书钉扎也面临着被绕过的风险。我们需要采取多种措施,提高证书钉扎的安全性,并结合其他安全措施,构建更安全的应用程序。
记住,安全是一个持续的过程,没有绝对的安全。我们需要不断学习新的攻击技术和防御手段,才能更好地保护我们的应用程序。
好了,今天的讲座就到这里,谢谢大家的观看!希望大家有所收获,下次再见!