跳过证书校验,中间人照样握手成功
主线位置:校验主线的全部,加上握手主线里客户端的
ExpectCertificateVerify。ServerCertVerifier的四个方法、dangerous()这道关卡、no-op 校验器和「只保留签名」的校验器各跳过了什么,见客户端证书与自定义校验。这一页不再拆方法,只做一件事:在内存里放一个中间人,看客户端的密码最后到了谁手里。
sequenceDiagram participant C as 客户端 participant M as 中间人 C->>M: ClientHello bank.test M->>C: 自造证书 和 签名 Note over C: 校验器说 Ok C->>M: Finished C->>M: password=hunter2 Note over M: 解密 读到明文
实验 crate:exp-rustls/pitb-lab,src/bin/p17_mitm.rs。真正的银行 bank.test 用「Real CA」签的证书,客户端信任 Real CA。攻击者自己造了一个「Attacker CA」,用它签了一张名字也是 bank.test 的证书。客户端以为在连 bank.test,字节实际交给攻击者的 ServerConnection;握手成功就写一行 POST /login password=hunter2,看对面读到什么。四种客户端校验器:
NoVerifier:四个方法全部返回assertion()。SignatureOnly:证书和名字直接通过,两个签名方法调verify_tls13_signature真的验。Pinned(real leaf):只接受和真实叶子逐字节相同的证书,签名照样验。- 默认的
WebPkiServerVerifier。
ring provider,TLS 1.3。M2 Max、macOS 27、rustc 1.99.0,release。
现象
== attacker in the middle
NoVerifier: handshake Ok; attacker reads "POST /login password=hunter2"
SignatureOnly: handshake Ok; attacker reads "POST /login password=hunter2"
Pinned(real leaf): client error: invalid peer certificate: ApplicationVerificationFailure
debug: InvalidCertificate(ApplicationVerificationFailure)
(server then sees: received fatal alert: AccessDenied)
default WebPkiServerVerifier: client error: invalid peer certificate: UnknownIssuer
debug: InvalidCertificate(UnknownIssuer)
(server then sees: received fatal alert: UnknownCA)
== the real server
Pinned(real leaf): handshake Ok; real server reads "POST /login password=hunter2"
default WebPkiServerVerifier: handshake Ok; real server reads "POST /login password=hunter2"
前两行就是这个坑:没有报错,没有警告,握手成功,连接是加密的,密码明文落在攻击者手里。保留签名校验的 SignatureOnly 和什么都不查的 NoVerifier 结果一样。
原因
签名证明的只是「对面有这张证书的私钥」。 客户端在 ExpectCertificateVerify::handle 里依次调校验器的两个方法:
// 1. Verify the certificate chain.
// ...
let cert_verified = self
.config
.verifier
.verify_server_cert(
end_entity,
intermediates,
&self.server_name,
&self.server_cert.ocsp_response,
now,
)
// ...
// 2. Verify their signature on the handshake.
let handshake_hash = self.transcript.current_hash();
let sig_verified = self
.config
.verifier
.verify_tls13_signature(
construct_server_verify_message(&handshake_hash).as_ref(),
end_entity,
cert_verify,
)
第二步拿的公钥来自 end_entity,也就是对面自己发来的那张证书。攻击者发的是自己造的证书,签名用的是自己的私钥,当然对得上。「这张证书是不是真的银行的」只在第一步回答:证书链能不能连到客户端信任的根、名字对不对。第一步返回 assertion(),第二步就只是在确认「攻击者是攻击者」。
名字检查也在第一步里。 攻击者这次把 bank.test 写进了 SAN,名字检查本来也会通过;把它挡下的只有证书链:默认校验器报 UnknownIssuer,因为 Attacker CA 不在客户端的根里。对 no-op 校验器来说,名字写什么都一样。
rustls 自己不再补验。 状态机把这两件事完全交给 ServerCertVerifier,ServerCertVerified 和 HandshakeSignatureValid 只是「校验器说过 OK」的标记,assertion() 可以凭空造出来。
修法
最好是不要自定义: 证书不被信任时,把签发它的 CA 加进 RootCertStore(UnknownIssuer、内网 CA 的站点连不上),而不是关掉校验。
只信一台服务器:固定它的证书。 Pinned 的 verify_server_cert 拿握手里的叶子和事先存好的 DER 逐字节比较,不一样就返回 CertificateError::ApplicationVerificationFailure(发给对面的告警是 access_denied,映射在 From<CertificateError> for AlertDescription),两个签名方法调 verify_tls13_signature / verify_tls12_signature:
impl ServerCertVerifier for Pinned {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_i: &[CertificateDer<'_>],
_n: &ServerName<'_>,
_o: &[u8],
_t: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
if end_entity.as_ref() == self.0.as_ref() {
Ok(ServerCertVerified::assertion())
} else {
Err(rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure))
}
}
fn verify_tls13_signature(&self, m: &[u8], c: &CertificateDer<'_>, d: &DigitallySignedStruct) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(m, c, d, &algs())
}
// verify_tls12_signature likewise; supported_verify_schemes() returns algs().supported_schemes()
}
上面的输出里它拒绝了中间人、接受了真服务器。这里签名校验是必需的:固定的是一张公开的证书,攻击者也拿得到它,能挡住攻击者的只有「证书里的公钥对应的私钥在真服务器手里」这一条,也就是第二步。代价是证书一换(续期、轮换密钥)客户端就连不上,固定公钥(SPKI)而不是整张证书可以撑过不换密钥的续期,但思路一样:事先知道对面是谁。
要在标准校验之外加条件:包一层,不要替换。 自定义校验器内部持有一个 WebPkiServerVerifier,verify_server_cert 先调它、通过之后再做自己的额外检查,两个签名方法原样转发给它。这样标准的链和名字检查一项不少。
确实只是本地调试: 把 no-op 校验器放在显式的开关后面,比如 rustls 自带示例 tlsclient-mio 的 --insecure,并且让它只在调试构建里存在。
怎么检查
- 搜代码: 自定义校验器必然经过
dangerous(),trait 在rustls::client::danger里(客户端证书与自定义校验讲过为什么是模块而不是 feature)。grep -rn 'danger' src/列出每一处,逐个看verify_server_cert有没有无条件返回assertion()。 - 自己当一次中间人: 本页的实验就是测试写法。给自定义校验器写一条测试:一个用陌生 CA 签发、名字正确的服务器,握手必须失败。
- 依赖里也要看: HTTP 客户端、数据库驱动常有「接受无效证书」之类的选项,打开它们等于装上了 no-op 校验器。
小结
- no-op 校验器让握手照常成功,中间人用自造的证书就能读到明文;没有任何报错。
- 只保留签名校验也一样:签名只证明对面有它自己那张证书的私钥,证明不了它是谁。
- 能挡住中间人的是证书链和名字匹配,或者事先固定的证书 / 公钥;后者也必须保留签名校验。
- 优先把 CA 加进根证书库;要加条件就包住
WebPkiServerVerifier,不要替换它。