Rust in Everything
基于 rustls 0.23.46

跳过证书校验,中间人照样握手成功

主线位置:校验主线的全部,加上握手主线里客户端的 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,不要替换它。

💬 关联讨论

发起讨论
加载中...