文章内容
一、HTTPS 防中间人:先说清楚在防什么
"HTTPS 为什么能防中间人攻击"是网络方向的高频面试题。多数答案停在"因为加密了",这话不算错,但不全。
1.1 中间人攻击的场景

中间人(Man-in-the-Middle)是横在客户端和服务端之间、能截获并转发流量的一方——公共 Wi-Fi 热点、被投毒的 DNS、链路上的设备都可能扮演这个角色。它的目标不是看懂密文,而是让客户端误以为自己在跟真正的服务端说话。
一旦冒充成立,客户端与中间人建一条加密连接,中间人再与真服务端建另一条。两段都加密,明文却在中间那一环完整暴露。
1.2 "加密"为什么不够
加密解决的是内容保密:数据在链路上被抄走也读不懂。可加密总要有个对象——你用谁的密钥加密,谁就能解开。身份没确认之前,你并不知道那把密钥是不是中间人给你的。
用攻击者的公钥加密再工工整整发过去,算法本身毫无问题,被读走的却是明文。加密保护内容,不保护身份,混着谈就会漏掉真正的防线。
1.3 标准回答的全貌:三层
HTTPS 防中间人分成三层,缺一层都不成立:
- 证书验证身份——确认对面是不是目标网站;
- 密钥协商——生成一把只有双方知道的对称会话密钥;
- 对称加密传输——用这把密钥保护后续所有业务数据。
第一层被绕过,后面两层做得再好也是在跟攻击者安全通信。
二、第一层:证书验证身份
2.1 验证什么
TLS 握手时服务端会把证书发给客户端,里面写着服务器公钥、绑定域名、有效期和签发机构。客户端逐项检查:
- 有效期——是否在
notBefore与notAfter之间,过期直接拒绝; - 吊销状态——通过 CRL 或 OCSP 查它有没有被提前作废(私钥泄露时签发方会主动吊销);
- 域名匹配——证书的 SAN(Subject Alternative Name)字段是否覆盖你正在访问的域名。
域名匹配最容易被忽视。中间人可能持有一张完全合法、权威 CA 签发、也没过期的证书,只不过那是他自己域名的。少了这一步校验,这张证书就能拿来冒充任何站点。
2.2 证书链

CA(Certificate Authority,证书颁发机构)是被公认可信的签发方,浏览器和操作系统内置的那份根证书列表就是整套信任体系的起点。
根 CA 的私钥离线保管,不直接签发网站证书,而是签发中间 CA,再由中间 CA 签发服务端证书,形成一条链:根证书 → 中间 CA 证书 → 服务端证书。客户端用中间 CA 的公钥验服务端证书的签名,再用根证书的公钥验中间 CA 的签名,一路上溯到内置可信根。
链条上任何一环断掉——签名对不上、中间证书缺失、某一级过期或被吊销——验证就该失败。
2.3 中间人为什么造不出合法证书
想冒充 example.com,中间人需要一张绑定该域名、能链到可信根的证书,还要有对应私钥。私钥只在真服务端手里,从公开证书推不出来;权威 CA 签发前又要求申请者证明对域名的控制权。
自签一张证书甚至自建 CA 都做得到,但这个 CA 不在可信根列表里,验证到顶端无法上溯,浏览器立刻拦截并弹警告页。
但是有两个例外:
- 用户被诱导装了攻击者的根证书,此后攻击者签的证书在这台设备上都"合法";
- 企业在受控设备上预装自有根证书、由代理网关做解密审计,例如Charles、mitmproxy 能看明文也是同一原理。
上述两者的前提都是信任根被动过手脚,除此之外,普通中间人无法让假证书安静的通过校验。
三、第二层:密钥协商
3.1 目标

证书验证通过只说明对面身份属实。接下来双方要在一条被监听的链路上商量出一把只有彼此知道的对称密钥——会话密钥(session key),只服务本次连接,结束即作废。难点在于握手报文可能被完整记录,协商机制必须做到:把所有握手消息抄一份,也算不出那把密钥。
3.2 早期 RSA 与现代临时密钥协商
早期 TLS 用 RSA 密钥交换:客户端生成一段随机数(pre-master secret),用证书里的服务端公钥加密发出,服务端用私钥解开,双方据此推导会话密钥。中间人没有私钥,解不开。
缺陷是结构性的:所有会话都依赖同一把长期私钥,攻击者可以先存下流量,等私钥某天泄露(内鬼、心脏出血这类漏洞),再回头把历年密文全部解开。
现代 TLS 改用 ECDHE——椭圆曲线迪菲-赫尔曼临时密钥交换。双方各生成一对临时密钥,只交换公开部分,各自结合对方的公开值算出同一个共享秘密,这个秘密从未上过网络。"临时"是关键:每次握手都换一对新的,用完丢弃;服务端的长期私钥只用于签名,证明临时参数确实出自本人,不参与密钥计算。
由此得到前向安全(Forward Secrecy):长期私钥日后泄露,也解不开此前录下的会话,因为那些临时私钥早已不存在。TLS 1.3 干脆移除了 RSA 密钥交换,只保留(EC)DHE。
四、第三层:对称加密传输
4.1 为什么不用非对称一直传
会话密钥到手,业务数据全部改用对称加密——加解密同一把密钥,典型算法是 AES-GCM 和 ChaCha20-Poly1305。
非对称加密不参与数据面,原因是算力:RSA 要做大数模幂运算,AES 在现代 CPU 上有 AES-NI 指令集直接支持,吞吐差着数量级。还有一条硬约束——RSA-2048 配 OAEP 填充单次最多加密 190 字节,一个 1MB 的响应体要切成五千多块,工程上不成立。
4.2 分工
非对称加密负责握手:验证证书签名、确认身份、保护密钥协商;对称加密负责握手之后的全部数据传输。信任建立一次,高性能传输持续整场连接。
五、代码验证
5.1 证书链验证
ssl 模块走的就是系统那套验证逻辑,能直接看到链验证的结果和失败时的报错类型:
import ssl # 标准库,封装 OpenSSL 的 TLS 实现import socket # 建立底层 TCP 连接 ctx = ssl.create_default_context() # 默认上下文:加载系统内置可信根,并开启 check_hostname 与 CERT_REQUIREDhost = "example.com" # 要访问的目标域名 with socket.create_connection((host, 443), timeout=6) as raw: # 先建立普通 TCP 连接 with ctx.wrap_socket(raw, server_hostname=host) as tls: # 在其上完成 TLS 握手;server_hostname 同时用于 SNI 和域名校验 cert = tls.getpeercert() # 证书验证通过后才返回解析结果,失败会在 wrap_socket 抛异常 print(tls.version()) # 打印协商出的协议版本,如 TLSv1.3 print(tls.cipher()) # 打印协商出的密码套件,可看到对称算法 print(cert["subject"]) # 证书主体,含 commonName 与被绑定的域名 print(cert["issuer"]) # 签发者,通常是中间 CA 而非根 CA print(cert["notAfter"]) # 有效期截止时间 bad = "wrong.host.badssl.com" # badssl.com 提供的测试站点,证书不包含这个域名try: # 用异常捕获观察验证失败的具体类型 with socket.create_connection((bad, 443), timeout=6) as raw: # TCP 层照样连得通 ctx.wrap_socket(raw, server_hostname=bad) # 握手阶段做域名校验,这里会失败except ssl.SSLCertVerificationError as e: # 证书验证类错误的专用异常 print("验证失败:", e) # 报错信息会指明 Hostname mismatch实际跑出来,第一段打印 TLSv1.3 和 TLS_AES_256_GCM_SHA384,issuer 是中间 CA(例如 Cloudflare TLS Issuing ECC CA 3)而非根 CA,说明验证确实经中间层上溯到了内置可信根。第二段抛出 SSLCertVerificationError: ... Hostname mismatch,连接在应用层拿到任何数据之前就被切断。
5.2 非对称与对称的耗时差距
下面对同样 1KB 数据做加密加解密往返,各重复 200 轮:
import os # 生成随机测试数据与随机数import time # 计时from cryptography.hazmat.primitives.asymmetric import rsa, padding # RSA 密钥与填充方案from cryptography.hazmat.primitives import hashes # OAEP 需要指定哈希算法from cryptography.hazmat.primitives.ciphers.aead import AESGCM # 对称 AEAD 算法 DATA = os.urandom(1024) # 1KB 测试数据ROUNDS = 200 # 重复轮数,减少单次抖动 key = rsa.generate_private_key(public_exponent=65537, key_size=2048) # 生成 RSA-2048 私钥pub = key.public_key() # 取出对应公钥oaep = padding.OAEP( # OAEP 填充,RSA 加密的标准做法 mgf=padding.MGF1(hashes.SHA256()), # 掩码生成函数用 SHA-256 algorithm=hashes.SHA256(), # 标签哈希同样用 SHA-256 label=None, # 不使用可选标签)CHUNK = 190 # RSA-2048 + OAEP/SHA-256 单次上限:256 - 2*32 - 2 = 190 字节blocks = [DATA[i:i + CHUNK] for i in range(0, len(DATA), CHUNK)] # 1KB 必须切成 6 块才能加密 t0 = time.perf_counter() # 记录起点for _ in range(ROUNDS): # 重复 200 轮 for b in blocks: # 逐块处理 key.decrypt(pub.encrypt(b, oaep), oaep) # 公钥加密后立刻私钥解密,构成一次完整往返t_rsa = time.perf_counter() - t0 # RSA 总耗时 aead = AESGCM(AESGCM.generate_key(bit_length=256)) # 生成 256 位对称密钥并构造 AES-GCM 实例t0 = time.perf_counter() # 重新计时for _ in range(ROUNDS): # 同样 200 轮 nonce = os.urandom(12) # GCM 要求每次使用不重复的 12 字节 nonce aead.decrypt(nonce, aead.encrypt(nonce, DATA, None), None) # 整块 1KB 一次加密再解密,无需分块 t_aes = time.perf_counter() - t0 # AES 总耗时print(f"RSA {t_rsa * 1000:.1f} ms, AES {t_aes * 1000:.1f} ms, 相差 {t_rsa / t_aes:.0f} 倍")一台普通开发机上的结果是 RSA 约 646 毫秒、AES-GCM 约 0.6 毫秒,相差三个数量级(数值随 CPU 与 AES-NI 支持情况波动,量级稳定)。再算上分块的复杂度,用非对称加密扛数据面就被废弃了。
六、面试回答骨架
6.1 三层回答模板

按这个顺序说:
第一层,证书验证身份。 校验有效期、吊销状态、域名匹配,并沿证书链验到内置可信根。中间人拿不到目标域名的合法证书和私钥,自签证书又接不上可信根。
第二层,密钥协商。 双方用 ECDHE 这类临时密钥交换算出会话密钥,握手报文被录下也推不出它。
第三层,对称加密传输。 业务数据用会话密钥做对称加密,兼顾性能与保密。
总结:HTTPS 防中间人不是靠"内容看不懂",而是在加密之前先确认对方的身份。
6.2 两个常见追问
"中间人自己签一张证书行不行?" 签得出来,但自建 CA 不在可信根列表里,链验证失败。除非用户手动装了攻击者的根证书,或企业在受控设备上预置根证书做审计——前提都是信任根被改动,而非 TLS 被攻破。
"非对称和对称各负责什么?" 非对称用于握手和信任建立,对称负责握手之后的数据传输——它快得多,也没有单次长度限制。
回到最初那个问题:只说"因为加密"不够,是因为加密只保护内容,而中间人攻的是身份。
本文由 AgentPlanFlow 生成