一、为什么需要防抓包
App 与服务器之间传输的敏感数据(密码、银行卡号、聊天记录等)一旦被截获,后果严重。即便开启了 HTTPS,抓包工具(Charles、Fiddler、Burp Suite)仍能通过中间人攻击轻松拿到明文内容,因此我们需要更强的防护 ------ SSL Pinning(证书固定)。
二、HTTPS 的基础保护
HTTPS = HTTP + SSL/TLS,主要提供:
-
加密:内容加密,防止窃听。
-
身份认证:客户端验证服务器是否合法。
-
完整性:防止数据被篡改。
但默认的验证机制存在一个"信任漏洞"。
三、抓包工具是如何得手的(中间人攻击原理)
当你为抓包工具安装了自签根证书并让设备信任它后,抓包过程如下:
-
App 向
https://api.example.com发起请求。 -
请求被代理截获,代理冒充服务器,用自己的证书(由你信任的假根证书签发)回复 App。
-
系统检查证书链时,发现由用户信任的根证书签发,判定为"合法",于是 App 与代理建立了加密通道。
-
代理解密 App 的数据,再以正常客户端身份与真实服务器建立连接,返回响应。
核心问题 :Android 默认的证书验证只检查 证书链是否由设备信任的 CA 签发,并不校验证书的"唯一身份"。你只要导入一个自签根证书,就可以伪造任意域名的证书。
四、防抓包的思路:SSL Pinning(证书固定)
既然抓包工具是利用了"谁签发都行"的弱点,我们就必须锁定服务器的真证书,拒绝任何冒充者。
通俗解释:就像你第一次见一个人,不仅看他的身份证(CA 签名),还记住他的脸、声音、指纹。以后每次见面都核对这些特征,即便有人拿着假身份证也骗不了你。
SSL Pinning 有两种形式:
-
证书固定 (Certificate Pinning) :将服务器的
.crt证书文件嵌入 App,连接时逐字节比对服务器返回的证书。 -
公钥固定 (Public Key Pinning):提取证书中的公钥进行比对。证书更新时如果公钥不变,App 无需升级,兼容性更好。
五、Android 中的证书验证机制
Android 的 HTTPS 验证通过以下组件完成:
-
TrustManager :负责验证服务器证书链是否可信。系统默认的
TrustManager使用设备内置的 CA 列表(包含用户安装的 CA)。 -
HostnameVerifier:验证连接的主机名是否与证书中的 CN/SAN 匹配,防止域名劫持。
要自定义验证,我们可以替换默认的 TrustManager,让它只信任我们指定的证书。很多网络库(如 OkHttp)还提供了更便捷的封装。
六、Java 实现 SSL Pinning 的几种方式
准备工作:获取服务器证书
通过浏览器或 OpenSSL 导出服务器的证书(*.crt 或 *.pem 格式),保存到 res/raw/ 或 assets/目录。
bash
# 导出证书命令示例
openssl s_client -connect yourserver.com:443 -servername yourserver.com </dev/null 2>/dev/null | \
openssl x509 -outform PEM > res/raw/server.crt
同时获取公钥的 SHA-256 哈希(用于公钥固定):
bash
openssl s_client -connect yourserver.com:443 -servername yourserver.com </dev/null 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform der | \
openssl dgst -sha256 -binary | \
base64
# 输出类似:TJCpzk4Yl5YV4R3Z5J6...=
方案 1:自定义 TrustManager(适用原生 HttpURLConnection)
思路:创建一个只包含我们内置证书的 KeyStore,用它初始化 TrustManagerFactory,然后配置 SSLContext。
java
// 加载内置证书并创建自定义 SSLSocketFactory
public SSLSocketFactory getPinnedSSLSocketFactory(Context context) throws Exception {
// 1. 读取内置证书文件 (res/raw/server.crt)
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate ca;
try (InputStream certInput = context.getResources().openRawResource(R.raw.server)) {
ca = cf.generateCertificate(certInput);
}
// 2. 创建一个 KeyStore 并将证书放入信任区
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null); // 初始化空仓库
keyStore.setCertificateEntry("server", ca);
// 3. 使用该 KeyStore 创建 TrustManager
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);
// 4. 初始化 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);
return sslContext.getSocketFactory();
}
// 发起 HTTPS 请求时使用
URL url = new URL("https://yourserver.com/api");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(pinnedSSLSocketFactory);
// 可选:自定义 HostnameVerifier
conn.setHostnameVerifier((hostname, session) -> hostname.equals("yourserver.com"));
要点说明:
-
我们用自己的
KeyStore只包含一张证书,TrustManager就只信任这一张,其他任何证书都会导致握手失败。 -
一定要设置主机名验证,防止中间人用假证书但主机名不匹配(虽然通常抓包工具会伪造匹配的主机名)。
-
此方法也适用于 OkHttp(通过
SSLSocketFactory传入)。
方案 2:使用 OkHttp 的 CertificatePinner(极简推荐)
OkHttp 提供了一个 CertificatePinner,只需配置公钥哈希即可完成固定,无需手动加载证书文件。
java
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(new CertificatePinner.Builder()
.add("yourserver.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
// 可添加备份公钥,防止证书更新后无法连接
.add("yourserver.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build())
.build();
获取哈希字符串 :用上方 OpenSSL 命令输出的 Base64 值,前面加上 sha256/ 前缀即可。
原理:OkHttp 在每次连接时比较服务器证书链中的公钥哈希是否与预设值匹配,完全由库处理,无需关心 TrustManager。
方案 3:Network Security Config(Android 7.0+ 声明式固定)
在 res/xml/network_security_config.xml 中配置:
xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">yourserver.com</domain>
<pin-set expiration="2025-12-31">
<!-- SPKI 哈希值,使用上面 openssl 命令生成 -->
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- 备用证书的公钥哈希,用于证书平滑切换 -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
然后在 AndroidManifest.xml 中引用:
xml
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
</application>
优点 :无需编写代码,系统自动在 TLS 握手阶段验证。
缺点:仅支持 Android 7.0 (API 24) 及以上,且灵活性不如代码实现。
方案 4:公钥固定实现(手动提取公钥比对)
适用于需要完全控制流程的场景,逻辑如下:
-
从服务器证书提取公钥;
-
用 SHA-256 哈希,转 Base64;
-
与内置字符串比较。
可在自定义 TrustManager 的 checkServerTrusted 方法中实现:
java
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
if (chain == null || chain.length == 0) {
throw new CertificateException("Certificate chain is empty");
}
// 获取服务器返回的证书
X509Certificate cert = chain[0];
PublicKey publicKey = cert.getPublicKey();
// 计算公钥的 SHA-256 哈希
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] publicKeyHash = md.digest(publicKey.getEncoded());
String expectedPin = "你的 Base64 字符串";
String actualPin = Base64.encodeToString(publicKeyHash, Base64.NO_WRAP);
if (!expectedPin.equals(actualPin)) {
throw new CertificateException("Public key pin mismatch");
}
// 可继续执行系统默认的证书链验证(可选)
}
注意:这种实现绕过了证书链的常规验证,建议同时检查证书有效期等。
七、双向认证(客户端证书)
除服务端验证外,还可要求客户端出示证书,防止未授权 App 访问。
实现步骤:
-
将客户端证书(.p12 或 .bks)放入
res/raw。 -
加载密钥库,构建
KeyManager。 -
初始化
SSLContext时传入KeyManager。
java
public SSLContext getSSLContextWithClientCert(Context context) throws Exception {
// 加载客户端证书 (.p12)
KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
InputStream certInput = context.getResources().openRawResource(R.raw.client);
clientKeyStore.load(certInput, "your_password".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(clientKeyStore, "your_password".toCharArray());
// 同时可结合服务端证书固定
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), getPinnedTrustManagers(context), null);
return sslContext;
}
服务器也需要配置要求客户端证书,握手时客户端会自动提供。
八、常见问题与防护的局限性
-
证书过期 :硬编码证书或公钥后,服务器证书更新可能造成 App 无法连接。
对策:提前内置备用公钥,或实现动态更新机制(但需考虑安全分发)。 -
反编译提取 :asset/raw 中的证书文件或代码中的字符串可被反编译获取。
对策:对存储内容进行加密、代码混淆、运行时动态解密,增加逆向难度。 -
Hook 绕过 :攻击者可能通过 Xposed/Frida 等框架 Hook 掉证书验证函数,直接返回验证通过。
对策:加入完整性校验(检测 Xposed 环境)、关键逻辑用 C/C++ 实现、多重校验等。 -
系统根证书问题:Android 7.0+ 默认不信任用户安装的 CA,但抓包工具可通过 root 权限安装为系统证书,或利用 VirtualApp 绕过。SSL Pinning 可防御此类攻击。
-
HostnameVerifier 被忽略 :许多开发者为了省事,设置
hostnameVerifier返回true,这等同于放弃主机名验证,绝对不能出现在正式代码中。
九、总结
-
Android 默认的 HTTPS 验证依赖设备信任的 CA,容易被导入的自签根证书劫持。
-
SSL Pinning 通过固定在 App 内的特定证书或公钥,有效防御中间人攻击。
-
实现方式多种:自定义
TrustManager、OkHttp 的CertificatePinner、Network Security Config(推荐组合使用)。 -
结合双向认证、代码保护等手段,可以显著提升应用通信安全。
实践建议:
-
对核心敏感接口,使用 证书固定 + 公钥固定 双保险。
-
预留备用公钥,避免证书更新导致大面积崩溃。
-
千万不要随意放行主机名验证。