首页
社区
课程
招聘
[原创]Android APP 抓包学习笔记
发表于: 5天前 1156

[原创]Android APP 抓包学习笔记

5天前
1156

免责声明:本文内容仅供安全学习和研究目的,旨在帮助读者理解网络协议和抓包技术的原理。请勿将本文所述技术用于未经授权的网络活动。未经明确授权对他人系统或应用进行抓包、逆向等操作可能违反相关法律法规。读者应自行承担因使用本文内容而产生的一切法律责任。

要理解 Android App 抓包,首先需要理解网络协议栈——数据如何从应用层层封装到网络上传输,以及 TLS 如何在其中提供安全保障。本文从协议基础出发,深入 TLS 原理,再到抓包实战与攻防对抗,系统性地梳理 Android App 抓包的完整知识体系。

一、常见协议

1.1 协议栈模型

网络通信的核心是分层协议栈。业界有两种主流模型:

        OSI 七层模型              TCP/IP 四层模型
    ┌─────────────────┐
    │    应用层        │
    ├─────────────────┤       ┌─────────────────┐
    │    表示层        │       │    应用层        │
    ├─────────────────┤       │  (HTTP/TLS/DNS)  │
    │    会话层        │       └────────┬────────┘
    ├─────────────────┤                │
    │    传输层        │       ┌────────┴────────┐
    │   (TCP/UDP)     │       │    传输层        │
    ├─────────────────┤       │   (TCP/UDP)      │
    │    网络层        │       ├─────────────────┤
    │    (IP)         │       │    网络层        │
    ├─────────────────┤       │    (IP)          │
    │   数据链路层     │       ├─────────────────┤
    ├─────────────────┤       │  网络接口层      │
    │    物理层        │       │ (以太网/Wi-Fi)   │
    └─────────────────┘       └─────────────────┘

实际工程中普遍使用 TCP/IP 四层模型。理解分层对抓包很重要:不同的抓包工具工作在不同的层级,决定了它能捕获什么样的数据。

为什么不自定义协议?

一些开发者可能会想:能否自定义加密协议来保护通信?答案是不推荐,原因有二:

  1. 算法层面:密码学算法的安全性需要经过大量数学论证和长期实践检验,自研算法往往存在未知漏洞
  2. 开发层面:即使使用标准算法,实现中的细节错误(如随机数生成不当、padding oracle 漏洞等)也会导致安全性崩塌

因此,业界共识是使用经过充分审计的标准协议(如 TLS)和成熟的开源库(如 OpenSSL、BoringSSL)。

1.2 TCP 与 UDP

传输层的两个基础协议,它们的特性决定了上层协议的设计选择:

特性 TCP UDP
连接方式 三次握手建立连接 无连接
可靠性 可靠(重传、确认机制) 不可靠(不重传)
有序性 有序传输 无序
效率 开销较大 高效、低延迟
典型应用 HTTP、HTTPS、FTP DNS、视频流、游戏、WebRTC

1.3 应用层协议

HTTP 与 HTTPS

HTTP 是应用层的核心协议,HTTPS 则是在 HTTP 和 TCP 之间加入了 TLS 层。

HTTP/1.1 vs HTTP/2

特性 HTTP/1.1 HTTP/2
传输格式 文本协议 二进制帧
多路复用 不支持(一个连接一个请求) 支持(一个连接多个流)
头部压缩 HPACK 压缩
服务器推送 不支持 支持
队头阻塞 存在 应用层解决(TCP 层仍存在)

HTTP/2 的二进制帧格式和多路复用机制使得抓包分析更为复杂,需要抓包工具支持 HTTP/2 解析。

WebSocket

HTTP 协议是无状态的——每次请求都是独立的,服务器不会记住之前的请求。HTTP/1.1 虽然引入了 Keep-Alive 复用 TCP 连接,但本质上仍然是"请求-响应"模式:客户端不发请求,服务器就无法主动推送数据。

这在实时场景中问题很大:聊天、实时推送、协作编辑等场景需要服务端主动向客户端推送消息。传统的解决方案(如轮询、长轮询)要么浪费带宽,要么延迟高,每次都要重新建立 HTTP 请求的开销。

WebSocket 就是为解决这个问题而生的。它通过一次 HTTP 握手升级为持久的双向通道,之后双方可以随时互发消息,不再需要反复建立连接:

    HTTP 轮询                          WebSocket

Client ──req──> Server          Client <===========> Server
Client <──res── Server            持久双向连接
Client ──req──> Server            任意一方随时发送
Client <──res── Server            无需反复握手
  (每次都要重新请求)               (一次握手,持续通信)
  • ws://:明文传输
  • wss://:TLS 加密

HTTP 升级为 WebSocket 的握手过程

# 客户端请求升级
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: &lt;base64-encoded-key&gt;

# 服务端同意升级
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

握手完成后,连接不再使用 HTTP 帧,而是切换为 WebSocket 帧格式:

操作码 类型 说明
0x1 文本帧 UTF-8 编码的文本数据
0x2 二进制帧 二进制数据
0x8 关闭帧 任意一方可主动关闭
0x9 Ping 心跳检测
0xA Pong 心跳响应

Protobuf

为什么需要 Protobuf

App 与服务端通信时,需要把结构化数据(如用户信息、订单详情)序列化后通过网络传输。常见的方案有 JSON 和 Protobuf:

JSON(文本格式):
{"id":150,"name":"hi!","timestamp":123456}
→ 41 字节,人类可读,但体积大、解析慢

Protobuf(二进制格式):
08 96 01 12 03 68 69 21 18 C0 C4 07
→ 12 字节,不可读,但体积小、解析快

同样的数据,Protobuf 只需 JSON 约 1/3 的体积。在移动端场景下,更小的包体意味着更省流量和更低延迟,这就是为什么大量 App(尤其是 Google 系、社交、游戏类)选择 Protobuf 作为通信格式。

Protobuf 通常工作在 HTTP/HTTPS 之上(作为请求/响应的 body),也常用于 gRPC(基于 HTTP/2 的 RPC 框架)。抓包时看到的 HTTP body 是一堆不可读的二进制数据,就很可能是 Protobuf。

基本使用

首先定义 .proto 文件描述数据结构:

// demo.proto
syntax = "proto3";

message Demo {
    int32  id        = 1;   // 字段编号,不是默认值
    string name      = 2;
    int64  timestamp = 3;
}
# 编译为 Python 代码
protoc --python_out=. demo.proto
# 序列化
from demo_pb2 import Demo
demo = Demo(id=150, name="hi!", timestamp=123456)
data = demo.SerializeToString()  # → 二进制 bytes

# 反序列化
demo2 = Demo()
demo2.ParseFromString(data)
print(demo2.id, demo2.name)  # → 150 hi!

Wire Format(二进制编码格式)

Protobuf 序列化后的数据由一系列 tag + value 组成,没有分隔符,紧密排列:

┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│  tag    │  value  │  tag    │  value  │  tag    │  value  │
│(varint) │         │(varint) │         │(varint) │         │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
  字段1                字段2                字段3

tag 的编码tag = (field_number << 3) | wire_type

wire_type 含义 示例类型
0 Varint(变长整数) int32, int64, bool
1 64-bit 固定长度 fixed64, double
2 Length-delimited(带长度前缀) string, bytes, 嵌套消息
5 32-bit 固定长度 fixed32, float

编码示例int32 a = 1; 设置 a = 150 后序列化得到 08 96 01

08 = (1 << 3) | 0  → field_number=1, wire_type=0(varint)

96 01 的 Varint 解码:
  96 = 1001_0110  →  最高位1表示后续还有字节,有效位:001_0110
  01 = 0000_0001  →  最高位0表示结束,有效位:000_0001

  小端拼接(低位在前):000_0001  001_0110 = 1001_0110 = 150

Length-delimited 类型的格式为:tag + length(varint) + payload,例如 string name = 2; 设置 name = "hi!" 后编码为:

12 03 68 69 21
│  │  └──────── payload: "hi!" 的 ASCII(3 字节)
│  └─────────── length: 3(varint)
└────────────── tag: (2 << 3) | 2 = 0x12,field_number=2, wire_type=2

注意:Protobuf 的二进制数据中不包含字段名称,只有字段编号和类型信息。所以没有 .proto 定义文件时,反序列化只能得到 field_1field_2 这样的编号,无法知道字段的语义。

逆向 Protobuf 结构

抓包拿到 Protobuf 二进制数据后,如果没有 .proto 定义文件,需要逆向还原结构。

第一步:从二进制数据推测结构

使用 protobuf-inspector 可以直接解析原始二进制数据,推测字段类型:

# 将抓包得到的二进制数据传入
echo -ne '\x08\x96\x01\x12\x03\x68\x69\x21\x18\xc0\xc4\x07' | protobuf_inspector

# 输出类似:
# 1: 150          (varint)
# 2: "hi!"        (string)
# 3: 123456       (varint)

这一步能得到字段编号和大致类型,但无法确定字段名称和精确类型(比如分不清 int32 和 int64)。

第二步:从二进制文件中恢复完整定义

在 App 的 native 库(.so)中,protoc 编译器为每个 Protobuf 消息生成了 C++ 类,这些类包含丰富的类型信息:

  1. .data.rel.ro 段中查找 RTTI 类型信息,可能找到 proto 消息的虚表
  2. 虚表中包含关键函数,可用于恢复完整结构

Protobuf 消息虚表中的关键函数偏移:

偏移 函数 用途
0x10 GetTypeName() 返回消息类型名(如 user.UserLogin),最容易定位
0x50 _InternalParse() 反序列化核心,包含所有字段的编号和类型信息
0x60 _InternalSerialize() 序列化核心,同样包含完整的字段信息

第三步:借助 AI 完成还原

手动分析反汇编代码恢复 proto 结构非常繁琐,但这类工作非常适合 AI。推荐的工作流程:

  1. 通过虚表中的 GetTypeName()(偏移 0x10)定位目标消息类型
  2. 找到对应的 _InternalParse(偏移 0x50)或 _InternalSerialize(偏移 0x60)函数,在 IDA 中复制反汇编/伪代码
  3. 同时用 protobuf-inspector 解析抓包得到的二进制数据
  4. 将反汇编代码、抓包原始数据、protobuf-inspector 解析结果、接口 URL 等上下文一起交给 AI 分析:
这是接口 /api/user/login 的 Protobuf 请求体。

1. 抓包原始 hex:08 96 01 12 03 68 69 21 18 C0 C4 07
2. protobuf-inspector 解析结果:
   1: 150          (varint)
   2: "hi!"        (string)
   3: 123456       (varint)
3. 以下是该消息的 _InternalSerialize 函数反编译代码:
   [粘贴 IDA 伪代码]

请结合以上信息,还原出完整的 .proto 定义,包括字段名称和精确类型。

反汇编代码提供字段编号和精确类型,抓包数据和 protobuf-inspector 提供实际值用于验证和推测字段语义,两者结合可以得到最准确的还原结果。

此外,protoc 为每个字段生成了 get/set 函数(如 set_name()id()),在 IDA 中通过交叉引用找齐所有相关函数,同样可以交给 AI 进行关联分析。


二、TLS 协议

TLS(Transport Layer Security)是互联网安全通信的基石。理解 TLS 是进行 HTTPS 抓包的前提。

2.1 公钥密码体系

对称加密

最直观的加密方式是对称加密:加密和解密使用同一把密钥。

发送方                                    接收方
"Hello" ──[密钥K加密]──> "x7#f!" ──[密钥K解密]──> "Hello"

常见的对称加密算法:

算法 密钥长度 特点
AES-128/256 128/256 bit 当前最主流,安全高效
ChaCha20 256 bit 移动端友好,无需硬件加速
DES/3DES 56/168 bit 已淘汰,密钥太短

对称加密速度快、效率高,TLS 建立连接后的数据传输阶段就是使用对称加密(如 AES-GCM)。

但对称加密有一个根本问题:双方必须事先共享同一把密钥。在互联网场景中,客户端和服务器之间没有安全的信道来交换密钥——如果密钥在网络上传输,就可能被窃听者截获。

非对称加密(公钥密码体系)

公钥密码体系解决了密钥交换问题:每个人拥有一对密钥(公钥 + 私钥),公钥公开,私钥保密。公钥和私钥是数学上配对的,用其中一把处理过的数据,只有另一把能还原。这一特性产生了两种核心用法:

用法一:加密通信(公钥加密,私钥解密)

目的:保密性 —— 确保只有接收方能读取内容。

    发送方(Alice)                       接收方(Bob)
  ┌──────────┐                        ┌──────────┐
  │ "Hello"  │                        │ 密文     │
  │    ↓     │     ┌──────────┐       │    ↓     │
  │  加密  ←─┼─────┤ Bob 公钥 │       │  解密  ←─┼── Bob 私钥(仅 Bob 持有)
  │    ↓     │     └──────────┘       │    ↓     │
  │  密文  ──┼──── 不安全信道 ────────>│ "Hello"  │
  └──────────┘                        └──────────┘

任何人都可以用 Bob 的公钥加密消息,但只有 Bob 用自己的私钥才能解密。即使密文被截获也无法还原。

用法二:数字签名(私钥签名,公钥验签)

目的:身份认证 + 完整性 —— 证明消息确实来自某人,且未被篡改。

    签名方(Bob)                        验证方(Alice)
  ┌──────────┐                        ┌──────────┐
  │ 原始数据 │                        │ 原始数据 │
  │    ↓     │                        │    ↓     │
  │  Hash  ──┼─→ 摘要                 │  Hash  ──┼─→ 摘要
  │    ↓     │                        │          │
  │  加密  ←─┼── Bob 私钥             │  比对  ←─┼── 摘要 == 解密结果?
  │    ↓     │                        │    ↑     │
  │  签名 ───┼──── 连同数据发送 ──────>│  解密  ←─┼── Bob 公钥
  └──────────┘                        └──────────┘

签名过程:先对数据做 Hash 得到固定长度的摘要,再用私钥加密摘要,得到签名。验签过程:用公钥解密签名得到摘要,再与数据重新 Hash 的结果比对,一致则说明:① 数据确实由私钥持有者签发(身份认证);② 数据没有被篡改(完整性)。

加密 vs 签名:本质区别

加密 签名
谁操作 发送方用对方公钥加密 发送方用自己私钥签名
谁验证 接收方用自己私钥解密 接收方用对方公钥验签
解决的问题 保密性:防窃听 身份认证 + 完整性:防伪造、防篡改
数学操作 完全相同(公私钥互操作) 完全相同(公私钥互操作)

本质上加密和签名的数学运算是一样的(都是公私钥的互操作),区别在于用哪把钥匙、由谁操作、解决什么问题

在 TLS 中两种用法都会用到:

  • 签名:服务器用私钥签名握手参数,客户端用证书中的公钥验签 → 证明服务器身份
  • 密钥协商:通过 ECDHE 协商共享密钥(而非直接用公钥加密传输密钥,以实现前向安全)

非对称加密解决了密钥交换问题,但速度比对称加密慢得多(通常慢 100~1000 倍)。因此 TLS 的实际做法是两者结合:用非对称加密(ECDHE)协商出一个共享密钥,然后用这个共享密钥进行对称加密(AES-GCM)通信。

但公钥密码体系本身面临中间人攻击:攻击者可以替换公钥,让双方以为在与对方通信,实际上都在与攻击者通信。

解决中间人攻击的演进路径

  1. HMAC 签名公钥:使用预共享密钥对公钥做 HMAC,确保公钥完整性。但 HMAC 仍依赖预共享密钥,攻击者获取后可伪造
  2. CA 证书体系:引入受信任的第三方(CA)对公钥签名,形成证书。客户端预装 CA 根证书,通过证书链验证公钥的真实性

CA(Certificate Authority)是什么?

CA 本质上是一个受信任的第三方公证机构。可以类比为现实中的公证处:

你要和陌生人签合同,怎么确认对方身份?——去公证处核实。公证处之所以可信,是因为它的资质由政府(更上级的权威)授予。

在数字世界中:

网站(example.com)想让用户信任自己的公钥
       ↓
向 CA 提交申请(CSR),CA 验证域名所有权
       ↓
CA 用自己的私钥对网站公钥签名,生成证书
       ↓
用户的浏览器/操作系统预装了 CA 的根证书(公钥)
       ↓
用户收到网站证书后,用 CA 公钥验证签名 → 确认公钥真实

为什么 CA 能防止中间人? 攻击者可以伪造公钥,但无法伪造 CA 的签名——因为 CA 的私钥只有 CA 自己持有。用户用预装的 CA 公钥验签,伪造的证书一定会验证失败。

常见的 CA 机构:

CA 说明
Let's Encrypt 免费、自动化,目前使用最广泛
DigiCert 商业 CA,常见于大型企业和金融机构
GlobalSign 历史悠久的商业 CA
Sectigo (原 Comodo) 市场份额较大的商业 CA

实际的信任体系是分层的:根 CA 不直接签发终端证书,而是签发中间 CA 证书,中间 CA 再签发网站证书,形成证书链

根 CA(Root CA)──── 离线保管私钥,预装在系统中
  └─ 中间 CA(Intermediate CA)──── 日常签发工作
       └─ 终端证书(example.com)──── 网站使用

这样即使中间 CA 被攻破,也可以吊销中间 CA 证书而不影响整个信任体系。根 CA 的私钥离线存储在硬件安全模块(HSM)中,极少使用。

2.2 证书

证书结构

一个 X.509 证书的核心字段:

Subject(主题)       证书所有者信息
Issuer(颁发者)      签发此证书的 CA
Validity(有效期)    Not Before 和 Not After
Public Key(公钥)    证书持有者的公钥
SAN(主题备用名)     支持的域名列表
Basic Constraints    CA:TRUE 表示这是 CA 证书
Key Usage            证书可用于哪些操作(签名、加密等)
CA Signature         CA 使用自己的私钥对以上内容的签名

证书签发流程:生成私钥 → 创建 CSR(含公钥、域名、私钥签名) → CA 签发证书

证书格式

格式 编码 特点
PEM Base64 -----BEGIN CERTIFICATE----- 开头,文本可读
DER 二进制 体积更小,不可直接阅读

证书校验

客户端收到服务器证书后的校验流程:

服务器证书(server.crt)
     ↓ 用中间 CA 公钥验证签名
中间 CA 证书(intermediate.crt)
     ↓ 用根 CA 公钥验证签名
根 CA 证书(root.crt)← 系统预装,信任锚点
  1. 服务端发送证书链(服务器证书 + 中间证书)
  2. 检查证书是否过期
  3. 检查域名是否匹配(CN 或 SAN)
  4. 用上级 CA 的公钥验证签名,逐级向上直到找到系统预装的根证书

证书绑定(Certificate Pinning)

证书绑定是指应用内置期望的公钥指纹(而非整个证书,因为证书会过期但公钥可以不变),在 TLS 握手时验证服务器证书的公钥是否与预期一致。

更高级的做法:

  • 客户端将获取到的公钥通过 HMAC 发送给服务端进行校验
  • 客户端主动探测获取服务端证书,再发给服务端比对

根证书存放位置

平台 位置
Windows certmgr.msc
macOS 钥匙串访问.app
Linux /etc/ssl/certs
Android 系统证书 /system/etc/security/cacerts<br>/apex/com.android.conscrypt/cacerts(Android 14+)
Android 用户证书 /data/misc/user/0/cacerts-added

Android 证书体系的关键变化

版本 变化
Android 7 (API 24) 默认不再信任用户安装的 CA 证书
Android 9 引入 Conscrypt 安全提供者(基于 BoringSSL),至今仍是默认提供者
Android 10 引入 Project Mainline,将证书存储模块化为 APEX 包
Android 14 新增 /apex/com.android.conscrypt/cacerts 路径

证书文件命名格式为 &lt;hash&gt;.0,hash 是证书 subject 的哈希值。多个证书哈希相同时后缀依次为 .1.2

2.3 椭圆曲线密码学(ECC)

TLS 中的密钥交换算法 ECDHE(Elliptic Curve Diffie-Hellman Ephemeral)基于椭圆曲线数学。

椭圆曲线方程

椭圆曲线的一般形式为 y² = x³ + ax + b,比特币和以太坊使用的 secp256k1 曲线为 y² = x³ + 7(a=0, b=7)。

              y
              |                        /
              |                      /
              |                    /
              |                  /
              |                /
              |              /
              |            /                 y² = x³ + 7 (secp256k1)
              |          /
        .-----*--------'                    * (-∛7, 0) ≈ (-1.91, 0)
       /      |                              * (0, ±√7) ≈ (0, ±2.65)
--*-----------+---------------------------- x
       \      |
        '-----*--------.
              |          \
              |            \
              |              \
              |                \
              |                  \
              |                    \
              |                      \
              |                        \

曲线关于 x 轴上下对称,左端在负 x 轴处(-∛7, 0)平滑转折,上下两支连续地向第 1、4 象限发散延伸,整体呈现向右开口的形态。

椭圆曲线上的运算

点加法:给定曲线上两点 P 和 Q,过 P、Q 做直线交曲线于第三点 R',R' 关于 x 轴的对称点即为 P + Q = R。

点倍乘(标量乘法)P = k·G = G + G + ... + G(k 次)。

  • G(基点):椭圆曲线上一个预先约定的固定点,所有参与方使用同一个 G(由曲线标准定义,如 secp256k1 规范中给出)
  • k(私钥):一个随机生成的大整数,必须保密
  • P(公钥):对 G 做 k 次点加法得到的结果点,可以公开
 点倍乘就是连续做"点加法"1·G = G                         已知起点
  2·G = G + G                     第 1 次加法
  3·G = 2·G + G                   第 2 次加法
  ...                             ...
  k·G = ?                         第 k-1 次加法 → 最终得到公钥 P

 每一次加法都会"跳"到曲线上一个看似随机的新位置:

        y
        │
        │     ● 2G            ● 5G
        │                ● 3G
        │  ● G                          ● 7G
        │            ● 4G
   ─────┼─────────────────────────── x
        │                     ● 8G
        │       ● 6G
        │

单向陷门:为什么安全?

椭圆曲线密码学的安全性在于单向陷门函数的性质:

 ┌─────────────────────────────────────────────────────────────┐
 │                                                             │
 │   正向:私钥 k  ──→  公钥 P = k·G         ✅ 非常容易       │
 │                                                             │
 │   就是做 k 次点加法,使用"倍加-累加"算法                       │
 │   即使 k 是 256 位大数,也只需约 256 次运算                    │
 │                                                             │
 │─────────────────────────────────────────────────────────────│
 │                                                             │
 │   逆向:公钥 P  ──→  私钥 k = ?            ❌ 几乎不可能     │
 │                                                             │
 │   已知 P 和 G,求 k 使得 P = k·G                             │
 │   这是"椭圆曲线离散对数问题"(ECDLP)                         │
 │   目前没有已知的高效算法,256 位密钥需要约 2^128 次运算         │
 │   即使全球算力集中也需要数十亿年                                │
 │                                                             │
 └─────────────────────────────────────────────────────────────┘

直觉理解:每次点加法都会跳到曲线上一个"不可预测"的位置。做了 k 次之后,最终位置 P 和起点 G 之间没有可利用的规律。正向计算像沿着路走 k 步,逆向破解像只看终点猜走了几步——而"路"是在曲线上不断折返的。

私钥 k 是随机数,公钥 P = k·G,这就是 ECC 的安全性基础。

基于 ECC 的加解密(ECIES 思路)

ECC 本身是基于点运算的,不能像 RSA 那样直接对消息加密。实际使用中通过 ECDH 密钥协商 + 对称加密 实现:

 加密过程(Alice → Bob):

 Bob 的公钥: P_b = k_b · G  (公开)
 Bob 的私钥: k_b             (保密)

 ① Alice 生成临时私钥 r(随机数),计算临时公钥 R = r·G
 ② Alice 用 Bob 的公钥计算共享点: S = r · P_b = r·k_b·G
 ③ 从 S 派生对称密钥 Key = KDF(S)
 ④ 用 Key 对消息做对称加密: 密文 = AES(Key, 明文)
 ⑤ 发送 (R, 密文) 给 Bob
         ↓
 解密过程(Bob):

 ① 收到 (R, 密文)
 ② Bob 用自己的私钥计算共享点: S = k_b · R = k_b·r·G  ← 和 Alice 算出的一样!
 ③ 从 S 派生对称密钥 Key = KDF(S)
 ④ 用 Key 解密: 明文 = AES_Dec(Key, 密文)

核心思路:ECC 负责安全地协商出一个共享密钥,实际的加解密交给对称加密完成。TLS 中的 ECDHE 正是这个思路的应用。

ECDHE 密钥交换

    客户端                                服务端
    私钥 a (随机数)                       私钥 b (随机数)
    公钥 A = a·G  ──── 交换公钥 ────>    公钥 B = b·G
                  <────────────────
    共享密钥 = a·B = a·(b·G)             共享密钥 = b·A = b·(a·G)
             = ab·G                               = ab·G  ✓ 相同!

窃听者只能获得 A 和 G,无法推算出 a,因此无法计算出共享密钥。共享密钥再通过 KDF(密钥派生函数)生成实际的会话密钥。

ECDHE 中的 E(Ephemeral) 表示每次握手都生成新的临时密钥对,即使长期私钥泄露,历史会话也无法被解密——这就是前向安全性

2.4 TLS 握手与密钥协商流程

密码套件(Cipher Suite)

密码套件是一组算法的"套餐",告诉双方:用什么方式交换密钥、用什么算法加密数据、用什么算法校验完整性。

以 TLS 1.2 的一个典型密码套件为例:

  TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
  │     │     │        │    │    │
  │     │     │        │    │    └── SHA256:PRF(伪随机函数)/ 密钥派生
  │     │     │        │    └─────── GCM:认证加密模式(同时加密+完整性校验)
  │     │     │        └──────────── AES_128:对称加密算法,128 位密钥
  │     │     └───────────────────── RSA:用 RSA 签名验证服务器身份(证书签名)
  │     └─────────────────────────── ECDHE:密钥交换算法(椭圆曲线 Diffie-Hellman)
  └───────────────────────────────── TLS:协议

简单说就是四个问题:怎么交换密钥?怎么验证身份?怎么加密数据?怎么校验完整性?

TLS 1.3 做了大幅简化——密钥交换固定为 ECDHE,身份验证从套件中分离,套件只描述对称加密和哈希:

  TLS_AES_128_GCM_SHA256
  │    │    │    │
  │    │    │    └── SHA256:HKDF 密钥派生
  │    │    └─────── GCM:认证加密模式
  │    └──────────── AES_128:对称加密
  └───────────────── TLS:协议

  密钥交换(ECDHE)和签名算法通过扩展字段单独协商

TLS 1.3 握手流程(通俗版)

用一个比喻来理解整个握手过程——两个人要在咖啡店(公共网络)交换秘密,旁边都是偷听的人

Client                                              Server
  │                                                    │
  │ ─── ClientHello ──────────────────────────────>    │
  │     "我支持这些加密方式,这是我的临时公钥 A=a·G"       │
  │                                                    │
  │ <── ServerHello ──────────────────────────────     │
  │     "我选了这个加密方式,这是我的临时公钥 B=b·G"       │
  │                                                    │
  │  ┌──────────────────────────────────────────────┐  │
  │  │  双方各自计算共享密钥:                          │  │
  │  │  Client: a·B = a·(b·G) = ab·G                │  │
  │  │  Server: b·A = b·(a·G) = ab·G  ✓ 相同!      │  │
  │  │                                               │  │
  │  │  从 ab·G 派生出 Handshake Secret              │  │
  │  └──────────────────────────────────────────────┘  │
  │                                                    │
  │     ══════ 此后所有消息用 Handshake Secret 加密 ═══  │
  │                                                    │
  │ <── Certificate ──────────────────────────────     │
  │     服务端发送证书链(服务器证书 + 中间CA证书)          │
  │                                                    │
  │  ┌──────────────────────────────────────────────┐  │
  │  │  客户端验证证书:                                │  │
  │  │  ① 检查证书是否过期                              │  │
  │  │  ② 检查域名是否匹配(SAN / CN)                  │  │
  │  │  ③ 用中间CA公钥验证服务器证书签名                  │  │
  │  │  ④ 用根CA公钥验证中间CA证书签名                   │  │
  │  │  ⑤ 根CA证书在系统预装的信任列表中 → 信任链建立     │  │
  │  └──────────────────────────────────────────────┘  │
  │                                                    │
  │ <── CertificateVerify ────────────────────────     │
  │     服务端用证书私钥对之前所有握手消息的哈希做签名       │
  │     客户端用证书中的公钥验签 → 证明服务端确实持有私钥    │
  │                                                    │
  │ <── Finished ─────────────────────────────────     │
  │     服务端发送所有握手消息的 HMAC 校验值               │
  │     客户端验证 → 确保握手过程未被篡改                  │
  │                                                    │
  │ ─── Finished ─────────────────────────────────>    │
  │     客户端发送自己的 HMAC 校验值                      │
  │                                                    │
  │  ┌──────────────────────────────────────────────┐  │
  │  │  从 Handshake Secret 派生出 Master Secret     │  │
  │  │  再从 Master Secret 派生出 Application Secret │  │
  │  └──────────────────────────────────────────────┘  │
  │                                                    │
  │ ═══════════ 用 Application Secret 加密应用数据 ════  │

证书中的公钥不用来加密数据吗?

TLS 1.3 中:不用。注意看上面的流程——在 ClientHello / ServerHello 阶段,双方就已经通过 ECDHE 交换了临时公钥并计算出共享密钥。等到 Certificate 到达时,加密密钥早已协商完成。证书中的公钥仅用于身份验证(验证 CertificateVerify 的签名),不参与加密。

TLS 1.2 RSA 模式 中:客户端会用证书中的公钥加密一个预主密钥(Pre-Master Secret) 发送给服务端,服务端用私钥解密得到预主密钥,双方再从中派生会话密钥。

TLS 1.2 RSA 模式(已被 TLS 1.3 淘汰):

Client                                          Server
  │ ─── ClientHello ────────────────────────>     │
  │ <── ServerHello + Certificate ───────────     │
  │                                               │
  │  用证书公钥加密 Pre-Master Secret              │
  │ ─── ClientKeyExchange ──────────────────>     │
  │     [Encrypted(Pre-Master Secret)]            │
  │                                               │
  │  服务端用私钥解密得到 Pre-Master Secret          │
  │  双方从 Pre-Master Secret 派生会话密钥           │

这种模式的致命缺陷:没有前向安全性。如果服务端的证书私钥日后泄露,攻击者可以解密所有历史流量(用私钥解密每个会话的 Pre-Master Secret)。而 ECDHE 每次握手都用新的临时密钥,证书私钥泄露不影响历史会话。这就是 TLS 1.3 强制使用 ECDHE 的原因。

ECC 参数在握手中的交换

回顾 2.3 节 ECDHE 的公式 P = k·G,看看各参数在 TLS 握手中是如何传递的:

ECC 参数 是什么 在哪个字段传递
G(基点) 曲线上的固定点 不直接传递。双方通过 supported_groups 扩展协商使用哪条曲线(如 x25519、secp256r1),曲线标准中已定义了 G
a(客户端私钥) 客户端生成的随机数 不传递,始终保密
A = a·G(客户端公钥) 客户端的临时公钥 ClientHello 的 key_share 扩展
b(服务端私钥) 服务端生成的随机数 不传递,始终保密
B = b·G(服务端公钥) 服务端的临时公钥 ServerHello 的 key_share 扩展
ab·G(共享密钥) 双方各自计算得到 不传递,双方独立计算得到相同结果

整个过程中,网络上只传输了公钥 A 和 B,私钥 a、b 和共享密钥 ab·G 从未出现在网络中。

从共享密钥到通信密钥:密钥派生

ECDHE 协商出的 ab·G 并不直接用于加密通信。TLS 1.3 通过 HKDF(基于 HMAC 的密钥派生函数) 逐步派生出多把不同用途的密钥:

 ECDHE 共享密钥 (ab·G)
       │
       ▼
 ┌─────────────┐
 │ HKDF-Extract │ ← 提取熵,生成统一的密钥材料
 └──────┬──────┘
        │
        ▼
   Handshake Secret ──────── 用于加密握手阶段的消息
        │                    (Certificate、Finished 等)
        │
        ▼
 ┌─────────────┐
 │ HKDF-Expand  │ ← 派生出不同用途的子密钥
 └──────┬──────┘
        │
        ▼
   Master Secret
        │
        ├──→ client_application_traffic_secret ──→ 客户端→服务端的加密密钥
        │
        ├──→ server_application_traffic_secret ──→ 服务端→客户端的加密密钥
        │
        └──→ exporter_master_secret ──→ 供应用层使用(如 EAP-TLS)

为什么不直接用 ECDHE 的共享密钥加密数据?

ECDHE 共享密钥 (ab·G) 最终通信密钥 (Application Secret)
数量 只有 1 个 多个(读/写各一个)
长度 椭圆曲线点坐标(256 bit 点) 精确匹配加密算法需求(如 AES-128 = 128 bit)
安全性 直接暴露可能泄露曲线信息 经 HKDF 处理后与原始值无数学关联
绑定握手上下文 不包含握手信息 混入了 ClientHello、ServerHello 等握手内容的哈希,防止重放攻击
密钥更新 需要重新握手 支持 KeyUpdate 消息在不重新握手的情况下更新密钥

简单说:ECDHE 只是提供了原始的共享秘密,经过密钥派生后,才变成真正安全、好用的通信密钥。

ClientHello 关键字段

加粗的字段可能被用作指纹:

  • 协议版本、随机数
  • 会话/重用信息(session_id、PSK 等)
  • 密码套件列表(Cipher Suites)
  • 压缩算法列表(TLS 1.2 及以前)
  • 扩展(Extensions):
    • SNI(server_name):一个 IP 可能托管多个 HTTPS 站点,服务器根据 SNI 返回对应证书
    • supported_groups:支持的椭圆曲线,用于 ECDHE 密钥交换
    • signature_algorithms:支持的签名算法,用于证书校验
    • ALPN:协商应用层协议(如 h2、http/1.1),避免额外往返
    • supported_versions:支持的 TLS 版本(TLS 1.3 中引入)
    • key_share:密钥交换参数,实现 1-RTT 握手
    • session_ticket:会话恢复,快速重连

ServerHello:服务端从中选择一个密码套件、返回自己的临时公钥。此时双方已拥有共享密钥。

TLS 1.3 仅支持 5 种标准密码套件,全部使用 AEAD(同时提供加密和完整性校验):

套件 特点
TLS_AES_256_GCM_SHA384 最安全
TLS_AES_128_GCM_SHA256 性能与安全的平衡
TLS_CHACHA20_POLY1305_SHA256 移动设备友好
TLS_AES_128_CCM_SHA256 IoT 场景
TLS_AES_128_CCM_8_SHA256 低功耗设备

TLS 1.3 中密码套件数量极少且全部使用 AEAD,因此密码套件本身不再是有效的指纹特征

三、抓包

3.1 传输层抓包:tcpdump

tcpdump 工作在网络层,直接捕获原始数据包。常见的抓包软件如 Reqable、Charles、Fiddler 都是应用层抓包,只能解析应用层协议(HTTP/HTTPS);而 tcpdump 能捕获 TCP/UDP 等传输层数据。

# 捕获所有网络接口的 TCP 包,保存为 pcap 文件
tcpdump tcp -i any -w capture.pcap

生成的 .pcap 文件可用 Wireshark 打开分析。

需要注意的是:TLS 握手包本质上就是 TCP 包,并且握手阶段的前几条消息(ClientHello、ServerHello)是明文传输的。用 tcpdump / Wireshark 抓到的 TCP 包可以直接看到:

 TCP 包中能看到的 TLS 信息:

 ✅ 明文可见(握手协商阶段):
    ClientHello  → 密码套件列表、SNI(域名)、supported_groups、key_share(公钥)
    ServerHello  → 选定的密码套件、key_share(公钥)

  加密不可见(Handshake Secret 加密):
    Certificate、CertificateVerify、Finished

  加密不可见(Application Secret 加密):
    应用层数据(HTTP 请求/响应等)

所以即使不做 MITM,通过 tcpdump 也能获取不少信息:目标域名(SNI)、使用的密码套件、TLS 版本、椭圆曲线类型等。这也是 JA3/JA4 指纹的数据来源——它们完全基于 ClientHello 明文字段。但要看到实际的 HTTP 请求内容,就必须解密 TLS,这就需要应用层抓包。

3.2 应用层抓包

TLS 抓包原理

MITM(中间人)代理抓包的核心原理:

               正常连接                          MITM 抓包

    Client ──────────────── Server      Client ── Proxy ── Server
              TLS                        TLS①        TLS②
                                    (代理证书)  (真实证书)
  1. 抓包工具自己充当 CA,生成根证书
  2. 用户将该根证书安装到系统信任列表
  3. 客户端连接时,抓包工具根据请求的域名动态签发自签名证书返回给客户端
  4. 客户端验证通过后,与代理建立 TLS 连接;代理再与真实服务器建立另一条 TLS 连接
  5. 代理在中间解密、记录、再加密转发

证书安装

在 Android 7+ 上,需要将抓包工具的 CA 证书安装到系统证书目录才能被信任。

方式一:修改 App 网络安全配置(重打包)

<!-- AndroidManifest.xml -->
&lt;application android:networkSecurityConfig="@xml/network_security_config"&gt;
&lt;/application&gt;
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
&lt;network-security-config&gt;
    &lt;base-config cleartextTrafficPermitted="true"&gt;
        &lt;trust-anchors&gt;
            &lt;certificates src="system" /&gt;
            &lt;certificates src="user" /&gt;
        &lt;/trust-anchors&gt;
    &lt;/base-config&gt;
&lt;/network-security-config&gt;

方式二:安装到系统证书目录(需 root)

# 转换证书格式为 PEM
openssl x509 -inform DER -in reqable.der -out reqable.pem

# 计算证书 hash 值并重命名
CERT_HASH=$(openssl x509 -inform PEM -subject_hash_old -in reqable.pem | head -1)
cp reqable.pem ${CERT_HASH}.0

# 获取 root 权限并挂载系统分区
adb root
adb shell avbctl disable-verification  # 禁用 AVB 验证
adb reboot && adb root
adb remount

# 推送证书并设置权限
adb push ${CERT_HASH}.0 /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/${CERT_HASH}.0
adb shell chown root:root /system/etc/security/cacerts/${CERT_HASH}.0
adb shell chcon u:object_r:system_file:s0 /system/etc/security/cacerts/${CERT_HASH}.0
adb reboot

方式三:tmpfs 临时挂载(remount 失败时的备选)

adb root
adb shell mkdir -p /data/local/tmp/cacerts
adb shell cp /system/etc/security/cacerts/* /data/local/tmp/cacerts/
adb push ${CERT_HASH}.0 /data/local/tmp/cacerts/

# 用 tmpfs 覆盖系统证书目录
adb shell mount -t tmpfs tmpfs /system/etc/security/cacerts
adb shell cp /data/local/tmp/cacerts/* /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/*
adb shell chown root:root /system/etc/security/cacerts/*
adb shell chcon u:object_r:system_file:s0 /system/etc/security/cacerts/*
# 注意:重启后失效,需重新执行或配置开机自动执行

方式四:Magisk 模块 AlwaysTrustUserCerts

自动将用户证书挂载到系统证书目录,无需手动操作。

DTLS 抓包

前面介绍了 TLS 的抓包原理(MITM 代理),它基于 TCP。而 DTLS(Datagram Transport Layer Security)是 UDP 版本的 TLS——在 UDP 基础上增加了加密和乱序/重传机制,常见于 WebRTC、IoT 等场景。

与 TLS 的区别:DTLS 握手多了一个 HelloVerifyRequest 步骤,用于防止 DoS 反射攻击。

两种握手方式

方式 适用场景 特点
证书交换 WebRTC 等 通常使用自签名证书,通过信令通道(SDP)交换的指纹来验证真伪
预共享密钥(PSK) IoT 等资源受限场景 双方提前配置相同的密钥和 ID,典型套件如 TLS_PSK_WITH_AES_128_GCM_SHA256

证书方式如何验证真伪? 浏览器 A 在 SDP 中告诉浏览器 B:"我的证书哈希值是 SHA-256: XX:YY:ZZ…"。DTLS 握手时 B 收到证书后计算哈希值进行比对。

DTLS 抓包可以使用 mitmproxy 的 WireGuard 模式(仅限证书场景):

mitmweb --mode wireguard -vvv --set proxy_debug=true

代理抓包

最常用的方式,通过 HTTP/SOCKS 代理捕获应用层流量。

常用工具:

  • Reqable:跨平台 GUI 工具,支持 HTTP/HTTPS 和 SOCKS5 代理
  • mitmproxy / mitmweb:开源命令行/Web 界面工具,支持脚本扩展
# mitmproxy 安装
pip install mitmproxy

# Web 界面(默认代理端口 8080,Web 端口 8081)
mitmweb --web-host 0.0.0.0

# WireGuard 模式:全局透明代理,不易被检测
mitmweb --mode wireguard --web-host 0.0.0.0

mitmweb 的优势:相比 Reqable、Charles 等工具,mitmweb 对 WebSocketDTLS 有更好的支持。Reqable/Charles 主要面向 HTTP/HTTPS 请求-响应模式,对 WebSocket 的长连接帧和 DTLS(基于 UDP)的支持有限或不支持。而 mitmweb 可以实时查看 WebSocket 的每一帧数据(文本帧、二进制帧),也支持 UDP 流量的代理和解析,适合抓取游戏、音视频通话等使用 DTLS/UDP 的场景。

VPN 抓包

代理抓包属于应用层抓包(HTTP 代理),App 可以选择不使用系统代理;VPN 抓包位于IP 层,App 无法绕过。

App 流量 → 虚拟网卡(tun0) → VPN 软件 → SOCKS5 代理 → 抓包工具 → 目标服务器

推荐的 VPN 代理工具:sockstun(搭配 Reqable 的 SOCKS5 代理使用)

如果应用检测 VPN(通过判断 tun0 网卡是否存在),可以将 VPN 部署到路由器上来规避。

iptables 抓包

通过 iptables 将流量透明重定向到 redsocks,再由 redsocks 转发到上游 SOCKS5 代理。应用完全无法感知代理的存在。

# 清空 NAT OUTPUT 链
iptables -t nat -F OUTPUT

# 回环地址不重定向(避免死循环)
iptables -t nat -A OUTPUT -d 127.0.0.1 -j RETURN

# 上游代理地址不重定向(避免死循环)
iptables -t nat -A OUTPUT -p tcp -d 192.168.1.1 --dport 1080 -j RETURN

# 将 HTTP/HTTPS 流量重定向到 redsocks 监听端口
iptables -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-ports 16666
iptables -t nat -A OUTPUT -p tcp --dport 443 -j REDIRECT --to-ports 16666

redsocks 配置(Android 预编译版本):

// redsocks.conf
base {
    log_debug = on;
    log_info = on;
    log = stderr;
    daemon = off;
    redirector = iptables;
}

redsocks {
    bind = "127.0.0.1:16666";     // 接收 iptables 重定向的流量
    relay = "192.168.1.1:1080";   // 上游 SOCKS5 代理
    type = socks5;
    autoproxy = 0;
    timeout = 13;
}
# 在 Android 设备上运行
./redsocks5 -c redsocks.conf

Hook 抓包

当常规代理方式都被绕过时,可以直接 hook 应用的网络 I/O 函数。

通用的抓包软件(如 eCapture)只能抓使用动态库实现 TLS 的应用。如果应用静态编译 TLS 库(如 Flutter 静态链接 BoringSSL),则需要自己分析定位 hook 点,可以结合 AI 分析反汇编代码。

Frida Hook send/recv

通过 Frida hook socketreadwritesendtorecvfrom 等系统调用:

const socketFds = new Set();

// hook socket() 创建,记录文件描述符
Interceptor.attach(Module.getExportByName(null, 'socket'), {
    onEnter(args) {
        this.domain = args[0].toInt32();
        this.type = args[1].toInt32();
        this.protocol = args[2].toInt32();
    },
    onLeave(retval) {
        const fd = retval.toInt32();
        if (fd >= 0) {
            socketFds.add(fd);
            console.log(`[socket] fd=${fd}, domain=${this.domain}, ` +
                        `type=${this.type}, protocol=${this.protocol}`);
        }
    }
});

// 通用数据打印函数
function tryPrintData(name, fd, buf, count) {
    try {
        if (!socketFds.has(fd)) return;
        const data = Memory.readByteArray(buf, count);
        console.log(`[${name}] fd=${fd}, size=${count}`);
        console.log(hexdump(data, {
            offset: 0, length: count, header: true, ansi: true
        }));
    } catch (e) {
        console.error(`[${name}] failed to read buffer: ` + e);
    }
}

// hook write/read/sendto/recvfrom
Interceptor.attach(Module.getExportByName(null, 'write'), {
    onEnter(args) {
        this.fd = args[0].toInt32();
        this.buf = args[1];
        this.count = args[2].toInt32();
    },
    onLeave() { tryPrintData('write', this.fd, this.buf, this.count); }
});

Interceptor.attach(Module.getExportByName(null, 'read'), {
    onEnter(args) {
        this.fd = args[0].toInt32();
        this.buf = args[1];
        this.count = args[2].toInt32();
    },
    onLeave(retval) {
        const n = retval.toInt32();
        if (n > 0) tryPrintData('read', this.fd, this.buf, n);
    }
});

Interceptor.attach(Module.getExportByName(null, 'sendto'), {
    onEnter(args) {
        tryPrintData('sendto', args[0].toInt32(), args[1], args[2].toInt32());
    }
});

Interceptor.attach(Module.getExportByName(null, 'recvfrom'), {
    onEnter(args) { this.fd = args[0].toInt32(); this.buf = args[1]; },
    onLeave(retval) {
        const len = retval.toInt32();
        if (len > 0) tryPrintData('recvfrom', this.fd, this.buf, len);
    }
});

eCapture —— 基于 eBPF 的 TLS 明文捕获

eCapture 通过 eBPF uprobe hook SSL/TLS 库的 SSL_read / SSL_write,在加密前/解密后捕获明文。

支持的 TLS 库:OpenSSL、LibreSSL、BoringSSL、GnuTLS、NSPR(NSS)

前置条件:root 权限、Linux 内核支持 eBPF

ecapture tls                            # 捕获所有 OpenSSL 流量
ecapture tls --pid 1234                 # 捕获特定进程
ecapture tls -u 0                       # 指定用户 UID
ecapture tls --hex                      # 16 进制显示
ecapture tls --pcapfile="save.pcap" -m pcap  # 保存为 pcap
ecapture tls -l /tmp/tls.log            # 保存日志

自定义协议栈

部分 App 不使用系统的 TLS 实现,而是自带 TLS 库(如 BoringSSL、Fizz 等)。好消息是这些库通常都是开源的,可以直接获取源码,结合 AI 分析证书验证逻辑和 hook 点,比纯黑盒逆向高效得多。

为什么 Flutter 难抓包?

  1. 不使用系统代理:直接使用底层 socket,不读取 Android 系统代理设置
  2. 内置证书验证逻辑:不信任用户安装的 CA 证书
  3. 内置 TLS 库:静态链接 BoringSSL,hook 系统 SSL 库的方式失效

逆向 Flutter/DIO 的 BoringSSL

  1. 使用 IDA 打开 libflutter.so,搜索 ssltls 字符串确认 SSL 库(出于安全考虑,应用通常不会自研 SSL 库)

  2. 分析源码寻找 hook 点。可以借助 AI 加速分析,将 IDA 反编译结果和相关上下文提供给 AI,提示词参考:

    我正在逆向一个使用 BoringSSL 的 Flutter 应用(libflutter.so),需要绕过 SSL 证书校验。请帮我在 BoringSSL 源码中寻找合适的 hook 点,要求:

    1. 接口清晰,修改返回值即可关闭验证
    2. 附近有特征字符串方便在 IDA 中定位
    3. 给出函数签名、返回值含义、所在源文件路径

    对于不熟悉的 TLS 库也是同样的思路——将反编译代码和字符串特征交给 AI,让它帮你定位验证函数和绕过方案。

  3. BoringSSL 证书校验的关键函数:ssl_crypto_x509_session_verify_cert_chain() → 校验成功返回 1,位于全局函数表 ssl_crypto_x509_method,附近有 ssl_serverssl_client 字符串

  4. 使用 edbg 找到调用点,对返回值进行 patch;也可以 hook memcmp 等比较函数

逆向 Fizz(Facebook 的 TLS 1.3 库)

Fizz 证书校验的核心分支:

if (state.verifier()) {
    try {
        if (auto verifiedCert =
                state.verifier()->verify(state.unverifiedCertChain())) {
            newCert = verifiedCert;
        } else {
            newCert = std::move(leaf);
        }
    } catch (...) { /* 验证失败抛异常 */ }
} else {
    newCert = std::move(leaf);  // 没有 verifier,直接使用(不验证)
}

if (state.verifier()) Patch 为永假,使其走 else 分支,可跳过所有证书验证(包括证书链验证)。


四、抓包检测与绕过

4.1 代理检测

App 可以通过多种方式检测系统代理:

直接获取代理环境变量

// Java 层
String proxyHost = System.getProperty("http.proxyHost");
String proxyPort = System.getProperty("http.proxyPort");
if (proxyHost != null && proxyPort != null) {
    Log.w("Security", "Proxy detected!");
}
// Native 层获取环境变量的函数不同,但字符串一样

通过系统 API 检测

String proxyHost = Settings.Secure.getString(
    getContentResolver(), Settings.Secure.HTTP_PROXY);

选择不走代理:App 也可以直接指定不使用系统代理(如 Flutter 的 DIO 库)。

绕过方式:使用 VPN 抓包或 iptables 透明代理;也可以通过 Frida hook 检测函数(如 System.getPropertySettings.Secure.getString),使其返回空值。

4.2 VPN 检测

方式一:ConnectivityManager API(官方推荐,准确率高)

ConnectivityManager cm = (ConnectivityManager)
    getSystemService(Context.CONNECTIVITY_SERVICE);
Network[] networks = cm.getAllNetworks();
for (Network network : networks) {
    NetworkCapabilities caps = cm.getNetworkCapabilities(network);
    if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
        return true;
    }
}

方式二:网卡名称检测(启发式,可能误报/漏检)

Enumeration&lt;NetworkInterface&gt; en = NetworkInterface.getNetworkInterfaces();
while (en.hasMoreElements()) {
    NetworkInterface ni = en.nextElement();
    String name = ni.getName().toLowerCase(Locale.getDefault());
    if (name.startsWith("tun") ||     // OpenVPN, WireGuard
        name.startsWith("ppp") ||     // PPTP, L2TP
        name.startsWith("wg") ||      // WireGuard
        name.startsWith("ipsec")) {   // IPSec
        return true;
    }
}

其他检测手段

  • 路由表检测:检查路由表中是否存在 VPN 相关路由规则
  • DNS 检测:检测 DNS 服务器是否被 VPN 修改
  • 流量特征检测:分析网络流量特征判断是否经过 VPN

绕过方式:将 VPN 部署到路由器设备上,App 无法在本机检测到;也可以通过 Frida hook 检测函数(如 NetworkCapabilities.hasTransportNetworkInterface.getName),篡改返回值绕过检测。

4.3 JA3 指纹

JA3 通过提取 ClientHello 中的特定字段生成唯一指纹,用于识别客户端应用或 TLS 库:

提取字段

  1. TLS 版本
  2. 密码套件列表
  3. 扩展类型列表
  4. 椭圆曲线列表(Supported Groups)
  5. 椭圆曲线点格式(EC Point Formats)

将以上字段转换为十进制,按固定格式拼接,做 MD5 得到 32 位十六进制 JA3 指纹。Wireshark 可直接识别。

服务端识别出指纹异常后通常不会立即拒绝,而是标记账号。App 比 Web 对协议栈的控制更底层,未来 TLS 指纹对抗会更加激烈。

服务端指纹

客户端也可以检测服务端的 Server Hello 指纹。TLS 协议栈通常不支持直接获取 Server Hello,常用探测法

  • 实现一个简易 TLS 协议专门用于探测(不用于实际通信,保证安全性)
  • 将服务端指纹通过 HMAC 发送给服务端进行互相验证
  • 使用 stackplz / edbg 通过内存断点定位相关代码

uTLS —— 伪造 TLS 指纹

uTLS 是 Go 标准 TLS 库的 fork,提供对 ClientHello 的低级访问,内置多种浏览器指纹:

import "github.com/refraction-networking/utls"

// 内置指纹
uconn := utls.UClient(conn, config, utls.HelloChrome_Auto)
uconn := utls.UClient(conn, config, utls.HelloFirefox_Auto)
uconn := utls.UClient(conn, config, utls.HelloIOS_Auto)
uconn := utls.UClient(conn, config, utls.HelloAndroid_11_OkHttp)

// 完全自定义指纹
conn := utls.UClient(conn, config, utls.HelloCustom)
spec := utls.ClientHelloSpec{
    TLSVersMin: utls.VersionTLS13,
    TLSVersMax: utls.VersionTLS13,
    CipherSuites: []uint16{
        0x1302, // TLS_AES_256_GCM_SHA384
    },
    CompressionMethods: []byte{0},
    Extensions: []utls.TLSExtension{
        &utls.SupportedCurvesExtension{Curves: []utls.CurveID{utls.X25519}},
        &utls.SupportedPointsExtension{SupportedPoints: []byte{0}},
        &utls.SignatureAlgorithmsExtension{
            SupportedSignatureAlgorithms: []utls.SignatureScheme{
                utls.ECDSAWithP256AndSHA256,
            },
        },
        &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13}},
        &utls.KeyShareExtension{KeyShares: []utls.KeyShare{
            {Group: utls.X25519},
        }},
    },
}
conn.ApplyPreset(&spec)

4.4 证书绑定(SSL Pinning)

单向认证

标准 HTTPS 即为单向认证:客户端验证服务器身份。证书绑定在此基础上更进一步,不仅验证证书链,还要验证公钥指纹是否与预期一致。

双向认证(mTLS)

服务器也要验证客户端身份。客户端持有含私钥的证书:

  • Android Java 中通常为 .p12 格式(通常有密码保护)
  • Python/curl/openssl 通常使用 PEM 格式,需要 openssl 命令转换

提取客户端证书的方法

  • 通过 Hook App 提取证书和密码(OkHttp 如有混淆,可通过字符串定位)
  • 通过内存特征搜索,从进程内存中提取证书
  • 提取后配置给抓包工具,指定密码和域名

4.5 其他对抗手段

自定义 TLS 扩展

OpenSSL 支持通过 SSL_CTX_add_custom_ext 在 ClientHello 中携带自定义数据(如设备 ID、混淆密钥)。

// 注册自定义扩展(类型 0x1234)
SSL_CTX_add_custom_ext(
    ctx, 0x1234, SSL_EXT_CLIENT_HELLO,
    obfs_add_ext_ex, obfs_free_ext_ex, nullptr,
    obfs_parse_ext_ex, nullptr
);

// 回调:添加 2 字节负载 [enabled | xor_mask]
static int obfs_add_ext_ex(SSL* ssl, unsigned int ext_type,
    unsigned int context, const unsigned char** out, size_t* out_len,
    X509*, size_t chainidx, int* out_alert, void* add_arg) {
    if (ext_type != OBFUSCATION_EXTENSION_TYPE) return 0;
    unsigned char* buf = (unsigned char*)OPENSSL_malloc(2);
    if (!buf) return 0;
    buf[0] = 1;        // enabled
    buf[1] = XOR_MASK; // 混淆密钥
    *out = buf;
    *out_len = 2;
    return 1;
}

对抗意义:MITM 工具不会转发自定义扩展,服务端据此检测中间人;模拟发包缺少自定义扩展会被标记为异常。

自定义 BIO 流量混淆

BIO 是 OpenSSL 的 I/O 抽象层,可以像管道一样串联。利用自定义 BIO Filter 可以在 TLS 密文之上再做一层混淆(如 XOR):

SSL_write(ssl, data, len)
    ↓ [SSL 加密] → TLS 密文
    ↓ BIO_write(filter) → XOR 混淆
    ↓ BIO_write(socket) → send() → 网络
// XOR 混淆 BIO Filter
static int obfs_write(BIO* b, const char* data, int len) {
    BIO* next = BIO_next(b);
    if (!next || len <= 0) return 0;
    uint8_t* tmp = (uint8_t*)OPENSSL_malloc((size_t)len);
    if (!tmp) return 0;
    memcpy(tmp, data, (size_t)len);
    obfs_xor_fixed(tmp, (size_t)len);
    int ret = BIO_write(next, (const char*)tmp, len);
    OPENSSL_free(tmp);
    return ret;
}

抓包方式:只能 hook SSL_write / SSL_read 捕获明文(下载源码通过调试信息中的 SSL_write 字符串定位函数)。

密钥导出

TLS 1.3 支持通过 ExportKeyingMaterial 导出派生密钥(非通信密钥),不同 label 产生不同密钥。

应用场景:即使攻破了证书验证、对齐了各种指纹,客户端仍可在请求中附加基于导出密钥的 HMAC,服务端校验 HMAC 来确认 TLS 通道是真正的端到端连接。

// Go 语言
key, err := connState.ExportKeyingMaterial(
    "EXPORTER-Channel-Binding",    // label
    []byte("tls-fingerprint-lab"), // context
    32,                             // key length
)
// OpenSSL / C 语言
unsigned char key[32];
SSL_export_keying_material(
    ssl, key, 32,
    "EXPORTER-Channel-Binding", strlen("EXPORTER-Channel-Binding"),
    (unsigned char*)"tls-fingerprint-lab", strlen("tls-fingerprint-lab"),
    1  // use_context
);

绕过思路:密钥导出依赖 TLS 库内部的 ExportKeyingMaterial / SSL_export_keying_material 函数,MITM 代理无法伪造(因为代理和真实服务器的 TLS 会话不同,派生出的密钥必然不同)。只能通过分析和 hook:定位 App 中调用导出密钥的位置,hook 该函数使其返回预期值,或者 hook 上层 HMAC 校验逻辑使其始终通过。

其他指纹技术

指纹类型 基于 说明
HTTP/2 指纹 HTTP/2 SETTINGS 帧、窗口大小、优先级 不同客户端的 HTTP/2 参数差异明显
TCP 指纹 TCP 握手参数(窗口大小、MSS、TTL 等) p0f 等工具可识别操作系统
行为指纹 请求时序和请求模式 爬虫 vs 真实用户的行为差异
TLS 扩展指纹 更细粒度的 TLS 特征 扩展顺序、GREASE 值等

五、抓包思路

经过前面的学习,我们对 Android 抓包的原理和方法有了系统性的了解。当碰到一个 App 无法抓到包时,可以按以下流程逐步分析和突破:

5.1 第一步:判断流量去哪了

首先确认 App 的网络请求是否真正发出,以及走的是什么协议:

tcpdump 抓取原始流量
  │
  ├── 有流量 → 确认目标 IP 和端口
  │     ├── 443 端口 → HTTPS(常规 TLS)
  │     ├── 非标准端口 → 可能是自定义协议、DTLS、QUIC 等
  │     └── UDP 流量 → 可能是 DTLS、QUIC、自定义 UDP 协议
  │
  └── 无流量 → 检查是否有网络权限、是否离线缓存

5.2 第二步:尝试常规代理抓包

使用 Reqable / Charles / mitmproxy 等代理工具,设置系统代理:

设置系统代理
  │
  ├── 能抓到包 → 直接分析(最简单的情况)
  │
  └── 抓不到包 → 分析原因:
        │
        ├── App 不走系统代理?
        │     → 尝试 VPN 抓包或 iptables 透明代理
        │
        ├── 证书错误 / 连接失败?
        │     → 证书未安装或未被信任
        │     → 尝试安装 CA 到系统证书目录
        │
        └── 连接成功但无数据?
              → 可能是非 HTTP 协议(WebSocket、gRPC、自定义协议)

5.3 第三步:解决证书信任问题

如果代理工具抓不到包:

证书不被信任
  │
  ├── Android 7+ 默认不信任用户证书
  │     ├── 方案 A:重打包,修改 networkSecurityConfig 信任用户证书
  │     ├── 方案 B:root 后安装到系统证书目录
  │     ├── 方案 C:tmpfs 临时挂载
  │     └── 方案 D:Magisk 模块 AlwaysTrustUserCerts
  │
  └── 安装后仍然失败?
        → 很可能是证书绑定(SSL Pinning)

5.4 第四步:绕过证书绑定

SSL Pinning
  │
  ├── 单向认证 Pinning
  │     ├── Frida + ssl_pinning_bypass 脚本(通用方案)
  │     ├── Xposed + JustTrustMe / TrustMeAlready
  │     ├── 针对 OkHttp:hook CertificatePinner.check()
  │     └── 针对 WebView:hook X509TrustManager
  │
  └── 双向认证(mTLS)
        ├── 从 App 中提取客户端证书(.p12)和密码
        │     ├── Hook App 的 KeyStore.load() 获取密码
        │     ├── 内存搜索证书特征字节
        │     └── 反编译定位证书资源文件
        └── 将证书配置到抓包工具

5.5 第五步:对抗代理/VPN 检测

如果 App 检测到代理或 VPN 后拒绝请求:

检测对抗
  │
  ├── 代理检测
  │     ├── Hook System.getProperty() 返回 null
  │     ├── 改用 VPN 抓包(IP 层,不依赖代理设置)
  │     └── 改用 iptables 透明代理(完全不可感知)
  │
  └── VPN 检测
        ├── Hook ConnectivityManager / NetworkInterface 相关 API
        └── 将 VPN 部署到路由器上(App 在本机无法检测)

5.6 第六步:处理自定义协议栈

如果 App 使用了自定义 TLS 库(如 Flutter 静态链接 BoringSSL):

自定义 TLS 库
  │
  ├── 确认使用的 TLS 库
  │     └── IDA 打开 .so,搜索 ssl/tls/boringssl 等字符串
  │
  ├── 定位证书校验函数
  │     ├── 从开源代码分析校验流程
  │     ├── 寻找特征字符串定位函数(如 ssl_server、ssl_client)
  │     └── 结合 AI 分析反汇编代码
  │
  └── Patch / Hook 校验逻辑
        ├── 修改返回值使校验始终通过
        ├── Hook memcmp 等比较函数
        └── 使用 edbg / eBPF 定位并 patch

5.7 第七步:应对高级防护

当常规手段都被绕过后,可能面临更深层的检测:

高级防护
  │
  ├── TLS 指纹检测(JA3/JA4)
  │     ├── 使用 Wireshark 对比正常 App 和代理的 JA3 指纹
  │     ├── 使用 uTLS 模拟目标指纹
  │     └── 注意:服务端可能只是标记而非立即封禁
  │
  ├── 自定义 TLS 扩展
  │     ├── 抓包对比正常请求,识别自定义扩展字段
  │     └── 在代理工具或模拟请求中还原扩展
  │
  ├── BIO 流量混淆
  │     ├── 网络层抓包看到的是混淆后的数据
  │     └── 只能 hook SSL_write/SSL_read 获取明文
  │
  ├── 密钥导出校验
  │     ├── 需要在真实 TLS 连接上导出密钥
  │     └── MITM 代理无法生成正确的导出密钥
  │
  └── 服务端指纹校验
        ├── 客户端验证 Server Hello 指纹
        └── 需要分析校验逻辑并 hook 绕过

5.8 总结:抓包难度阶梯

难度递增 ──────────────────────────────────────────────>

  无防护        证书信任       SSL Pinning     自定义协议栈
    │              │               │               │
  直接代理      安装系统证书    Frida/Xposed    IDA逆向+Patch
  抓包即可      即可抓包        hook绕过        定位校验函数
                                  │               │
                              双向认证          TLS指纹
                              提取客户端证书     uTLS模拟
                                                  │
                                              自定义扩展/BIO
                                              密钥导出校验
                                              需要深度逆向

每一层防护都有对应的突破手段,关键在于准确判断当前卡在哪一层,然后对症下药。实际场景中往往是多种防护叠加使用,需要逐层剥离。


附录:常用命令速查

证书操作

# 生成私钥
openssl genrsa -out server.key 2048

# 生成 CSR
openssl req -new -key server.key -out server.csr

# 生成自签名 CA 证书
openssl req -x509 -new -key ca.key -out ca.crt -days 3650 \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=My Company/CN=My Root CA"

# 使用 CA 签发证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
  -out server.crt -days 365 -CAcreateserial

# 查看证书
openssl x509 -in server.crt -text -noout

# 验证证书链
openssl verify -CAfile ca.crt server.crt

# 计算证书 hash(Android 证书命名用)
openssl x509 -subject_hash_old -noout -in server.crt

# 创建 SAN 扩展
echo "subjectAltName=DNS:example.local" > san.ext

# 带 SAN 签发证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 365 -sha256 -extfile san.ext

SAN 配置文件(完整版):

[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req

[dn]
C  = CN
ST = Beijing
L  = Beijing
O  = MyOrganization
CN = example.local

[v3_req]
subjectAltName = @alt_names
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth

[alt_names]
DNS.1 = example.local
DNS.2 = 2ddK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4N6%4N6#2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2D9L8$3y4S2L8l9`.`.
# DNS.3 = *.example.local  # 通配符
# IP.1  = 192.168.1.100    # IP 地址
# 使用配置文件生成 CSR
openssl req -new -key server.key -out server.csr -config san.cnf

# 带 SAN 扩展签发
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 365 -sha256 -extfile san.cnf -extensions v3_req

ADB 常用命令

adb devices                                         # 查看连接设备
adb shell pm list packages | grep &lt;keyword&gt;         # 查找应用包名
adb shell am start -n &lt;package&gt;/&lt;activity&gt;          # 启动应用
adb logcat | grep -i vpn                            # 查看 VPN 相关日志

格式转换速查

# PEM → DER
openssl x509 -in cert.pem -outform DER -out cert.der

# DER → PEM
openssl x509 -in cert.der -inform DER -outform PEM -out cert.pem

# PEM → PKCS12(.p12/.pfx,含私钥,Android Java 常用)
openssl pkcs12 -export -in cert.pem -inkey key.pem -out cert.p12

# PKCS12 → PEM(提取证书和私钥)
openssl pkcs12 -in cert.p12 -out cert.pem -nodes

# 查看 PKCS12 内容
openssl pkcs12 -in cert.p12 -info -nodes

传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 8
支持
分享
最新回复 (3)
雪    币: 104
活跃值: (7947)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
5天前
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
感谢分享
5天前
0
雪    币: 233
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
感谢分享!~
1天前
0
游客
登录 | 注册 方可回帖
返回