【Android安全-Android APP 抓包学习笔记】此文章归类为:Android安全。
免责声明:本文内容仅供安全学习和研究目的,旨在帮助读者理解网络协议和抓包技术的原理。请勿将本文所述技术用于未经授权的网络活动。未经明确授权对他人系统或应用进行抓包、逆向等操作可能违反相关法律法规。读者应自行承担因使用本文内容而产生的一切法律责任。
要理解 Android App 抓包,首先需要理解网络协议栈——数据如何从应用层层封装到网络上传输,以及 TLS 如何在其中提供安全保障。本文从协议基础出发,深入 TLS 原理,再到抓包实战与攻防对抗,系统性地梳理 Android App 抓包的完整知识体系。
网络通信的核心是分层协议栈。业界有两种主流模型:
OSI 七层模型 TCP/IP 四层模型
┌─────────────────┐
│ 应用层 │
├─────────────────┤ ┌─────────────────┐
│ 表示层 │ │ 应用层 │
├─────────────────┤ │ (HTTP/TLS/DNS) │
│ 会话层 │ └────────┬────────┘
├─────────────────┤ │
│ 传输层 │ ┌────────┴────────┐
│ (TCP/UDP) │ │ 传输层 │
├─────────────────┤ │ (TCP/UDP) │
│ 网络层 │ ├─────────────────┤
│ (IP) │ │ 网络层 │
├─────────────────┤ │ (IP) │
│ 数据链路层 │ ├─────────────────┤
├─────────────────┤ │ 网络接口层 │
│ 物理层 │ │ (以太网/Wi-Fi) │
└─────────────────┘ └─────────────────┘
实际工程中普遍使用 TCP/IP 四层模型。理解分层对抓包很重要:不同的抓包工具工作在不同的层级,决定了它能捕获什么样的数据。
一些开发者可能会想:能否自定义加密协议来保护通信?答案是不推荐,原因有二:
因此,业界共识是使用经过充分审计的标准协议(如 TLS)和成熟的开源库(如 OpenSSL、BoringSSL)。
传输层的两个基础协议,它们的特性决定了上层协议的设计选择:
| 特性 | TCP | UDP |
|---|---|---|
| 连接方式 | 三次握手建立连接 | 无连接 |
| 可靠性 | 可靠(重传、确认机制) | 不可靠(不重传) |
| 有序性 | 有序传输 | 无序 |
| 效率 | 开销较大 | 高效、低延迟 |
| 典型应用 | HTTP、HTTPS、FTP | DNS、视频流、游戏、WebRTC |
HTTP 是应用层的核心协议,HTTPS 则是在 HTTP 和 TCP 之间加入了 TLS 层。
HTTP/1.1 vs HTTP/2:
| 特性 | HTTP/1.1 | HTTP/2 |
|---|---|---|
| 传输格式 | 文本协议 | 二进制帧 |
| 多路复用 | 不支持(一个连接一个请求) | 支持(一个连接多个流) |
| 头部压缩 | 无 | HPACK 压缩 |
| 服务器推送 | 不支持 | 支持 |
| 队头阻塞 | 存在 | 应用层解决(TCP 层仍存在) |
HTTP/2 的二进制帧格式和多路复用机制使得抓包分析更为复杂,需要抓包工具支持 HTTP/2 解析。
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: <base64-encoded-key>
# 服务端同意升级
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
握手完成后,连接不再使用 HTTP 帧,而是切换为 WebSocket 帧格式:
| 操作码 | 类型 | 说明 |
|---|---|---|
| 0x1 | 文本帧 | UTF-8 编码的文本数据 |
| 0x2 | 二进制帧 | 二进制数据 |
| 0x8 | 关闭帧 | 任意一方可主动关闭 |
| 0x9 | Ping | 心跳检测 |
| 0xA | Pong | 心跳响应 |
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!
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_1、field_2这样的编号,无法知道字段的语义。
抓包拿到 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++ 类,这些类包含丰富的类型信息:
.data.rel.ro 段中查找 RTTI 类型信息,可能找到 proto 消息的虚表Protobuf 消息虚表中的关键函数偏移:
| 偏移 | 函数 | 用途 |
|---|---|---|
| 0x10 | GetTypeName() |
返回消息类型名(如 user.UserLogin),最容易定位 |
| 0x50 | _InternalParse() |
反序列化核心,包含所有字段的编号和类型信息 |
| 0x60 | _InternalSerialize() |
序列化核心,同样包含完整的字段信息 |
第三步:借助 AI 完成还原
手动分析反汇编代码恢复 proto 结构非常繁琐,但这类工作非常适合 AI。推荐的工作流程:
GetTypeName()(偏移 0x10)定位目标消息类型_InternalParse(偏移 0x50)或 _InternalSerialize(偏移 0x60)函数,在 IDA 中复制反汇编/伪代码这是接口 /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(Transport Layer Security)是互联网安全通信的基石。理解 TLS 是进行 HTTPS 抓包的前提。
最直观的加密方式是对称加密:加密和解密使用同一把密钥。
发送方 接收方
"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 的结果比对,一致则说明:① 数据确实由私钥持有者签发(身份认证);② 数据没有被篡改(完整性)。
| 加密 | 签名 | |
|---|---|---|
| 谁操作 | 发送方用对方公钥加密 | 发送方用自己私钥签名 |
| 谁验证 | 接收方用自己私钥解密 | 接收方用对方公钥验签 |
| 解决的问题 | 保密性:防窃听 | 身份认证 + 完整性:防伪造、防篡改 |
| 数学操作 | 完全相同(公私钥互操作) | 完全相同(公私钥互操作) |
本质上加密和签名的数学运算是一样的(都是公私钥的互操作),区别在于用哪把钥匙、由谁操作、解决什么问题。
在 TLS 中两种用法都会用到:
非对称加密解决了密钥交换问题,但速度比对称加密慢得多(通常慢 100~1000 倍)。因此 TLS 的实际做法是两者结合:用非对称加密(ECDHE)协商出一个共享密钥,然后用这个共享密钥进行对称加密(AES-GCM)通信。
但公钥密码体系本身面临中间人攻击:攻击者可以替换公钥,让双方以为在与对方通信,实际上都在与攻击者通信。
解决中间人攻击的演进路径:
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)中,极少使用。
一个 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)← 系统预装,信任锚点
证书绑定是指应用内置期望的公钥指纹(而非整个证书,因为证书会过期但公钥可以不变),在 TLS 握手时验证服务器证书的公钥是否与预期一致。
更高级的做法:
| 平台 | 位置 |
|---|---|
| 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 路径 |
证书文件命名格式为 <hash>.0,hash 是证书 subject 的哈希值。多个证书哈希相同时后缀依次为 .1、.2。
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 次)。
点倍乘就是连续做"点加法":
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 本身是基于点运算的,不能像 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 正是这个思路的应用。
客户端 服务端
私钥 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) 表示每次握手都生成新的临时密钥对,即使长期私钥泄露,历史会话也无法被解密——这就是前向安全性。
密码套件是一组算法的"套餐",告诉双方:用什么方式交换密钥、用什么算法加密数据、用什么算法校验完整性。
以 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)和签名算法通过扩展字段单独协商
用一个比喻来理解整个握手过程——两个人要在咖啡店(公共网络)交换秘密,旁边都是偷听的人:
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 的原因。
回顾 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 只是提供了原始的共享秘密,经过密钥派生后,才变成真正安全、好用的通信密钥。
加粗的字段可能被用作指纹:
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,因此密码套件本身不再是有效的指纹特征。
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,这就需要应用层抓包。
MITM(中间人)代理抓包的核心原理:
正常连接 MITM 抓包
Client ──────────────── Server Client ── Proxy ── Server
TLS TLS① TLS②
(代理证书) (真实证书)
在 Android 7+ 上,需要将抓包工具的 CA 证书安装到系统证书目录才能被信任。
方式一:修改 App 网络安全配置(重打包)
<!-- AndroidManifest.xml -->
<application android:networkSecurityConfig="@xml/network_security_config">
</application>
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
方式二:安装到系统证书目录(需 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
自动将用户证书挂载到系统证书目录,无需手动操作。
前面介绍了 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 代理捕获应用层流量。
常用工具:
# 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 对 WebSocket 和 DTLS 有更好的支持。Reqable/Charles 主要面向 HTTP/HTTPS 请求-响应模式,对 WebSocket 的长连接帧和 DTLS(基于 UDP)的支持有限或不支持。而 mitmweb 可以实时查看 WebSocket 的每一帧数据(文本帧、二进制帧),也支持 UDP 流量的代理和解析,适合抓取游戏、音视频通话等使用 DTLS/UDP 的场景。
代理抓包属于应用层抓包(HTTP 代理),App 可以选择不使用系统代理;VPN 抓包位于IP 层,App 无法绕过。
App 流量 → 虚拟网卡(tun0) → VPN 软件 → SOCKS5 代理 → 抓包工具 → 目标服务器
推荐的 VPN 代理工具:sockstun(搭配 Reqable 的 SOCKS5 代理使用)
如果应用检测 VPN(通过判断 tun0 网卡是否存在),可以将 VPN 部署到路由器上来规避。
通过 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 应用的网络 I/O 函数。
通用的抓包软件(如 eCapture)只能抓使用动态库实现 TLS 的应用。如果应用静态编译 TLS 库(如 Flutter 静态链接 BoringSSL),则需要自己分析定位 hook 点,可以结合 AI 分析反汇编代码。
通过 Frida hook socket、read、write、sendto、recvfrom 等系统调用:
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 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 难抓包?
逆向 Flutter/DIO 的 BoringSSL:
使用 IDA 打开 libflutter.so,搜索 ssl、tls 字符串确认 SSL 库(出于安全考虑,应用通常不会自研 SSL 库)
分析源码寻找 hook 点。可以借助 AI 加速分析,将 IDA 反编译结果和相关上下文提供给 AI,提示词参考:
我正在逆向一个使用 BoringSSL 的 Flutter 应用(libflutter.so),需要绕过 SSL 证书校验。请帮我在 BoringSSL 源码中寻找合适的 hook 点,要求:
- 接口清晰,修改返回值即可关闭验证
- 附近有特征字符串方便在 IDA 中定位
- 给出函数签名、返回值含义、所在源文件路径
对于不熟悉的 TLS 库也是同样的思路——将反编译代码和字符串特征交给 AI,让它帮你定位验证函数和绕过方案。
BoringSSL 证书校验的关键函数:ssl_crypto_x509_session_verify_cert_chain() → 校验成功返回 1,位于全局函数表 ssl_crypto_x509_method,附近有 ssl_server、ssl_client 字符串
使用 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 分支,可跳过所有证书验证(包括证书链验证)。
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.getProperty、Settings.Secure.getString),使其返回空值。
方式一: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<NetworkInterface> 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 部署到路由器设备上,App 无法在本机检测到;也可以通过 Frida hook 检测函数(如 NetworkCapabilities.hasTransport、NetworkInterface.getName),篡改返回值绕过检测。
JA3 通过提取 ClientHello 中的特定字段生成唯一指纹,用于识别客户端应用或 TLS 库:
提取字段:
将以上字段转换为十进制,按固定格式拼接,做 MD5 得到 32 位十六进制 JA3 指纹。Wireshark 可直接识别。
服务端识别出指纹异常后通常不会立即拒绝,而是标记账号。App 比 Web 对协议栈的控制更底层,未来 TLS 指纹对抗会更加激烈。
客户端也可以检测服务端的 Server Hello 指纹。TLS 协议栈通常不支持直接获取 Server Hello,常用探测法:
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)
标准 HTTPS 即为单向认证:客户端验证服务器身份。证书绑定在此基础上更进一步,不仅验证证书链,还要验证公钥指纹是否与预期一致。
服务器也要验证客户端身份。客户端持有含私钥的证书:
.p12 格式(通常有密码保护)openssl 命令转换提取客户端证书的方法:
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 是 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 无法抓到包时,可以按以下流程逐步分析和突破:
首先确认 App 的网络请求是否真正发出,以及走的是什么协议:
tcpdump 抓取原始流量
│
├── 有流量 → 确认目标 IP 和端口
│ ├── 443 端口 → HTTPS(常规 TLS)
│ ├── 非标准端口 → 可能是自定义协议、DTLS、QUIC 等
│ └── UDP 流量 → 可能是 DTLS、QUIC、自定义 UDP 协议
│
└── 无流量 → 检查是否有网络权限、是否离线缓存
使用 Reqable / Charles / mitmproxy 等代理工具,设置系统代理:
设置系统代理
│
├── 能抓到包 → 直接分析(最简单的情况)
│
└── 抓不到包 → 分析原因:
│
├── App 不走系统代理?
│ → 尝试 VPN 抓包或 iptables 透明代理
│
├── 证书错误 / 连接失败?
│ → 证书未安装或未被信任
│ → 尝试安装 CA 到系统证书目录
│
└── 连接成功但无数据?
→ 可能是非 HTTP 协议(WebSocket、gRPC、自定义协议)
如果代理工具抓不到包:
证书不被信任
│
├── Android 7+ 默认不信任用户证书
│ ├── 方案 A:重打包,修改 networkSecurityConfig 信任用户证书
│ ├── 方案 B:root 后安装到系统证书目录
│ ├── 方案 C:tmpfs 临时挂载
│ └── 方案 D:Magisk 模块 AlwaysTrustUserCerts
│
└── 安装后仍然失败?
→ 很可能是证书绑定(SSL Pinning)
SSL Pinning
│
├── 单向认证 Pinning
│ ├── Frida + ssl_pinning_bypass 脚本(通用方案)
│ ├── Xposed + JustTrustMe / TrustMeAlready
│ ├── 针对 OkHttp:hook CertificatePinner.check()
│ └── 针对 WebView:hook X509TrustManager
│
└── 双向认证(mTLS)
├── 从 App 中提取客户端证书(.p12)和密码
│ ├── Hook App 的 KeyStore.load() 获取密码
│ ├── 内存搜索证书特征字节
│ └── 反编译定位证书资源文件
└── 将证书配置到抓包工具
如果 App 检测到代理或 VPN 后拒绝请求:
检测对抗
│
├── 代理检测
│ ├── Hook System.getProperty() 返回 null
│ ├── 改用 VPN 抓包(IP 层,不依赖代理设置)
│ └── 改用 iptables 透明代理(完全不可感知)
│
└── VPN 检测
├── Hook ConnectivityManager / NetworkInterface 相关 API
└── 将 VPN 部署到路由器上(App 在本机无法检测)
如果 App 使用了自定义 TLS 库(如 Flutter 静态链接 BoringSSL):
自定义 TLS 库
│
├── 确认使用的 TLS 库
│ └── IDA 打开 .so,搜索 ssl/tls/boringssl 等字符串
│
├── 定位证书校验函数
│ ├── 从开源代码分析校验流程
│ ├── 寻找特征字符串定位函数(如 ssl_server、ssl_client)
│ └── 结合 AI 分析反汇编代码
│
└── Patch / Hook 校验逻辑
├── 修改返回值使校验始终通过
├── Hook memcmp 等比较函数
└── 使用 edbg / eBPF 定位并 patch
当常规手段都被绕过后,可能面临更深层的检测:
高级防护
│
├── TLS 指纹检测(JA3/JA4)
│ ├── 使用 Wireshark 对比正常 App 和代理的 JA3 指纹
│ ├── 使用 uTLS 模拟目标指纹
│ └── 注意:服务端可能只是标记而非立即封禁
│
├── 自定义 TLS 扩展
│ ├── 抓包对比正常请求,识别自定义扩展字段
│ └── 在代理工具或模拟请求中还原扩展
│
├── BIO 流量混淆
│ ├── 网络层抓包看到的是混淆后的数据
│ └── 只能 hook SSL_write/SSL_read 获取明文
│
├── 密钥导出校验
│ ├── 需要在真实 TLS 连接上导出密钥
│ └── MITM 代理无法生成正确的导出密钥
│
└── 服务端指纹校验
├── 客户端验证 Server Hello 指纹
└── 需要分析校验逻辑并 hook 绕过
难度递增 ──────────────────────────────────────────────>
无防护 证书信任 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 = fa8K9s2c8@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 devices # 查看连接设备
adb shell pm list packages | grep <keyword> # 查找应用包名
adb shell am start -n <package>/<activity> # 启动应用
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
更多【Android安全-Android APP 抓包学习笔记】相关视频教程:www.yxfzedu.com