一次 HTTPS 请求的完整旅程:从 TLS 握手到加密传输
HTTP 你已经看透了每一个字节。HTTPS 在 TCP 之上加了一层 TLS——数据还是那些数据,但全程加密,抓包只能看到密文。
这篇文章把 HTTPS 的每个阶段拆开来看:TLS 握手怎么协商密钥、证书怎么验证、数据怎么加密,以及如何用 Wireshark 把这一切看清楚。
目录
- 全局视角:HTTPS 比 HTTP 多了什么
- TLS 在协议栈里的位置
- TLS 握手:完整的七步流程
- 证书验证:怎么确认服务器是真的
- 密钥协商:ECDHE 的工作原理
- 加密数据传输:TLS Record 结构
- TLS 1.2 vs TLS 1.3:握手包数量减半
- 真实抓包:逐包解读一次完整 HTTPS 会话
- 解密 HTTPS 流量:SSLKEYLOGFILE 方法
- 关键概念速查表
1. 全局视角:HTTPS 比 HTTP 多了什么
HTTP 的问题很简单:所有数据明文传输,路由器、运营商、同一 WiFi 下的任何人都能看到你发了什么、收到了什么。
HTTPS = HTTP + TLS。TLS 在 TCP 连接建立之后、HTTP 数据发送之前,插入了一个"握手"阶段,协商出一个只有两端知道的对称密钥,之后所有 HTTP 数据都用这个密钥加密传输。
你的机器 (192.168.5.221) 服务器 (54.198.84.224:443)
│ │
│ ① DNS 查询(和 HTTP 一样) │
│─────────────────────────────────────────►│
│◄─────────────────────────────────────────│
│ │
│ ② TCP 三次握手(和 HTTP 一样) │
│──── [SYN] ────────────────────────────►│
│◄─── [SYN, ACK] ─────────────────────────│
│──── [ACK] ────────────────────────────►│
│ │
│ ③ TLS 握手(HTTPS 新增,约 2 个 RTT) │
│──── ClientHello ───────────────────────►│
│◄─── ServerHello ────────────────────────│
│◄─── Certificate(1400字节,分片传输)─────│
│◄─── ServerKeyExchange, ServerHelloDone ──│
│──── ClientKeyExchange + ChangeCipherSpec►│
│◄─── NewSessionTicket + ChangeCipherSpec ─│
│◄─── Finished ───────────────────────────│
│ │
│ ④ 加密的 HTTP 请求 │
│──── [Application Data] ────────────────►│ ← 看不到内容
│ │
│ ⑤ 加密的 HTTP 响应(多个包) │
│◄─── [Application Data × N] ─────────────│ ← 看不到内容
│ │
│ ⑥ TLS 关闭 + TCP 四次挥手 │
│◄─── [Encrypted Alert] ──────────────────│
│◄─── [FIN, ACK] ─────────────────────────│
│──── [ACK] + [FIN, ACK] ────────────────►│
│◄─── [ACK] ─────────────────────────────│和 HTTP 相比,多了第 ③ 步 TLS 握手,端口从 80 变成 443,其余完全一样。
2. TLS 在协议栈里的位置
TLS 不是独立的协议层,它夹在 TCP 和 HTTP 之间:
┌──────────────────────────────────────────────────────┐
│ 应用层 (HTTP) │
│ GET / HTTP/1.1\r\nHost: httpbin.org\r\n\r\n │
│ ← 这层的内容对 TLS 完全透明,TLS 只管加密 │
├──────────────────────────────────────────────────────┤
│ TLS 层 │
│ ContentType=23 Version=TLS1.2 Length=... │
│ [加密后的 HTTP 数据,外部看不到原文] │
├──────────────────────────────────────────────────────┤
│ 传输层 (TCP) │
│ src_port=58458 dst_port=443 seq=... flags=[PSH,ACK]│
├──────────────────────────────────────────────────────┤
│ 网络层 (IP) │
│ src=192.168.5.221 dst=54.198.84.224 TTL=128 │
├──────────────────────────────────────────────────────┤
│ 链路层 (Ethernet) │
│ src_mac=aa:bb:cc dst_mac=dd:ee:ff │
└──────────────────────────────────────────────────────┘Wireshark 抓到的 HTTPS 包,IP 和 TCP 层完全可读,TLS 层的 ContentType 和 Length 可读,但 Payload 是密文。
3. TLS 握手:完整的七步流程
以 TLS 1.2 为例(最常见,TLS 1.3 在第 7 节讲)。
客户端 (192.168.5.221) 服务器 (54.198.84.224)
│ │
│ 1. ClientHello │
│ "我支持这些加密套件,这是我的随机数 C" │
│ SNI=httpbin.org(明文!) │
│─────────────────────────────────────────────►│
│ │
│ 2. ServerHello │
│ "我选 ECDHE-RSA-AES128-GCM-SHA256" │
│ "这是我的随机数 S" │
│◄─────────────────────────────────────────────│
│ │
│ 3. Certificate(可能分多个 TCP 包) │
│ "这是我的证书链(服务器证书+中间CA)" │
│◄─────────────────────────────────────────────│
│ │
│ 4. ServerKeyExchange │
│ "这是我的 ECDHE 临时公钥" │
│◄─────────────────────────────────────────────│
│ │
│ 5. ServerHelloDone │
│ "我说完了,轮到你" │
│◄─────────────────────────────────────────────│
│ │
│ ← 客户端验证证书,计算 Pre-Master Secret → │
│ │
│ 6. ClientKeyExchange + ChangeCipherSpec │
│ + Encrypted Handshake Message │
│ "这是我的 ECDHE 公钥,切换加密,握手完成" │
│─────────────────────────────────────────────►│
│ │
│ 7. NewSessionTicket + ChangeCipherSpec │
│ + Encrypted Handshake Message │
│ "给你一个 Session Ticket(下次可以快速恢复)" │
│ "切换加密,握手完成" │
│◄─────────────────────────────────────────────│
│ │
│ ══════ TLS 握手完成,开始加密通信 ══════ │Session Ticket 是什么?
第 7 步里服务器发的 NewSessionTicket 是一个优化机制:
服务器把本次会话的密钥材料加密后打包成 Ticket,发给客户端保存
下次连接同一服务器时,客户端在 ClientHello 里带上这个 Ticket
服务器解密 Ticket,直接恢复上次的会话密钥,跳过完整握手
好处:节省 1 个 RTT(从 2 RTT 降到 1 RTT)
代价:如果 Ticket 加密密钥泄露,历史会话可能被解密(影响前向保密)密钥是怎么来的
握手过程中产生了三个原材料:
C(Client Random) ← ClientHello 里,客户端生成的 32 字节随机数
S(Server Random) ← ServerHello 里,服务器生成的 32 字节随机数
PMS(Pre-Master Secret)← 通过 ECDHE 密钥交换,双方各自计算出相同的值
Master Secret = PRF(PMS, "master secret", C + S)
↑ 伪随机函数,把三个原材料混合成 48 字节主密钥
Session Key = 从 Master Secret 派生出 4 个密钥:
client_write_key ← 客户端加密用
server_write_key ← 服务器加密用
client_write_MAC ← 客户端消息认证码用
server_write_MAC ← 服务器消息认证码用为什么需要两个随机数加上 PMS?防止任何一方单独控制最终密钥。即使服务器是恶意的,它也无法预测客户端的随机数,反之亦然。
4. 证书验证:怎么确认服务器是真的
TLS 握手里最关键的一步:客户端收到服务器证书后,怎么确认这个证书是真的?
证书里有什么
X.509 证书结构:
┌─────────────────────────────────────────────────────┐
│ Subject: CN=httpbin.org │ ← 这张证书是给谁的
│ Issuer: CN=Amazon RSA 2048 M03 │ ← 谁签发的
│ Valid: Jul 20 2025 ~ Aug 17 2026 │ ← 有效期
│ Public Key: (RSA 2048-bit) │ ← 服务器公钥
│ SAN: httpbin.org, *.httpbin.org │ ← 覆盖的域名
│ Signature: [CA 用私钥对上面内容的签名] │ ← 防篡改
└─────────────────────────────────────────────────────┘信任链验证
你的操作系统/浏览器内置了约 150 个"根证书"(Root CA)
这些根 CA 是被全球信任的机构(DigiCert、Let's Encrypt、Amazon 等)
验证过程(以 httpbin.org 为例):
httpbin.org 的服务器证书
│ 由谁签发?
▼
Amazon RSA 2048 M03(中间 CA)
│ 由谁签发?
▼
Amazon Root CA 1(根 CA)
│ 在系统信任列表里吗?
▼
✓ 在 → 证书链验证通过,连接继续
✗ 不在 → 浏览器报"证书不受信任",连接中断
类比:
证书 = 身份证
中间 CA = 省级公安局
根 CA = 国家公安部(最终权威)
你信任公安部,所以信任省公安局,所以信任这张身份证验证的四个检查项
① 签名验证
用 CA 的公钥解密证书签名,得到证书内容的哈希值
自己计算证书内容的哈希值
两者相等 → 证书没被篡改
② 域名匹配
证书的 CN 或 SAN 字段必须包含你访问的域名
*.httpbin.org 可以匹配 api.httpbin.org
但不能匹配 a.b.httpbin.org(通配符只匹配一级)
③ 有效期
当前时间必须在 notBefore ~ notAfter 之间
证书过期 → 浏览器报"证书已过期"
④ 吊销检查(OCSP / CRL)
证书可能被提前吊销(私钥泄露、公司倒闭等)
OCSP:实时向 CA 查询证书状态(Online Certificate Status Protocol)
CRL:下载 CA 发布的吊销列表(Certificate Revocation List)5. 密钥协商:ECDHE 的工作原理
现代 HTTPS 几乎都用 ECDHE(椭圆曲线 Diffie-Hellman 临时密钥交换)。它解决了一个核心问题:如何在不安全的信道上,让双方协商出一个只有自己知道的共享密钥?
直觉理解:颜色混合比喻
公开信息(所有人都能看到):
底色 = 黄色
客户端的秘密:红色(只有自己知道)
服务器的秘密:蓝色(只有自己知道)
客户端发给服务器:黄色 + 红色 = 橙色(公开传输,ServerKeyExchange 里)
服务器发给客户端:黄色 + 蓝色 = 绿色(公开传输,ClientKeyExchange 里)
客户端拿到绿色,加上自己的红色 → 橙绿混合色(Pre-Master Secret)
服务器拿到橙色,加上自己的蓝色 → 橙绿混合色(Pre-Master Secret)
两边得到相同的颜色!
攻击者只看到橙色和绿色,无法还原出红色或蓝色。实际的数学过程(ECDH)
公开参数:椭圆曲线(如 P-256 或 x25519),基点 G
客户端:
生成随机私钥 a(只存在内存里,不发出去)
计算公钥 A = a × G(椭圆曲线点乘)
把 A 发给服务器(在 ClientKeyExchange 里)
服务器:
生成随机私钥 b(只存在内存里,不发出去)
计算公钥 B = b × G
把 B 发给客户端(在 ServerKeyExchange 里)
双方各自计算共享密钥:
客户端:K = a × B = a × (b × G) = ab × G
服务器:K = b × A = b × (a × G) = ab × G
→ 结果相同!这就是 Pre-Master Secret
攻击者知道 A、B、G,但无法从 A=a×G 反推出 a
(椭圆曲线离散对数问题,目前无法破解)ECDHE 里的 E(Ephemeral,临时的):每次连接都生成新的临时密钥对 a 和 b,握手结束后立刻丢弃。即使服务器的长期私钥泄露,攻击者也无法解密历史会话,因为临时密钥已经不存在了。这叫前向保密(Forward Secrecy)。
6. 加密数据传输:TLS Record 结构
握手完成后,所有数据都封装在 TLS Record 里传输。
TLS Record 的结构
TLS Record(在 TCP Payload 里):
┌──────────────────────────────────────────────────────┐
│ Content Type (1 byte) │
│ 14 = ChangeCipherSpec (0x14) │
│ 15 = Alert (0x15) 错误/关闭通知 │
│ 16 = Handshake (0x16) 握手消息 │
│ 17 = Application Data (0x17) 加密的 HTTP 数据 │
├──────────────────────────────────────────────────────┤
│ Version (2 bytes) │
│ 03 01 = TLS 1.0 │
│ 03 03 = TLS 1.2(也用于 TLS 1.3,历史原因) │
├──────────────────────────────────────────────────────┤
│ Length (2 bytes) │
│ 后面 Payload 的字节数(最大 16384 字节) │
├──────────────────────────────────────────────────────┤
│ Payload (Length bytes) │
│ 握手阶段:明文的握手消息(ClientHello 等) │
│ 数据阶段:加密后的内容(AES-GCM 等) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Encrypted HTTP Data │ │
│ │ + Auth Tag(16字节,防篡改) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘加密套件(Cipher Suite)决定了用什么算法
握手时协商的加密套件,例如本次实验用的:
ECDHE-RSA-AES128-GCM-SHA256
完整名称:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
拆解:
ECDHE → 密钥交换算法(椭圆曲线 DH,有前向保密)
RSA → 证书签名算法(用来验证服务器身份)
AES_128_GCM → 对称加密算法(加密实际数据,128位密钥)
SHA256 → PRF/MAC 哈希算法(消息完整性)AES-GCM 加密的实际过程
输入:
明文 = HTTP 请求/响应(如 "GET / HTTP/1.1\r\n...")
密钥 = session key(握手时协商的,128 位)
Nonce = 每条消息唯一的 12 字节随机数(防重放攻击)
输出:
密文 = AES-GCM(明文, 密钥, Nonce) ← 和明文等长
Tag = 16 字节认证标签(接收方用来验证数据没被篡改)
接收方:
用相同密钥和 Nonce 解密
验证 Tag,不匹配则立刻丢弃(可能被篡改或传输错误)
GCM 的优势:加密和认证一步完成,比 CBC+HMAC 快,且没有 padding oracle 漏洞7. TLS 1.2 vs TLS 1.3:握手包数量减半
TLS 1.3 是 2018 年发布的新版本,主要改进是把握手从 2 个 RTT 压缩到 1 个 RTT。
TLS 1.2 握手(2 RTT)
时间轴(每个 → 代表一次网络传输):
t=0ms 客户端 ──ClientHello──────────────────────────────► 服务器
t=20ms 客户端 ◄─ServerHello + Certificate + ServerHelloDone─ 服务器
↑ 第一个 RTT 结束,客户端开始验证证书
t=20ms 客户端 ──ClientKeyExchange + ChangeCipherSpec + Finished► 服务器
t=40ms 客户端 ◄─ChangeCipherSpec + Finished──────────────── 服务器
↑ 第二个 RTT 结束,握手完成
t=40ms 客户端 ──HTTP GET(加密)────────────────────────────► 服务器
↑ 从 TCP 连接建立到第一个 HTTP 请求,共花了约 2 RTTTLS 1.3 握手(1 RTT)
t=0ms 客户端 ──ClientHello(含 KeyShare)──────────────► 服务器
↑ 直接把 ECDHE 公钥带上,不等服务器确认算法
t=20ms 客户端 ◄─ServerHello(含 KeyShare)+ Certificate
+ CertificateVerify + Finished──────────── 服务器
↑ 服务器一次性把所有东西发完,此时密钥已协商好
t=20ms 客户端 ──Finished + HTTP GET(加密)────────────► 服务器
↑ 确认握手的同时直接发数据!
从 TCP 连接建立到第一个 HTTP 请求,只花了 1 RTTTLS 1.3 的其他改进
① 删除了不安全的算法
不再支持 RSA 密钥交换(没有前向保密)
不再支持 RC4、DES、3DES、MD5、SHA-1
只保留 AES-GCM、ChaCha20-Poly1305 等现代算法
② 0-RTT 恢复(Session Resumption)
之前连接过的服务器,可以在第一个包里直接发数据
延迟降到 0 RTT(有重放攻击风险,仅用于幂等请求如 GET)
③ 加密更早
TLS 1.2:Certificate 是明文传输的(抓包可以看到证书内容)
TLS 1.3:Certificate 加密传输(更好的隐私保护)
④ 握手消息更少
TLS 1.2:ServerHello + Certificate + ServerKeyExchange + ServerHelloDone(4条)
TLS 1.3:ServerHello + EncryptedExtensions + Certificate + CertVerify + Finished(合并发送)Wireshark 里怎么区分 TLS 版本
看 ClientHello 里的 Extensions:
TLS 1.2(没有 supported_versions extension):
Handshake Protocol: Client Hello
Version: TLS 1.2 (0x0303)
← 没有 supported_versions,就是 TLS 1.2
TLS 1.3(有 supported_versions extension):
Handshake Protocol: Client Hello
Version: TLS 1.2 (0x0303) ← 外层版本字段,兼容性保留,忽略它
Extensions:
supported_versions: TLS 1.3 (0x0304) ← 这里才是真实版本
→ 规则:看 Extensions 里的 supported_versions,不要看外层 Version 字段8. 真实抓包:逐包解读一次完整 HTTPS 会话
下面用真实抓包数据(python s06_the_tls_layer.py get httpbin.org)逐包解读。
环境信息:
客户端:192.168.5.221(Windows,TTL=128)
服务器:54.198.84.224(httpbin.org,Linux,TTL=233)
协议:TLSv1.2
加密套件:ECDHE-RSA-AES128-GCM-SHA256
Wireshark 过滤器:ip.addr == 54.198.84.224 && tcp.port == 4438.1 完整包序列总览
包号 方向 协议 长度 Info
────────────────────────────────────────────────────────────────────
316 C → S TCP 52 58458 → 443 [SYN] MSS=1360 WS=256 SACK_PERM
322 S → C TCP 52 443 → 58458 [SYN,ACK] MSS=1418 WS=256
323 C → S TCP 40 [ACK] Seq=1 Ack=1
↑ ─── TCP 三次握手完成 ───
324 C → S TLSv1.2 557 Client Hello (SNI=httpbin.org)
333 S → C TCP 40 [ACK] Seq=1 Ack=518
334 S → C TLSv1.2 1400 Server Hello
335 S → C TCP 40 [ACK]
336 S → C TCP 1400 [TCP PDU reassembled in 337] ← 证书分片1
337 S → C TLSv1.2 1400 Certificate(1400字节,TCP重组)
338 S → C TLSv1.2 211 Server Key Exchange, Server Hello Done
339 C → S TCP 40 [ACK]
340 C → S TLSv1.2 166 Client Key Exchange, Change Cipher Spec,
Encrypted Handshake Message
348 S → C TLSv1.2 244 New Session Ticket, Change Cipher Spec,
Encrypted Handshake Message
↑ ─── TLS 握手完成 ───
351 C → S TLSv1.2 159 Application Data ← 加密的 HTTP GET
359 S → C TCP 40 [ACK]
364 S → C TLSv1.2 303 Application Data ← 响应第1片(小包)
365 S → C TCP 1400 [TCP PDU reassembled in 376] ← 响应分片
366 S → C TCP 1400 [TCP PDU reassembled in 376]
...(多个 1400 字节的 TCP 分片)
376 S → C TLSv1.2 142 Application Data ← 响应最后一片
↑ ─── HTTP 响应传输完成(共约 9827 字节)───
377 S → C TLSv1.2 71 Encrypted Alert ← TLS Close Notify
379 S → C TCP 40 [FIN, ACK]
380 C → S TCP 40 [ACK]
381 C → S TCP 40 [FIN, ACK]
382 S → C TCP 40 [ACK]
↑ ─── 连接完全关闭 ───8.2 TCP 三次握手(包 316-323)
继续输出,从 8.2 TCP 三次握手接着往下:
包 316:C → S [SYN]
52 字节(IP头20 + TCP头32含Options)
Seq=0 Win=65535 MSS=1360 WS=256 SACK_PERM
↑ MSS=1360:客户端告诉服务器"每个 TCP 数据段最多 1360 字节"
↑ WS=256:窗口缩放因子,实际接收窗口 = 65535 × 256 = 16,776,960 字节
↑ SACK_PERM:支持选择性确认,丢包时只重传丢失的包
包 322:S → C [SYN, ACK]
52 字节
Seq=0 Ack=1 Win=26883 MSS=1418 WS=256
↑ 服务器 MSS=1418,双方取较小值 1360 作为实际 MSS
↑ TTL=233(Linux 服务器,初始值 255,255-233=22,经过约 22 跳路由器)
包 323:C → S [ACK]
40 字节(最小 TCP 包,无 Options,无 Payload)
Seq=1 Ack=1
↑ 握手完成,连接建立8.3 TLS ClientHello(包 324)
包 324:C → S TLSv1.2 557 字节
Info: Client Hello (SNI=httpbin.org)
展开 TLS 层:
Content Type: Handshake (22) ← 0x16
Version: TLS 1.0 (0x0301) ← 兼容性写法,实际版本看 Extensions
Length: 512
Handshake Protocol: Client Hello
Version: TLS 1.2 (0x0303)
Random: [32字节] ← Client Random,密钥材料之一
Session ID: (空) ← 新连接,无缓存
Cipher Suites: (多个)
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 ← 最终协商用这个
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_RSA_WITH_AES_128_GCM_SHA256
...
Extensions:
server_name: httpbin.org ← SNI!明文可见,这是唯一暴露域名的地方
supported_groups: x25519, secp256r1, ...
signature_algorithms: rsa_pss_rsae_sha256, ...
重点:SNI 是明文的
即使 HTTPS 加密了所有 HTTP 内容,
ClientHello 里的 server_name 字段仍然明文可见。
这意味着网络上的任何人(运营商、路由器)都能看到你访问了哪个域名,
但看不到你访问了什么路径、发了什么内容。8.4 TLS ServerHello(包 334)
包 334:S → C TLSv1.2 1400 字节
Info: Server Hello
Handshake Protocol: Server Hello
Version: TLS 1.2 (0x0303)
Random: [32字节] ← Server Random,密钥材料之一
Session ID: [32字节] ← 服务器分配的 Session ID
Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
↑ 从客户端列表里选了这个,双方都支持且安全
Compression Method: null ← 不压缩(压缩+加密有安全漏洞 CRIME)8.5 Certificate(包 336-337,TCP 分片重组)
包 336:S → C TCP 1400 字节
Info: [TCP PDU reassembled in 337]
↑ 这不是一个完整的 TLS 消息,只是证书数据的第一片
↑ Wireshark 提示"这个包的内容会在包 337 里重组显示"
包 337:S → C TLSv1.2 1400 字节
Info: Certificate(Wireshark 在这里显示重组后的完整证书)
Handshake Protocol: Certificate
Certificates (2 certs)
证书 1:服务器证书(httpbin.org)
subject: CN=httpbin.org
issuer: CN=Amazon RSA 2048 M03
validity:
notBefore: Jul 20 00:00:00 2025 GMT
notAfter: Aug 17 23:59:59 2026 GMT
subjectAltName: httpbin.org, *.httpbin.org
publicKey: RSA 2048-bit
证书 2:中间 CA 证书
subject: CN=Amazon RSA 2048 M03
issuer: CN=Amazon Root CA 1
↑ 这条链告诉客户端:httpbin.org → Amazon M03 → Amazon Root CA 1
为什么证书要分两个 TCP 包传?
证书数据约 2000+ 字节,超过了 MSS=1360
TCP 自动把它拆成两片:
片1(包336):1360 字节
片2(包337):剩余部分
Wireshark 的 "TCP PDU reassembled in 337" 就是在告诉你这件事8.6 ServerKeyExchange + ServerHelloDone(包 338)
包 338:S → C TLSv1.2 211 字节
Info: Server Key Exchange, Server Hello Done
Handshake Protocol: Server Key Exchange
EC Diffie-Hellman Server Params
Curve Type: named_curve
Named Curve: x25519 ← 使用 x25519 椭圆曲线
Pubkey: [32字节] ← 服务器的 ECDHE 临时公钥 B
Signature: [RSA 签名] ← 用服务器私钥签名,证明公钥是真的
Handshake Protocol: Server Hello Done
← "我说完了,轮到你"(空消息,只是一个信号)
为什么要对 ECDHE 公钥签名?
ECDHE 公钥是临时生成的,不在证书里。
如果不签名,中间人可以替换这个公钥,实施中间人攻击。
用服务器私钥签名后,客户端用证书里的公钥验证签名,
确认这个 ECDHE 公钥确实来自真正的服务器。8.7 ClientKeyExchange + ChangeCipherSpec + Finished(包 340)
包 340:C → S TLSv1.2 166 字节
Info: Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message
↑ 三条消息打包在一个 TCP 包里发出
1. Handshake Protocol: Client Key Exchange
EC Diffie-Hellman Client Params
Pubkey: [32字节] ← 客户端的 ECDHE 临时公钥 A
此时双方都有了对方的公钥:
客户端计算:PMS = a × B(a是自己的私钥,B是服务器公钥)
服务器计算:PMS = b × A(b是自己的私钥,A是客户端公钥)
两边结果相同 → Pre-Master Secret 协商完成
2. Change Cipher Spec
← "从下一条消息开始,我切换到加密模式"
← 这是一个独立的 TLS Record,Content Type=20
3. Encrypted Handshake Message(即 Finished)
← 第一条加密消息!
← 内容是所有握手消息的哈希值,用来验证握手没被篡改
← 如果中间人修改了任何握手消息,这里的哈希就对不上,连接中断8.8 NewSessionTicket + ChangeCipherSpec + Finished(包 348)
包 348:S → C TLSv1.2 244 字节
Info: New Session Ticket, Change Cipher Spec, Encrypted Handshake Message
1. New Session Ticket
← 服务器把本次会话的密钥材料加密打包,发给客户端保存
← 下次连接时客户端带上这个 Ticket,可以跳过完整握手(节省 1 RTT)
← Ticket 有效期通常 24 小时
2. Change Cipher Spec
← "从下一条消息开始,我也切换到加密模式"
3. Encrypted Handshake Message(即服务器的 Finished)
← 服务器的握手完成确认,同样是所有握手消息的哈希
══════ 至此 TLS 握手完成 ══════
双方都持有相同的 Session Key,后续所有数据加密传输8.9 Application Data:加密的 HTTP 请求(包 351)
包 351:C → S TLSv1.2 159 字节
Info: Application Data
TLS Record:
Content Type: Application Data (23) ← 0x17
Version: TLS 1.2 (0x0303)
Length: 119
Encrypted Data: [119字节密文] ← 看不到原文
这 119 字节密文解密后是:
GET / HTTP/1.1\r\n
Host: httpbin.org\r\n
User-Agent: learn-networking/1.0\r\n
Connection: close\r\n
\r\n
(配置 SSLKEYLOGFILE 后 Wireshark 可以直接显示明文)
注意:TLS Record 的 Length=119,但 TCP 包总长 159 字节
差值 = 159 - 20(IP) - 20(TCP) - 5(TLS header) = 114...
实际上 TLS Record 头部 5 字节 + 密文 119 字节 + TCP/IP 头 40 字节 = 164
Wireshark 显示的 Length 是 TLS Payload 长度,不含 TLS 头本身8.10 Application Data:加密的 HTTP 响应(包 364-376)
响应总大小约 9827 字节,被拆成多个包传输:
包 364:S → C TLSv1.2 303 字节 ← 第一个响应包(较小,可能是 HTTP Header)
包 365:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 366:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 367:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 368:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 369:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 370:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 371:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 372:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 373:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 374:S → C TCP 1400 字节 ← [TCP PDU reassembled in 376]
包 375:S → C TLSv1.2 142 字节 ← 最后一片
中间穿插着客户端的 TCP ACK 包(包 366、368、370... 的 ACK)
为什么有这么多 "TCP PDU reassembled in 376"?
一个 TLS Application Data Record 可以最大 16384 字节
但 TCP 每次最多传 MSS=1360 字节
所以一个大的 TLS Record 被 TCP 拆成多片传输
Wireshark 把所有片段重组后,在最后一片(376)上显示完整的 TLS 消息
中间那些片段只显示 "TCP PDU reassembled in 376",不单独解析 TLS8.11 TLS 关闭 + TCP 四次挥手(包 377-382)
包 377:S → C TLSv1.2 71 字节
Info: Encrypted Alert
← TLS Alert,Content Type=21,加密的 Close Notify
← 意思是"我的数据发完了,TLS 层关闭"
← 类比 HTTP 里的 Connection: close
包 379:S → C TCP 40 字节
Info: [FIN, ACK] Seq=14372 Ack=763
← TCP 层关闭,服务器主动发起
包 380:C → S TCP 40 字节
Info: [ACK] Seq=763 Ack=14373
← 客户端确认
包 381:C → S TCP 40 字节
Info: [FIN, ACK] Seq=763 Ack=14373
← 客户端也关闭
包 382:S → C TCP 40 字节
Info: [ACK] Seq=14373 Ack=764
← 服务器确认,连接彻底关闭
TLS Close Notify vs TCP FIN 的区别:
TLS Close Notify(包377):应用层的优雅关闭,告诉对方"数据发完了"
TCP FIN(包379):传输层的关闭,释放 TCP 连接资源
两者都需要,缺一不可:
只有 TCP FIN 没有 TLS Close Notify → 对方不知道是正常结束还是连接被截断
只有 TLS Close Notify 没有 TCP FIN → TCP 连接还占着资源8.12 Wireshark 操作指南
过滤器设置(先查 IP):
# 查目标 IP
python -c "import socket; print(socket.gethostbyname('httpbin.org'))"# Wireshark 过滤器
ip.addr == 54.198.84.224 && tcp.port == 443
# 只看 TLS 握手
tls.handshake
# 只看 ClientHello(查看 SNI)
tls.handshake.type == 1
# 只看 Certificate
tls.handshake.type == 11
# 只看加密数据
tls.record.content_type == 23Follow TLS Stream:
右键任意 TLS 包 → Follow → TLS Stream
- 未配置密钥:看到乱码
- 配置 SSLKEYLOGFILE 后:看到完整 HTTP 明文
时序图:Statistics → Flow Graph → 勾选 Limit to display filter → 选 TCP Flows
可以看到每个包的方向和时间,握手的 RTT 一目了然。
9. 解密 HTTPS 流量:SSLKEYLOGFILE 方法
9.1 为什么 Wireshark 默认看不到明文
Wireshark 是一个旁观者,它只能看到网线上传输的字节。HTTPS 的密钥从来不经过网线,只存在于两端的内存里,所以 Wireshark 单独无法解密。
普通抓包看到的: 配置密钥文件后看到的:
─────────────────────────────────────────────────────
No. Protocol Info No. Protocol Info
351 TLSv1.2 Application Data 351 HTTP GET / HTTP/1.1
364 TLSv1.2 Application Data 364 HTTP HTTP/1.1 200 OK (text/html)
376 TLSv1.2 Application Data 376 HTTP [TCP segment of reassembled PDU]解密的唯一方式:从持有密钥的程序(浏览器、Python)把密钥导出来,交给 Wireshark。
9.2 解密原理:三步走
第一步:Python/浏览器把 Master Secret 写入文件
─────────────────────────────────────────────────────
tls-keys.log 内容:
CLIENT_RANDOM a43f6c49...(32字节) dea372c9...(48字节)
↑ ↑
Client Random Master Secret
(ClientHello 里的随机数,每次握手唯一)
第二步:Wireshark 用 Client Random 匹配抓包
─────────────────────────────────────────────────────
抓到一个 Application Data 包
│
▼
找到这条流的 ClientHello,取出 Client Random
│
▼
拿 Client Random 去 tls-keys.log 里逐行匹配
│
▼
找到对应的 Master Secret
第三步:从 Master Secret 推导出 Session Key,解密
─────────────────────────────────────────────────────
Master Secret + Client Random + Server Random
│
▼ PRF(伪随机函数)
▼
client_write_key(客户端加密用)
server_write_key(服务器加密用)
│
▼ AES-GCM 解密
▼
明文 HTTP 内容一句话总结:Client Random 是索引,Master Secret 是钥匙,Wireshark 用索引找到钥匙,再用钥匙开锁。
9.3 每次请求的密钥都不一样
是的,每次 TLS 握手都产生全新的密钥:
第1次连接:
Client Random = a43f6c49... ← 随机生成
ECDHE 临时私钥 a₁ ← 随机生成,握手后销毁
→ Master Secret = X₁
→ Session Key = K₁
第2次连接(哪怕1秒后,同一个服务器):
Client Random = d27883c0... ← 全新随机数
ECDHE 临时私钥 a₂ ← 全新随机数,握手后销毁
→ Master Secret = X₂ ← 完全不同
→ Session Key = K₂ ← 完全不同
tls-keys.log 里有多少行 = 发生了多少次 TLS 握手
每行对应一个独立连接,互不影响这就是前向保密的意义:即使攻击者录下了你所有的加密流量,拿到了服务器私钥,也无法解密——临时密钥早已销毁,服务器私钥推不出 Session Key。
9.4 实验:用 Python + Wireshark 解密
前提:抓包和密钥导出必须同时进行。
第一步:设置密钥导出路径
# 会话级设置(当前窗口有效,关窗口自动消失)
$env:SSLKEYLOGFILE = "$env:USERPROFILE\Desktop\tls-keys.log"
# 验证设置生效
echo $env:SSLKEYLOGFILE
# 输出:C:\Users\xxx\Desktop\tls-keys.log第二步:Wireshark 开始抓包
1. 打开 Wireshark,选择网卡(有线选"以太网",WiFi 选"WLAN")
2. 先查目标 IP:
python -c "import socket; print(socket.gethostbyname('httpbin.org'))"
3. 过滤器填入:ip.addr == <查到的IP> && tcp.port == 443
4. 点击蓝色鲨鱼鳍开始抓包第三步:发起 HTTPS 请求
# 在同一个 PowerShell 窗口运行(必须继承环境变量)
python s06_the_tls_layer.py get httpbin.org看到输出 响应状态: HTTP/1.1 200 OK 后,停止 Wireshark 抓包。
第四步:配置 Wireshark 读取密钥文件
Edit → Preferences → Protocols → TLS
(Pre)-Master-Secret log filename:
C:\Users\<你的用户名>\Desktop\tls-keys.log
点 OK配置完成后,包列表立刻重新解析,Application Data 变成 HTTP。
第五步:查看解密结果
方法一:点击 GET 请求包,下方展开
▼ Transport Layer Security
▼ TLSv1.2 Record Layer: Application Data
▼ Decrypted TLS (90 bytes) ← 新出现!
▼ Hypertext Transfer Protocol
GET / HTTP/1.1\r\n
Host: httpbin.org\r\n
User-Agent: learn-networking/1.0\r\n
Connection: close\r\n
\r\n
方法二:右键任意 HTTP 包 → Follow → HTTP Stream
看到完整的一问一答:
GET / HTTP/1.1
Host: httpbin.org
...
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 9593
...
<!DOCTYPE html>...9.5 用浏览器代替 Python(不需要写任何代码)
设置系统环境变量后,从命令行启动浏览器,所有 HTTPS 流量都能解密:
# 永久设置(只做一次,重启后仍有效)
[System.Environment]::SetEnvironmentVariable(
"SSLKEYLOGFILE",
"$env:USERPROFILE\Desktop\tls-keys.log",
"User"
)
# 重启 PowerShell 后生效
# 从命令行启动 Chrome(必须从命令行启动才能继承环境变量)
& "C:\Program Files\Google\Chrome\Application\chrome.exe"之后在浏览器里正常访问任何 HTTPS 网站,Wireshark 就能解密所有流量。
9.6 清除环境变量
# 清除会话级变量(当前窗口)
Remove-Item Env:SSLKEYLOGFILE
# 清除系统级变量(如果之前用 SetEnvironmentVariable 设置过)
[System.Environment]::RemoveEnvironmentVariable("SSLKEYLOGFILE", "User")
# 验证已清除
echo $env:SSLKEYLOGFILE # 输出空白 = 已清除9.7 安全提示
tls-keys.log 的安全性和私钥一样重要:
✗ 不要把这个文件发给别人
✗ 不要在生产环境设置 SSLKEYLOGFILE
✓ 分析完后可以删除:Remove-Item "$env:USERPROFILE\Desktop\tls-keys.log"
SSLKEYLOGFILE 打破了 HTTPS 的安全前提:
正常情况:密钥只存在于内存,不落盘,不传输
开启后:密钥写入磁盘文件,任何能读这个文件的人都能解密你的流量10. 关键概念速查表
本次实验的完整包序列(真实数据)
包号 阶段 方向 包类型 关键信息
────────────────────────────────────────────────────────────────────────
316 TCP握手 C→S [SYN] MSS=1360, WS=256
322 TCP握手 S→C [SYN, ACK] MSS=1418, WS=256
323 TCP握手 C→S [ACK] 握手完成
324 TLS握手 C→S ClientHello SNI=httpbin.org(明文)
334 TLS握手 S→C ServerHello 选定 ECDHE-RSA-AES128-GCM-SHA256
336 TLS握手 S→C [TCP分片] 证书数据第1片
337 TLS握手 S→C Certificate httpbin.org证书 + Amazon中间CA
338 TLS握手 S→C ServerKeyExchange+HelloDone ECDHE临时公钥(x25519)
339 TLS握手 C→S [ACK]
340 TLS握手 C→S ClientKeyExchange+ 客户端ECDHE公钥
ChangeCipherSpec+Finished 切换加密,握手完成确认
348 TLS握手 S→C NewSessionTicket+ Session Ticket(下次快速恢复)
ChangeCipherSpec+Finished 服务器确认握手完成
351 数据传输 C→S Application Data 加密的 HTTP GET(159字节)
364 数据传输 S→C Application Data 响应第1片(303字节)
365-375 数据传输 S→C [TCP分片 × 11] 响应数据(每片1400字节)
376 数据传输 S→C Application Data 响应最后一片(142字节)
377 关闭 S→C Encrypted Alert TLS Close Notify
379 关闭 S→C [FIN, ACK] TCP关闭
380 关闭 C→S [ACK]
381 关闭 C→S [FIN, ACK]
382 关闭 S→C [ACK] 连接彻底关闭核心概念一句话总结
| 概念 | 一句话 |
|---|---|
| TLS | TCP 之上的加密层,HTTP 数据在进入 TCP 之前先被 TLS 加密 |
| TLS 握手 | 双方协商加密算法、交换密钥材料、验证服务器身份的过程,约 2 RTT |
| SNI | ClientHello 里明文的域名字段,是 HTTPS 里唯一暴露的访问目标信息 |
| 证书 | 服务器的"身份证",由 CA 签发,包含公钥、域名、有效期 |
| 信任链 | 服务器证书 → 中间 CA → 根 CA,根 CA 在系统信任列表里 |
| ECDHE | 椭圆曲线 DH 密钥交换,每次连接生成临时密钥,保证前向保密 |
| 前向保密 | 即使服务器私钥泄露,历史会话也无法被解密(因为临时密钥已销毁) |
| Pre-Master Secret | ECDHE 协商出的共享值,是派生 Session Key 的原材料之一 |
| Session Key | 握手后派生的对称密钥,实际加密数据用它,比非对称加密快 1000 倍 |
| AES-GCM | 对称加密算法,同时提供加密和消息认证(防篡改),现代 HTTPS 标配 |
| ChangeCipherSpec | 通知对方"从这条消息之后切换到加密模式" |
| Session Ticket | 服务器发给客户端的加密会话凭证,下次连接可跳过完整握手 |
| TCP PDU reassembled | Wireshark 提示:这个包是大消息的分片,在另一个包里重组显示 |
| Encrypted Alert | TLS 层的优雅关闭通知,类似 TCP 的 FIN |
| SSLKEYLOGFILE | 浏览器/Python 导出会话密钥的文件,Wireshark 用它解密 HTTPS |
| TLS 1.3 | 握手从 2 RTT 降到 1 RTT,删除不安全算法,Certificate 加密传输 |
Wireshark HTTPS 常用过滤器
# 基础过滤
ip.addr == 54.198.84.224 && tcp.port == 443 只看这次实验的流量
tls 只看 TLS 包
tls.handshake 只看握手包
# 按握手消息类型
tls.handshake.type == 1 ClientHello(查 SNI、支持的套件)
tls.handshake.type == 2 ServerHello(查选定的套件)
tls.handshake.type == 11 Certificate(查证书内容)
tls.handshake.type == 12 ServerKeyExchange(查 ECDHE 公钥)
tls.handshake.type == 14 ServerHelloDone
tls.handshake.type == 16 ClientKeyExchange(查客户端 ECDHE 公钥)
tls.handshake.type == 4 NewSessionTicket
# 按 TLS Record 类型
tls.record.content_type == 20 ChangeCipherSpec
tls.record.content_type == 21 Alert(包括 Close Notify)
tls.record.content_type == 22 Handshake
tls.record.content_type == 23 Application Data(加密数据)
# 解密后使用
http 解密后的 HTTP 流量
http.request.method == "GET" 只看 GET 请求
http.response.code == 200 只看 200 响应
# 排查问题
tls.alert_message TLS 错误(握手失败、证书错误等)
tcp.analysis.retransmission TCP 重传包(网络质量差时出现)HTTP vs HTTPS 对比
维度 HTTP HTTPS
─────────────────────────────────────────────────────────────────
端口 80 443
加密 无,明文传输 TLS 加密,密文传输
握手开销 TCP 三次握手(1.5 RTT) TCP + TLS 1.2(3.5 RTT)
TCP + TLS 1.3(2.5 RTT)
证书 不需要 需要(Let's Encrypt 免费)
Wireshark 可读 完全可读 IP/TCP/TLS头可读,Payload密文
配置 SSLKEYLOGFILE 后可解密
SNI 无 ClientHello 里明文可见域名
中间人攻击 容易 需要伪造受信任 CA 签发的证书
性能 略快(无加密开销) 现代硬件有 AES-NI 指令,差距可忽略
TCP 分片 HTTP 数据直接分片 TLS Record 先封装,再被 TCP 分片
关闭方式 TCP FIN TLS Close Notify + TCP FIN
