Android HTTPS 防抓包原理与实现(Java)

一、为什么需要防抓包

App 与服务器之间传输的敏感数据(密码、银行卡号、聊天记录等)一旦被截获,后果严重。即便开启了 HTTPS,抓包工具(Charles、Fiddler、Burp Suite)仍能通过中间人攻击轻松拿到明文内容,因此我们需要更强的防护 ------ SSL Pinning(证书固定)


二、HTTPS 的基础保护

HTTPS = HTTP + SSL/TLS,主要提供:

  • 加密:内容加密,防止窃听。

  • 身份认证:客户端验证服务器是否合法。

  • 完整性:防止数据被篡改。

但默认的验证机制存在一个"信任漏洞"。


三、抓包工具是如何得手的(中间人攻击原理)

当你为抓包工具安装了自签根证书并让设备信任它后,抓包过程如下:

  1. App 向 https://api.example.com 发起请求。

  2. 请求被代理截获,代理冒充服务器,用自己的证书(由你信任的假根证书签发)回复 App。

  3. 系统检查证书链时,发现由用户信任的根证书签发,判定为"合法",于是 App 与代理建立了加密通道。

  4. 代理解密 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:公钥固定实现(手动提取公钥比对)

适用于需要完全控制流程的场景,逻辑如下:

  1. 从服务器证书提取公钥;

  2. 用 SHA-256 哈希,转 Base64;

  3. 与内置字符串比较。

可在自定义 TrustManagercheckServerTrusted 方法中实现:

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 访问。

实现步骤:

  1. 将客户端证书(.p12 或 .bks)放入 res/raw

  2. 加载密钥库,构建 KeyManager

  3. 初始化 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;
}

服务器也需要配置要求客户端证书,握手时客户端会自动提供。


八、常见问题与防护的局限性

  1. 证书过期 :硬编码证书或公钥后,服务器证书更新可能造成 App 无法连接。
    对策:提前内置备用公钥,或实现动态更新机制(但需考虑安全分发)。

  2. 反编译提取 :asset/raw 中的证书文件或代码中的字符串可被反编译获取。
    对策:对存储内容进行加密、代码混淆、运行时动态解密,增加逆向难度。

  3. Hook 绕过 :攻击者可能通过 Xposed/Frida 等框架 Hook 掉证书验证函数,直接返回验证通过。
    对策:加入完整性校验(检测 Xposed 环境)、关键逻辑用 C/C++ 实现、多重校验等。

  4. 系统根证书问题:Android 7.0+ 默认不信任用户安装的 CA,但抓包工具可通过 root 权限安装为系统证书,或利用 VirtualApp 绕过。SSL Pinning 可防御此类攻击。

  5. HostnameVerifier 被忽略 :许多开发者为了省事,设置 hostnameVerifier 返回 true,这等同于放弃主机名验证,绝对不能出现在正式代码中


九、总结

  • Android 默认的 HTTPS 验证依赖设备信任的 CA,容易被导入的自签根证书劫持。

  • SSL Pinning 通过固定在 App 内的特定证书或公钥,有效防御中间人攻击。

  • 实现方式多种:自定义 TrustManager、OkHttp 的 CertificatePinnerNetwork Security Config(推荐组合使用)。

  • 结合双向认证、代码保护等手段,可以显著提升应用通信安全。

实践建议

  • 对核心敏感接口,使用 证书固定 + 公钥固定 双保险。

  • 预留备用公钥,避免证书更新导致大面积崩溃。

  • 千万不要随意放行主机名验证。

相关推荐
真恋寄语枫秋2 小时前
【Java零基础入门22】Java注解完整详解:内置注解、元注解、自定义注解
java
nvd112 小时前
极客指南:利用 OpenClaw + Termux + Shizuku 实现安卓设备的降维远程接管
android
敲代码的鱼哇5 小时前
PDF 预览与签名批注写回 支持安卓 iOS 鸿蒙 UTS插件
android·ios·pdf·鸿蒙·harmony
松仔log7 小时前
JetPack——Paging3+Room
android·java·zoom
Lei活在当下12 小时前
先用起来,再理解,关于协程Coroutine应该知道的事
android·java·jvm
Java爱好狂.12 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
kernelcraft12 小时前
cuongpmyoutube-dl-android:多平台视频下载的Android客户端
android·其他
tongluowan00713 小时前
以ReentrantLock为例解释AQS的工作流程
java·模板方法模式·aqs·reentrantlock
佚泽13 小时前
Android Studio 如何配置gradle
android·ide·android studio