免责声明 :本文内容仅供安全学习和研究目的,旨在帮助读者理解网络协议和抓包技术的原理。请勿将本文所述技术用于未经授权的网络活动。未经明确授权对他人系统或应用进行抓包、逆向等操作可能违反相关法律法规。读者应自行承担因使用本文内容而产生的一切法律责任。
要理解 Android App 抓包,首先需要理解网络协议栈——数据如何从应用层层封装到网络上传输,以及 TLS 如何在其中提供安全保障。本文从协议基础出发,深入 TLS 原理,再到抓包实战与攻防对抗,系统性地梳理 Android App 抓包的完整知识体系。
一、常见协议
1.1 协议栈模型
网络通信的核心是分层协议栈。业界有两种主流模型:
OSI 七层模型 TCP/IP 四层模型
┌─────────────────┐
│ 应用层 │
├─────────────────┤ ┌─────────────────┐
│ 表示层 │ │ 应用层 │
├─────────────────┤ │ (HTTP/TLS/DNS) │
│ 会话层 │ └────────┬────────┘
├─────────────────┤ │
│ 传输层 │ ┌────────┴────────┐
│ (TCP/UDP) │ │ 传输层 │
├─────────────────┤ │ (TCP/UDP) │
│ 网络层 │ ├─────────────────┤
│ (IP) │ │ 网络层 │
├─────────────────┤ │ (IP) │
│ 数据链路层 │ ├─────────────────┤
├─────────────────┤ │ 网络接口层 │
│ 物理层 │ │ (以太网/Wi-Fi) │
└─────────────────┘ └─────────────────┘
实际工程中普遍使用 TCP/IP 四层模型。理解分层对抓包很重要:不同的抓包工具工作在不同的层级 ,决定了它能捕获什么样的数据。
为什么不自定义协议?
一些开发者可能会想:能否自定义加密协议来保护通信?答案是不推荐 ,原因有二:
算法层面 :密码学算法的安全性需要经过大量数学论证和长期实践检验,自研算法往往存在未知漏洞
开发层面 :即使使用标准算法,实现中的细节错误(如随机数生成不当、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 无需反复握手
(每次都要重新请求) (一次握手,持续通信)
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
心跳响应
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;
}
protoc --python_out=. demo.proto
from demo_pb2 import Demo
demo = Demo(id =150 , name="hi!" , timestamp=123456 )
data = demo.SerializeToString()
demo2 = Demo()
demo2.ParseFromString(data)
print (demo2.id , demo2.name)
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_1、field_2 这样的编号,无法知道字段的语义。
逆向 Protobuf 结构
抓包拿到 Protobuf 二进制数据后,如果没有 .proto 定义文件,需要逆向还原结构。
第一步:从二进制数据推测结构
使用 protobuf-inspector 可以直接解析原始二进制数据,推测字段类型:
echo -ne '\x08\x96\x01\x12\x03\x68\x69\x21\x18\xc0\xc4\x07' | protobuf_inspector
这一步能得到字段编号和大致类型,但无法确定字段名称和精确类型(比如分不清 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 中复制反汇编/伪代码
同时用 protobuf-inspector 解析抓包得到的二进制数据
将反汇编代码、抓包原始数据、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)通信。
但公钥密码体系本身面临中间人攻击 :攻击者可以替换公钥,让双方以为在与对方通信,实际上都在与攻击者通信。
解决中间人攻击的演进路径 :
HMAC 签名公钥 :使用预共享密钥对公钥做 HMAC,确保公钥完整性。但 HMAC 仍依赖预共享密钥,攻击者获取后可伪造
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)← 系统预装,信任锚点
服务端发送证书链(服务器证书 + 中间证书)
检查证书是否过期
检查域名是否匹配(CN 或 SAN)
用上级 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 路径
证书文件命名格式为 <hash>.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 等传输层数据。
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②
(代理证书) (真实证书)
抓包工具自己充当 CA,生成根证书
用户将该根证书安装到系统信任列表
客户端连接时,抓包工具根据请求的域名动态签发 自签名证书返回给客户端
客户端验证通过后,与代理建立 TLS 连接;代理再与真实服务器建立另一条 TLS 连接
代理在中间解密、记录、再加密转发
证书安装
在 Android 7+ 上,需要将抓包工具的 CA 证书安装到系统证书目录 才能被信任。
方式一:修改 App 网络安全配置(重打包)
< application android:networkSecurityConfig="@xml/network_security_config">
< /application>
<?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)
openssl x509 -inform DER -in reqable.der -out reqable.pem
CERT_HASH=$(openssl x509 -inform PEM -subject_hash_old -in reqable.pem | head -1)
cp reqable.pem ${CERT_HASH} .0
adb root
adb shell avbctl disable-verification
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/
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 界面工具,支持脚本扩展
pip install mitmproxy
mitmweb --web-host 0.0.0.0
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 的场景。
VPN 抓包
代理抓包属于应用层抓包 (HTTP 代理),App 可以选择不使用系统代理;VPN 抓包位于IP 层 ,App 无法绕过。
App 流量 → 虚拟网卡(tun0) → VPN 软件 → SOCKS5 代理 → 抓包工具 → 目标服务器
推荐的 VPN 代理工具:sockstun (搭配 Reqable 的 SOCKS5 代理使用)
如果应用检测 VPN(通过判断 tun0 网卡是否存在),可以将 VPN 部署到路由器上来规避。
iptables 抓包
通过 iptables 将流量透明重定向到 redsocks,再由 redsocks 转发到上游 SOCKS5 代理。应用完全无法感知 代理的存在。
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
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"
relay = "192.168.1.1:1080"
type = socks5
autoproxy = 0
timeout = 13
}
./redsocks5 -c redsocks.conf
Hook 抓包
当常规代理方式都被绕过时,可以直接 hook 应用的网络 I/O 函数。
通用的抓包软件(如 eCapture)只能抓使用动态库 实现 TLS 的应用。如果应用静态编译 TLS 库(如 Flutter 静态链接 BoringSSL),则需要自己分析定位 hook 点,可以结合 AI 分析反汇编代码。
Frida Hook send/recv
通过 Frida hook socket、read、write、sendto、recvfrom 等系统调用:
const socketFds = new Set ();
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);
}
}
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
ecapture tls --pid 1234
ecapture tls -u 0
ecapture tls --hex
ecapture tls --pcapfile="save.pcap" -m pcap
ecapture tls -l /tmp/tls.log
自定义协议栈
部分 App 不使用系统的 TLS 实现,而是自带 TLS 库(如 BoringSSL、Fizz 等)。好消息是这些库通常都是开源的 ,可以直接获取源码,结合 AI 分析证书验证逻辑和 hook 点,比纯黑盒逆向高效得多。
为什么 Flutter 难抓包?
不使用系统代理 :直接使用底层 socket,不读取 Android 系统代理设置
内置证书验证逻辑 :不信任用户安装的 CA 证书
内置 TLS 库 :静态链接 BoringSSL,hook 系统 SSL 库的方式失效
逆向 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);
}
将 if (state.verifier()) Patch 为永假,使其走 else 分支,可跳过所有证书验证(包括证书链验证)。
四、抓包检测与绕过
4.1 代理检测
App 可以通过多种方式检测系统代理:
直接获取代理环境变量 :
String proxyHost = System.getProperty("http.proxyHost" );
String proxyPort = System.getProperty("http.proxyPort" );
if (proxyHost != null && proxyPort != null ) {
Log.w("Security" , "Proxy detected!" );
}
通过系统 API 检测 :
String proxyHost = Settings.Secure.getString(
getContentResolver(), Settings.Secure.HTTP_PROXY);
选择不走代理 :App 也可以直接指定不使用系统代理(如 Flutter 的 DIO 库)。
绕过方式 :使用 VPN 抓包或 iptables 透明代理;也可以通过 Frida hook 检测函数(如 System.getProperty、Settings.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<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
while (en.hasMoreElements()) {
NetworkInterface ni = en.nextElement();
String name = ni.getName().toLowerCase(Locale.getDefault());
if (name.startsWith("tun" ) ||
name.startsWith("ppp" ) ||
name.startsWith("wg" ) ||
name.startsWith("ipsec" )) {
return true ;
}
}
其他检测手段 :
路由表检测 :检查路由表中是否存在 VPN 相关路由规则
DNS 检测 :检测 DNS 服务器是否被 VPN 修改
流量特征检测 :分析网络流量特征判断是否经过 VPN
绕过方式 :将 VPN 部署到路由器设备上,App 无法在本机检测到;也可以通过 Frida hook 检测函数(如 NetworkCapabilities.hasTransport、NetworkInterface.getName),篡改返回值绕过检测。
4.3 JA3 指纹
JA3 通过提取 ClientHello 中的特定字段生成唯一指纹,用于识别客户端应用或 TLS 库:
提取字段 :
TLS 版本
密码套件列表
扩展类型列表
椭圆曲线列表 (Supported Groups)
椭圆曲线点格式 (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 ,
},
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、混淆密钥)。
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
);
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 ;
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() → 网络
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 通道是真正的端到端连接。
key, err := connState.ExportKeyingMaterial(
"EXPORTER-Channel-Binding" ,
[]byte ("tls-fingerprint-lab" ),
32 ,
)
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
);
绕过思路 :密钥导出依赖 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
openssl req -new -key server.key -out server.csr
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"
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
openssl x509 -subject_hash_old -noout -in server.crt
echo "subjectAltName=DNS:example.local" > san.ext
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 = 89eK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8Y4N6%4N6#2)9J5k6h3g2^5j5h3#2H3L8r3g2Q4x3X3g2D9L8$3y4S2L8l9`.`.
openssl req -new -key server.key -out server.csr -config san.cnf
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 <keyword>
adb shell am start -n <package>/<activity>
adb logcat | grep -i vpn
格式转换速查
openssl x509 -in cert.pem -outform DER -out cert.der
openssl x509 -in cert.der -inform DER -outform PEM -out cert.pem
openssl pkcs12 -export -in cert.pem -inkey key.pem -out cert.p12
openssl pkcs12 -in cert.p12 -out cert.pem -nodes
openssl pkcs12 -in cert.p12 -info -nodes
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!