这是《一篇文章带你读懂 XXX 攻击》系列的第二篇文章,本篇文章主要讲述 TLS Poison 攻击对应的三种攻击方式、一些可能算是“新”的 DNS Rebinding 技巧以及一些关于 IP 选择探索等内容。

[TOC]

Preface

使用《一篇文章带你读懂 XXX 攻击》的标题是为了督促自己把一个攻击的尽可能多的细节尽可能的搞懂,也是为了提升自己的写作以及表述水平。本文旨在帮助大家了解学习 TLS Poison 攻击,希望通过一篇文章让大家读懂 TLS Poison 攻击,但是仅仅网络协议涉及到很多内容,靠本文是不可能完全读懂的,本文的内容也并非完全正确,所以也希望大家抱着怀疑的态度合理对文章提出质疑。

本文主要是对 Black Hat USA 2020 - When TLS Hacks You 议题的整理与复盘,该攻击是一种利用 TLS 协议特性结合客户端实现缺陷达到攻击内网应用的攻击方式,可以达到任意写入 Memcached 等内网服务的攻击效果,进而配合其他漏洞造成 RCE 等危害。

这是去年的在 black hat USA 提出的一项攻击方式,但是作者在提出这个攻击方式后,由于种种因素,他放出来的 demo 并不能直接使用,所以在对该项的研究复现中整理了很多攻击细节,以及一些可以拓展的地方,还有一些关于计算机科学知识的探索。

本文主要分成三部分,第一部分主要简单讲述一些必要的背景知识,第二部分会详细记录三种攻击方式的实现步骤,第三部分会讲述一些关于在复现过程中的一些思考以及相关探索的内容。如果还有后续的进展,我也会同步到自己的个人博客上,欢迎关注,以及前来交流:https://blog.zeddyu.info/

如果对该篇文章有任何疑问或者质疑,欢迎来信:echo emVkZHl1Lmx1QGdtYWlsLmNvbQ==|base64 -d

欢迎对协议安全等内容感兴趣的同学一起交流学习!

PS: 如无特殊说明,整个实验背景均基于 Ubuntu 20.04 LTS ,curl 7.68.0 build with OpenSSL/1.1.1f ,后文提到的 IPv6 均指的是 IPv4-mapped IPv6 addresses 这类地址

Background

TLS Overview

传输层安全性协议(英语:Transport Layer Security,缩写:TLS)及其前身安全套接层(英语:Secure Sockets Layer,缩写:SSL)是一种安全协议,目的是为互联网通信提供安全及数据完整性保障。网景公司(Netscape)在1994年推出首版网页浏览器-网景导航者时,推出HTTPS协议,以SSL进行加密,这是SSL的起源。IETF将SSL进行标准化,1999年公布TLS 1.0标准文件(RFC 2246)。随后又公布TLS 1.1(RFC 4346,2006年)、TLS 1.2(RFC 5246,2008年)和TLS 1.3(RFC 8446,2018年)。在浏览器、电子邮件、即时通信、VoIP、网络传真等应用程序中,广泛使用这个协议。许多网站,如Google、Facebook 等也以这个协议来创建安全连线,发送资料。目前已成为互联网上保密通信的工业标准。

Netscape 开发了名为安全套接字层(Secure Socket Layer,SSL)的上一代加密协议,TLS 由此演变而来。TLS 1.0 版的开发实际上始于 SSL 3.1 版,但协议的名称在发布之前进行了更名,以表明它不再与 Netscape 关联。由于这个历史原因,TLS 和 SSL 这两个术语有时会互换使用。

该协议由两层组成: TLS 记录协议(TLS Record)和 TLS 握手协议(TLS Handshake)。

因为本文侧重点并非 TLS 本身的加密算法流程,所以会忽略很多密码算法流程,只提其中对我们后续攻击相关的部分,密码算法部分感兴趣的读者可以自行搜索了解。

TLS Handshake

TLS 握手是启动使用 TLS 加密的通信会话的过程。在 TLS 握手期间,两个通信方交换消息以相互确认,彼此验证,确立它们将使用的加密算法,并就会话密钥达成共识。它定义了消息的格式和交换的顺序。这些可以根据客户端和服务器的需求而变化,也就是说,有几种可能的程序来建立连接。初始交换的结果是TLS连接成功(双方都准备好用TLS传输应用数据)或发出警报消息。

每当用户通过 HTTPS 导航到网站,并且浏览器首先开始查询网站的源站服务器时,都会进行 TLS 握手。每当其他任何通信使用 HTTPS(包括 API 调用和 HTTPS 上的 DNS 查询)时,也会发生 TLS 握手。通过 TCP 握手打开 TCP 连接后,将发生 TLS 握手。

在 TLS 握手过程中,客户端和服务器一同执行以下操作:

  • 指定将要使用的 TLS 版本(TLS 1.0、1.2、1.3 等)
  • 决定将要使用哪些密码套件
  • 通过服务器的公钥和 SSL 证书颁发机构的数字签名来验证服务器的身份
  • 生成会话密钥,以在握手完成后使用对称加密
  • 检查是否需要恢复会话

TLS 握手是由客户端和服务器交换的一系列数据报或消息。TLS 握手涉及多个步骤,因为客户端和服务器要交换完成握手和进行进一步对话所需的信息。 TLS 握手的确切步骤将根据所使用的密钥交换算法的类型以及双方支持的密码套件而有所不同,RSA 密钥交换算法最为常用。但是并非所有 TLS 握手均使用非对称加密(公钥和私钥),但并非全都会在生成会话密钥的过程中使用私钥。例如 Diffie-Hellman 握手等,这里不做过多介绍。

TLS Record

TLS Record 协议使用握手过程中创建的密钥来确保应用数据的安全。记录协议负责保护应用数据的安全,并验证其完整性和来源。它管理以下内容:将传出的消息分为可管理的块、重新组合传入的消息、压缩外发报文块和解压接收报文块(可选)、将信息验证码(Message Authentication Code, MAC)应用到外发信息并使用 MAC 验证接收信息、加密外发报文和解密接收报文。当 TLS Record 协议完成后,外发加密数据被传到传输控制协议(TCP)层进行传输。

TLS 1.2

TLS 1.2 HankShake

由于历史原因,TLS 的前身 SSL 已经被废弃;现行趋势中,主流 TLS 版本为 1.2 ,并且 1.2 对于 1.1 的改动相对于本文重点来说并不重要,并且现在处于推广 1.3 的时代,我们这里从 TLS 1.2 开始讲起。

  1. Client hello: 客户端发送 ClientHello 消息,指定它支持的最高 TLS 协议版本、一个随机数、一个建议的密码套件列表和建议的压缩方法。如果客户端试图执行恢复握手,它可能会发送一个会话 ID 。如果客户端可以使用应用层协议协商,它可能包括一个支持的应用协议列表,例如 HTTP/2 。

  2. Server hello: 服务器以 ServerHello 消息作出响应,包含从客户端提供的选择中选择的协议版本、随机数、密码套件和压缩方法。为了确认或允许恢复握手,服务器可以发送一个会话 ID 。选择的协议版本应该是客户端和服务器都支持的最高版本。例如,如果客户端支持 TLS 1.1 版本,服务器支持 1.2 版本,则应选择 1.1 版本;不应选择 1.2 版本。

  3. (Optional) Certificate: 服务器向客户端发送证书或证书链。 证书链通常以服务器的公钥证书开始,并以证书颁发机构的根证书结束。 该消息是可选的,但是在需要服务器身份验证时使用。

  4. (Optional) Certificate request: 如果服务器必须对客户端进行身份验证,则它将向客户端发送证书请求。 在Internet应用程序中,很少发送此消息。

  5. (Optional) Server key exchange: 如果来自证书的公钥信息不足以进行密钥交换,则服务器会向客户端发送服务器密钥交换消息。 例如,在基于Diffie-Hellman(DH)的密码套件中,此消息包含服务器的DH公钥。

  6. Server hello done: 服务器告诉客户端它已经完成了其初始协商消息。

  7. (Optional)Certificate: 如果服务器从客户端请求证书,则客户端将发送其证书链,就像服务器之前所做的一样。

    Note: 只有少数Internet服务器应用程序要求客户端提供证书。

  8. Client key exchange: 客户端生成用于创建用于对称加密的密钥的信息。 对于 RSA ,客户端随后使用服务器的公共密钥对该密钥信息进行加密并将其发送到服务器。 对于基于 DH 的密码套件,此消息包含客户端的 DH 公钥。

  9. (Optional) Certificate verify: 如前所述,当客户端出示证书时,此消息由客户端发送。 其目的是允许服务器完成对客户端进行身份验证的过程。 使用此消息时,客户端使用加密哈希函数发送其进行数字签名的信息。 当服务器使用客户端的公共密钥解密此信息时,服务器便能够对客户端进行身份验证。

  10. Change cipher spec: 客户端发送一条消息,告知服务器更改为加密模式。

  11. Finished: 客户端告诉服务器已准备好开始安全数据通信。

  12. Change cipher spec: 服务器发送一条消息,告知客户端更改为加密模式。

  13. Finished: 服务器告诉客户端它已准备好开始安全数据通信,握手到此结束。

  14. Encrypted data: 客户端和服务器使用对称加密算法和在客户端问候和服务器问候期间协商的加密哈希函数,以及使用客户端在客户端密钥交换期间发送给服务器的秘密密钥进行通信。 此时可以重新协商握手。

  15. Close Messages: 在连接结束时,双方都会发送 close_notify Alert 报文,以通知对等方该连接已关闭。

大致流程如下图所示

对于不同的密钥算法又会产生稍微不一致的流程,这里并不作为重点,所以我们就不再展开描述了。

TLS 1.2 Session Resumption Overview

完整的 TLS 握手产生的额外延时和计算成本对所有需要安全通信的应用程序牺牲了很多性能代价,为了帮助降低部分成本, TLS 提供了一种机制恢复会话机制,用来恢复或共享多个连接之间的相同协商的秘钥数据。会话恢复是一个重要的优化部署,简略的握手消除了一个完整的 TLS 握手往返耗时,大大降低了双方的计算成本。在 TLS 1.2 中, TLS Session Resumption 可以采用 Session ID 和会话票机制来实现。除了性能上的优势外,恢复的会话还可以用于单点登录,因为它保证了原始会话和任何恢复的会话都来自同一个客户端。

TLS 1.2 Session Resumption - Session ID

在这种机制中,服务器在与客户端初次握手时,服务器会随机分配一个 Session ID。客户端和服务器将这个会话ID与会话密钥和连接状态一起存储。为了恢复会话,客户端将存储的会话ID与第一个协议消息(ClientHello)一起发送给服务器。如果服务器识别到了连接并愿意恢复会话,它就会用相同的会话ID来回复,重新建立各自的会话。这样就可以快速建立安全的连接,而且由于我们重用了之前协商好的会话数据,所以不会损失安全性。

Client 在一开始发送 ClientHello 消息中, ClientHello 消息中包括一个可变长度的 Session ID。如果为空则表示是一个新的会话,也就是客户端与服务端第一次握手,Server 在返回 ServerHello 时就会发送一个 Session ID ,此时内容为 Server 产生,当协商握手完成后,Session ID 就变得有效,并一直存在,直到由于超过有效时间或因为在与会话相关的连接上遇到服务器错误而被删除。如果不为空,该值就表示客户端希望重用该会话的安全参数,Server 会检查它的会话缓存以进行匹配,如果匹配成功,并且 Server 愿意在指定的会话状态下重建连接,它将会发送一个带有相同会话 ID 值的 ServerHello 消息,这时 Client 和 Server 必须都发送 ChangeCipherSpec 消息并且直接发送 Finished 消息,一旦重建立完成,Client 和 Server 可以开始交换应用层数据。如果一个会话 ID 不匹配,Server 会产生一个新的会话 ID,然后 TLS Client 和 Server 需要进行一次完整的握手。

      Client                                                Server

      ClientHello                   -------->
                                                       ServerHello
                                                [ChangeCipherSpec]
                                    <--------             Finished
      [ChangeCipherSpec]
      Finished                      -------->
      Application Data              <------->     Application Data

          Figure 2.  Message flow for an abbreviated handshake

在 RFC 5246 中,对 SessionID 做出了规定,其长度为 0-32 位:

opaque SessionID<0..32>;

同时 RFC 建议 Session ID的寿命上限为24小时,因为获得master_secret的攻击者可能会冒充被入侵的一方,直到相应的 Session ID 失效。

整个重用过程我们可以从下图比较直观的看到:

客户端首先发送了一个 Client Hello 消息给服务端,并且其 Session ID 为空,这时候 Server 响应了 Server Hello 当中就会返回一个 32 字节长度的 Session ID。现在客户端和服务器的 TLS 会话缓存中都存储了 Session ID,其值为 56bcf9f6ea40ac1bbf05ff7fd209d423da9f96404103226c7f927ad7a2992433。这样做的好处就是,在下一次TLS连接请求中,客户端不需要再经历完整的TLS握手。

客户端只需在其 Client Hello 消息中发送之前从 Server 那里得知的 Session ID ,然后 Server 确认这个 Session ID 在它的 TLS 会话缓存之后,它们就会进行所谓的 Abbreviated TLS Handshake 。在这次 TLS 握手过程中不会交换证书或密钥信息,之前协商好的密钥会被重新使用,这样就完成了一次 TLS Session Resumption 。

TLS 1.2 Session Resumption - Session Ticket

然而,Session ID 机制的一个实际限制是要求服务器为每个客户端创建和维护一个会话缓存。这就导致了服务器上的几个问题,每天可能会有成千上万甚至上百万个独特的连接;每一个打开的TLS连接都会消耗内存,需要 Session ID 缓存和删除策略,以及对于有许多服务器的热门网站的部署挑战,理想情况下,这些网站应该使用共享的 TLS Session 缓存以获得最佳性能。因此,对于任何多服务器的部署,Session ID 都需要一些仔细的思考和系统架构,以确保会话缓存的良好运行。

为了解决服务器端部署 TLS 会话缓存的这一问题,引入了 Session Ticket (RFC 5077)替换机制,它取消了服务器保留每个客户端会话状态的要求。取而代之的是,如果客户端表示支持会话票,服务器可以包含一个会话票记录,其中包括所有用只有服务器知道的秘密密钥加密的协商会话数据。然后,该会话票由客户端存储,并且可以包含在后续会话的握手消息中。因此,所有的会话数据只存储在客户端,但票据仍然是安全的,因为它是用只有服务器知道的密钥加密的。

会话票机制被称为无状态恢复机制。无状态恢复机制的主要改进是取消了服务器端的会话缓存,简化了部署,要求客户端在每次与服务器的新连接时提供会话票据,直到票据过期。

TLS SessionTicket 是一个扩展,其基于 RFC4366 。Ticket 的格式是一个 opaque 的结构,用于携带特定会话的状态信息。RFC 推荐的 Session Ticket 结构如下:

struct {
  uint32 ticket_lifetime_hint;
  opaque ticket<0..2^16-1>;
} NewSessionTicket;

struct {
  opaque key_name[16];
  opaque iv[16];
  opaque encrypted_state<0..2^16-1>;
  opaque mac[32];
} ticket;

这个扩展可以在 ClientHello 和 ServerHello 中发送。如果客户端拥有一个想要用来恢复会话的 ticket,那么它就会在 ClientHello 中的 SessionTicket 扩展中包含这个 ticket。如果客户端没有票据,并且准备在 NewSessionTicket 握手消息中接收票据,那么它必须在 SessionTicket 扩展中包含一个长度为零的 Session Ticket 。如果客户端不准备在 NewSessionTicket 握手消息中接收票据,则必须不包含 SessionTicket 扩展,除非客户端发送通过其他方式从服务器收到的非空票据。

         Client                                               Server

         ClientHello
        (empty SessionTicket extension)-------->
                                                         ServerHello
                                     (empty SessionTicket extension)
                                                        Certificate*
                                                  ServerKeyExchange*
                                                 CertificateRequest*
                                      <--------      ServerHelloDone
         Certificate*
         ClientKeyExchange
         CertificateVerify*
         [ChangeCipherSpec]
         Finished                     -------->
                                                    NewSessionTicket
                                                  [ChangeCipherSpec]
                                      <--------             Finished
         Application Data             <------->     Application Data

   Figure 1: Message Flow for Full Handshake Issuing New Session Ticket

如上流程图所示,客户端通过在 ClientHello 消息中包含一个 SessionTicket TLS 扩展名来表示它支持这种机制,此时 SessionTicket 为空,服务器将发送一个空的SessionTicket 扩展来表示它将使用 NewSessionTicket 握手消息发送一个新的 Session Ticket,该消息是在服务器成功验证客户端的 Finished 消息后,在ChangeCipherSpec 消息之前的 TLS 握手期间发送。在得到 Session Ticket 后,Client 将该 Session Ticket 与主密和其他与当前会话相关的参数一起缓存。

         Client                                                Server
         ClientHello
         (SessionTicket extension)      -------->
                                                          ServerHello
                                      (empty SessionTicket extension)
                                                     NewSessionTicket
                                                   [ChangeCipherSpec]
                                       <--------             Finished
         [ChangeCipherSpec]
         Finished                      -------->
         Application Data              <------->     Application Data

    Figure 2: Message Flow for Abbreviated Handshake Using New Session
                                  Ticket

当客户端希望恢复会话时,它在 ClientHello 消息中的 SessionTicket 扩展中包含该票据。然后服务器对收到的票据进行解密,验证票据的有效性,从票据的内容中检索会话状态,并使用这个状态来恢复会话。如果服务器成功验证了客户端的票据,那么就可以在 ServerHello 之后加入 NewSessionTicket 握手消息来续订票据。

我们可以通过实例来进一步了解这个机制:

客户端首先通过在 Client Hello 消息中添加 SessionTickets TLS Extension 来表明它支持无状态会话恢复(绿色部分), Server 还会通过发送包含有空的 SessionTicket TLS Extension 的 Server Hello 消息给 Client ,表示 Server 支持 SessionTicket TLS Extension(红色部分)。

在握手完成之前,也就是 Finished 之前,Server 会发送一个名为 New Session Ticket 的新 TLS 消息,其中包含加密的会话信息(如主密、使用的密码等),Server 可以在稍后使用它专门为此生成的唯一密钥进行解密。从这一点开始,Client 会将 Session Ticket 保存在它的 TLS 缓存中,直到下一次它在 Session Ticket过期之前都可以使用它与之前的 Server 恢复 TLS 会话。

现在,当 Client 想要重新使用之前的会话时,它在 Client Hello 消息的 SessionTicket TLS Extension 中发送了 Session Ticket,此时我们所注意到,客户端也创建了一个新的会话 ID ,用于以下目的:

  • 服务器回复相同的 Session ID 表示 Server 接受 Session Ticket ,并将重用该 Session 。
  • 服务器回复空的/不同的 Session ID :Server 决定进行完全握手,原因是可能是 Session Ticket 过期了,或者它正在恢复原来的会话。 PS:这样的 Session ID 并不存储在 Server 上,否则会破坏无状态会话重用的目的。这是一次性使用,只是为了向 Client 表示 Server 接受了他们发送的session ticket。

在上述的例子中,Server 成功接受并重用了 TLS 会话,我们可以确认进行了一个 Abbreviated TLS 握手,并且在服务器的 Server Hello 消息中,Server 回复了客户端发送的相同 Session ID 。这就是一个简单的基于 Session Ticket 机制的 Session Resumption ,服务端不需要在本地存储会话信息,因此与有状态的会话恢复相比,它是一个更具扩展性的选择。

TLS 1.3

TLS 1.3 Overview

TLS 1.3 可以说是 TLS 1.2 的升级版本,它在 RFC 8446 中定义,于 2018 年 8 月发表。我们这里简要的介绍几个我们比较关心的改动:

  • 减少握手等待时间,将握手时间从 2-RTT 降低到 1-RTT,并且增加 0-RTT 模式。
  • 废除 Session ID 和 Session Ticket 会话恢复方式,统一通过 PSK 的方式进行会话恢复,并在 NewSessionTicket 消息中添加过期时间和用于混淆时间的偏移值。

在握手时相对于 TLS 1.2 发生了比较明显的改动:

  1. 与 TLS 1.2 握手类似,TLS 1.3 握手以 Client Hello 消息开始,但有一个重要的变化就是客户端发送支持的加密套件列表,并猜测服务器可能选择的密钥协议协议,也会发送它对该特定密钥协议协议的密钥共享。
  2. Server 在回复 Server Hello 时,服务器回复它所选择的密钥协议协议,其中也包括服务器的密钥共享、证书以及 Server Finished 。
  3. 现在,客户端检查服务器证书,生成密钥,并发送 Client Finished ,之后就可以发送加密数据了。

这样一来,TLS 1.3 握手就节省了整整一个来回和数百毫秒的时间,比 TLS 1.2 握手有了很大的改进。RFC 8446 提供的简要流程图如下:

       Client                                           Server

Key  ^ ClientHello
Exch | + key_share*
     | + signature_algorithms*
     | + psk_key_exchange_modes*
     v + pre_shared_key*       -------->
                                                  ServerHello  ^ Key
                                                 + key_share*  | Exch
                                            + pre_shared_key*  v
                                        {EncryptedExtensions}  ^  Server
                                        {CertificateRequest*}  v  Params
                                               {Certificate*}  ^
                                         {CertificateVerify*}  | Auth
                                                   {Finished}  v
                               <--------  [Application Data*]
     ^ {Certificate*}
Auth | {CertificateVerify*}
     v {Finished}              -------->
       [Application Data]      <------->  [Application Data]

              +  Indicates noteworthy extensions sent in the
                 previously noted message.

              *  Indicates optional or situation-dependent
                 messages/extensions that are not always sent.

              {} Indicates messages protected using keys
                 derived from a [sender]_handshake_traffic_secret.

              [] Indicates messages protected using keys
                 derived from [sender]_application_traffic_secret_N.

               Figure 1: Message Flow for Full TLS Handshake

TLS 1.2 与 1.3 简要的握手对比如下图所示:

TLS 1.3 Session Resumption - PSK

按照上文所说,TLS 1.3 用通过预共享密钥(Pre-Shared Key, PSK)恢复会话的概念取代了 1.2 当中的 Session ID 和 Session Ticket 。在最初的握手之后,服务器向客户端发送一个 PSK 标识。 PSK 内容取决于服务器,可能包含一个数据库查询密钥或一个自我加密和自我认证的票据。客户端将此PSK身份与自己的会话密钥一起存储。其中, RFC 8446 定义的 PSK 结构如下所示:

      struct {
          opaque identity<1..2^16-1>;
          uint32 obfuscated_ticket_age;
      } PskIdentity;

      opaque PskBinderEntry<32..255>;

      struct {
          PskIdentity identities<7..2^16-1>;
          PskBinderEntry binders<33..2^16-1>;
      } OfferedPsks;

      struct {
          select (Handshake.msg_type) {
              case client_hello: OfferedPsks;
              case server_hello: uint16 selected_identity;
          };
      } PreSharedKeyExtension;

在随后的握手中,客户端在给服务器的 ClientHello 消息中提供这个 PSK ,服务器根据 PSK 的内容对票据进行解密,并使用包含的会话密钥和连接状态来恢复会话,或者服务器使用包含的查找密钥在自己的数据库中查找会话密钥和连接状态。 RFC 8446 提供了一个 Session Resumption 的流程图如下:

          Client                                               Server

   Initial Handshake:
          ClientHello
          + key_share               -------->
                                                          ServerHello
                                                          + key_share
                                                {EncryptedExtensions}
                                                {CertificateRequest*}
                                                       {Certificate*}
                                                 {CertificateVerify*}
                                                           {Finished}
                                    <--------     [Application Data*]
          {Certificate*}
          {CertificateVerify*}
          {Finished}                -------->
                                    <--------      [NewSessionTicket]
          [Application Data]        <------->      [Application Data]


   Subsequent Handshake:
          ClientHello
          + key_share*
          + pre_shared_key          -------->
                                                          ServerHello
                                                     + pre_shared_key
                                                         + key_share*
                                                {EncryptedExtensions}
                                                           {Finished}
                                    <--------     [Application Data*]
          {Finished}                -------->
          [Application Data]        <------->      [Application Data]

               Figure 3: Message Flow for Resumption and PSK
  1. 客户端向服务器发送一个带有 key_share 扩展的 ClientHello 消息。该扩展列出了客户端支持的密钥交换加密方法。
  2. 服务器用一个带有 key_share 扩展名的 ServerHello 消息进行响应,这个扩展包含了它要用于密钥交换的加密方法,并且服务器将其参数一同发送给客户端。
  3. 服务器和客户端都交换认证消息。
  4. 服务器向客户端发送 NewSessionTicket 消息,其中包含一个 PSK ,客户端可以通过在 ClientHello 消息的 pre_shared_key 扩展中包含这个 PSK ,用于未来的握手。
  5. 客户端和服务器现在可以交换加密的应用数据。
  6. 在未来的握手中,客户端向服务器发送一个包含 key_share 和 pre_shared_key 扩展名的 ClientHello 消息。 pre_shared_key 扩展包含 NewTicketSession 消息中发送的 PSK 。
  7. 服务器用包含 pre_shared_key 和 key_share 扩展名的 ServerHello 消息作出响应。 pre_shared_key 扩展包含服务器同意使用的 PSK ,并将其参数发送给客户端。
  8. 服务器和客户端互相发送 Finished 消息,之后客户端和服务器可以交换加密的应用数据。

我们可以通过一个简单的例子来直观感受一下 TLS 1.3 基于 PSK 的握手过程:

如上图所示,Client 第一次与 Server 握手发送 Client Hello 消息时,只包含了 key_share 拓展,Server 也做出响应的回应,使用包含 key_share 拓展的消息进行回应。

Client 在第二次发送 Client Hello 消息时,带上了之前 Server 在 NewSessionTicket 发送的 pre_shared_key ,将其作为拓展发送至 Server ,随后 Server 响应 Server Hello 消息时,也使用 pre_shared_key 作为拓展响应,这样就完成了基于 PSK 的 Session Resumption

可能有同学会问为什么我们看不到类似 TLS 1.2 当中直接有一个 NewSessionTicket 呢?我们可以仔细回顾一下上面的 RFC 流程图,Server 返回的 NewSessionTicket 消息有个中括号,而这里的中括号表示的是使用从 [sender]_application_traffic_secret_N 导出的密钥保护的信息,所以我们使用 wireshark 无法直接看到其中的内容,但是其中我们使用 opnssl 时候能够更明显的看到 Server 发过来的 TLS Session Ticket ,如图所示,也就是我们 Client 在进行恢复会话时所携带的 PSK ,在使用 openssl 进行恢复会话时,就会提示我们已经恢复了会话。这样就完成了一次 Session Resumption

TLS 1.3 Session Resumption - 0-RTT

虽然 TLS 1.3 最大的亮点之一是 0-RTT ,但是我们这里不做详细的分析,因为我们可以从下面的 RFC 8446 提供的流程图看出来,虽然增加了一个 early_data ,但是对于本篇文章来说并不是重点,他依然使用了 pre_shared_key ,所以对于上文基于 PSK 的会话恢复模式来说基本一致,这里就不做过多分析,感兴趣的同学可以自行搜索了解。

         Client                                               Server

         ClientHello
         + early_data
         + key_share*
         + psk_key_exchange_modes
         + pre_shared_key
         (Application Data*)     -------->
                                                         ServerHello
                                                    + pre_shared_key
                                                        + key_share*
                                               {EncryptedExtensions}
                                                       + early_data*
                                                          {Finished}
                                 <--------       [Application Data*]
         (EndOfEarlyData)
         {Finished}              -------->
         [Application Data]      <------->        [Application Data]

               +  Indicates noteworthy extensions sent in the
                  previously noted message.

               *  Indicates optional or situation-dependent
                  messages/extensions that are not always sent.

               () Indicates messages protected using keys
                  derived from a client_early_traffic_secret.

               {} Indicates messages protected using keys
                  derived from a [sender]_handshake_traffic_secret.

               [] Indicates messages protected using keys
                  derived from [sender]_application_traffic_secret_N.

               Figure 4: Message Flow for a 0-RTT Handshake

SSRF

服务器端请求伪造 (Server-Side Request Forgery, SSRF) 是一种由攻击者构造形成由服务端发起请求的一个安全漏洞。一般情况下,SSRF攻击的目标是从外网无法访问的内部系统。(正是因为它是由服务端发起的,所以它能够请求到与它相连而与外网隔离的内部系统)

SSRF 形成的原因大都是由于服务端提供了从其他服务器应用获取数据的功能且没有对目标地址做过滤与限制。比如从指定URL地址获取网页文本内容,加载指定地址的图片,下载等等。

DNS Rebinding

DNS重新绑定是计算机攻击的一种形式。 在这种攻击中,恶意网页会导致访问者运行客户端脚本,攻击网络上其他地方的计算机。攻击者注册一个域名(如attacker.com),并在攻击者控制下将其代理给DNS服务器。 服务器配置为很短响应时间的TTL记录,防止响应被缓存。 当受害者浏览到恶意域时,攻击者的DNS 服务器首先用托管恶意客户端代码的服务器的 IP 地址作出响应。 例如,他们可以将受害者的浏览器指向包含旨在在受害者计算机上执行的恶意 JavaScript 或 Flash 脚本的网站。

恶意客户端代码会对原始域名(例如attacker.com)进行额外访问,这些都是由同源政策所允许的。 但是,当受害者的浏览器运行该脚本时,它会为该域创建一个新的 DNS 请求,并且攻击者会使用新的 IP 地址进行回复。 例如,他们可以使用内部 IP 地址或互联网上某个目标的IP地址进行回复。

TLS Poison

好了,终于介绍完背景知识了,现在让我们来看看本次要介绍的 TLS Poison 攻击。

这里一句话帮大家总结概括一下:我们可以利用 TLS Session Resumption 的特性结合 DNS Rebinding 技巧操作一些客户端帮助我们进行 SSRF 攻击。

其实整个攻击就像上述说的一样,非常通俗易懂,无非就是 TLS Session Resumption 、 DNS Rebinding 、一个傻白甜受害者,结合在一起就便有了我们的 TLS Poison 。

PS: 如无特殊说明,整个实验背景均基于 Ubuntu 20.04 LTS ,curl 7.68.0 build with OpenSSL/1.1.1f ,后文提到的 IPv6 均指的是 IPv4-mapped IPv6 addresses 这类地址

Attack Steps

让我们进一步来看看这个攻击思路:

  1. 首先我们可以从背景知识当中知道 TLS Session Resumption 特性,无论是 TLS 1.2 或者 1.3 ,它们都会使用了一个凭据一样的东西来表明客户端的身份,就像在 Web 浏览中的 Cookie 一样,这个凭据又是由服务端下发给客户端的,所以如果我们有一个恶意的 Server ,让一个客户端使用 HTTPS 访问我们的服务器时,在 TLS 握手时这个凭据就是我们恶意服务器分配给客户端的,而客户端会把这个凭据按照一定规则存储起来,以便以后需要跟我们恶意服务器恢复上一次会话时使用。所以,从这里我们可以知道,如果可以让客户端以 HTTPS 形式访问任意服务器,在进行 TLS 握手时我们可以让客户端存储我们指定的会话凭据。
  2. 接着,在进行 HTTPS 请求前,客户端必定需要对我们给的域名进行一次 DNS 查询,而在客户端想要恢复会话的时候,如果客户端的 DNS 缓存过期,则又会进行一次 DNS 查询;如果没有过期,则利用之前的 DNS 查询结果得到的 IP 进行恢复会话。所以当条件满足,也就是正好在客户端想要恢复 TLS 会话,而 DNS 缓存又过期了的时候,就会发起一次 DNS 查询,这里正好就满足了 DNS Rebinding 的条件。
  3. 这样我们就可以为自己的域名搭建一个 DNS 服务器,在客户端第一次发起 DNS 请求时,我们让 DNS 服务器返回正确的、指向我们恶意服务器的 IP ,让第一次 TLS 握手成功,也让客户端缓存我们制定的会话凭据;在客户端恢复会话时,也就是客户端发起第二次 DNS 请求时,我们让 DNS 服务器返回我们想要攻击的内网地址,例如 127.0.0.1 ,这样在客户端尝试恢复会话的时候,客户端会拿着我们给它的特制票据去与这个地址尝试进行 TLS 握手。
  4. 这意味着什么呢?特制的票据、指定的内网地址,就构成了一次对内网服务的请求!

但是似乎我们又忽略了什么?如果我想攻击内网的 Memcached ,端口在 11211 不在 443 怎么办?正如我们上面所强调的“客户端会把这个凭据按照一定规则存储起来”,那么这个一定规则又是什么呢?

我们可以随便找个例子来看,比如 curl 7.75.0 ,在 curl 源码的 lib/vtls/vtls.c#408 文件当中,我们可以找到这样的代码:

  for(i = 0; i < data->set.general_ssl.max_ssl_sessions; i++) {
    check = &data->state.session[i];
    if(!check->sessionid)
      /* not session ID means blank entry */
      continue;
    if(strcasecompare(name, check->name) &&
       ((!conn->bits.conn_to_host && !check->conn_to_host) ||
        (conn->bits.conn_to_host && check->conn_to_host &&
         strcasecompare(conn->conn_to_host.name, check->conn_to_host))) &&
       ((!conn->bits.conn_to_port && check->conn_to_port == -1) ||
        (conn->bits.conn_to_port && check->conn_to_port != -1 &&
         conn->conn_to_port == check->conn_to_port)) &&
       (port == check->remote_port) &&
       strcasecompare(conn->handler->scheme, check->scheme) &&
       Curl_ssl_config_matches(ssl_config, &check->ssl_config)) {
      /* yes, we have a session ID! */
      (*general_age)++;          /* increase general age */
      check->age = *general_age; /* set this as used in this age */
      *ssl_sessionid = check->sessionid;
      if(idsize)
        *idsize = check->idsize;
      no_match = FALSE;
      break;
    }
  }

上述代码就是通过对于端口、协议、域名的比较来决定是否使用 sessionid ,而check变量的结构体就是Curl_ssl_session,在 lib/urldata.h#295 当中,我们还可以看到对于Curl_ssl_session结构体的定义

/* information stored about one single SSL session */
struct Curl_ssl_session {
  char *name;       /* host name for which this ID was used */
  char *conn_to_host; /* host name for the connection (may be NULL) */
  const char *scheme; /* protocol scheme used */
  void *sessionid;  /* as returned from the SSL layer */
  size_t idsize;    /* if known, otherwise 0 */
  long age;         /* just a number, the higher the more recent */
  int remote_port;  /* remote port */
  int conn_to_port; /* remote port for the connection (may be -1) */
  struct ssl_primary_config ssl_config; /* setup for this session */
};

所以对于 curl 7.75.0 来说,这个规则就是端口相同、域名相同、协议相同, curl 就会尝试去使用 sessionid 进行访问。

所以整个攻击思路我们大体上就明白了,让我们再来看看作者当初提出的攻击流程,直观感受一下这个攻击(这里我们用 HTTPS 举例):

  1. 首先第一步攻击者发送一个 https://jmaddux.com:25 的地址给一个 Client ,Client 会首先进行 DNS 查询询问 jmaddux.com 的 IP 地址,此时攻击者自己控制的 DNS Server 可以自定义响应为一个正常的 IP ,比如 35.x.x.x ,此时我们可以给这个响应记录非常短非常短的 TTL ,以便让 Client 不要缓存我们的 DNS 记录。

  1. 第二步, Client 会与攻击者指定的 35.x.x.x 这个攻击者的服务器进行 TLS 握手,并获得攻击者服务器返回给 Client 的特制的票据,在图中表示为 Payload ,然后完成 TLS 握手。

  1. 第三部,当 Client 想与 Server 恢复会话时,需要再次查找 jmaddux.com 的 DNS 记录,由于攻击者之前给的 DNS 记录 TTL 时间非常非常短,让其在此时在自己的缓存中查不到原来的记录,这样会使得 Client 又去询问 jmaddux.com 的 DNS 记录,此时攻击者让其 DNS Server 返回 127.0.0.1 这个地址。

  1. 最后,如果 Client 不校验本次获得的 ip 是否与上次一致,就会尝试与 127.0.0.1:25 进行 TLS Session Resumption ,也就是说, Client 会拿着攻击者特制的票据访问 127.0.0.1:25 ,尝试与之恢复 TLS 会话,这里就表现为 Client 发送含有 Payload 的 Client Hello 消息到 SMTP 服务器,至此完成一次 SSRF 攻击。

整体的流程图如下图所示:

这里再对图中进行一些解释:

  1. 攻击者通过一些方式让受害者打开一个 HTML 页面,其中内容会向攻击者准备的 TLS Server https://ssltest.jmaddux.com:11211 发起请求
  2. 当受害者打开这个页面后,受害者客户端会查询 ssltest.jmaddux.com 的 DNS 记录,最终会在攻击者准备的 DNS 服务器查询到攻击者提供的解析记录
  3. 此时 DNS 服务器正常返回结果为 TLS Server 地址并且 TTL 为 0 的响应
  4. 客户端发送 Client Hello 消息
  5. 服务端返回 Server Hello 消息,并在响应包中设置 session_id 为 payload
  6. 进行后续的TLS握手
  7. 握手完成后进行 http 通信时,攻击者 TLS Server 返回 301 跳转到 https://ssltest.jmaddux.com:11211
  8. 受害者客户端重新加载 https://ssltest.jmaddux.com:11211
  9. 理论上由于之前 DNS 响应的报文结果中 TTL 为 0 ,受害者客户端会再次向 DNS 服务器询问 ssltest.jmaddux.com 的解析结果
  10. 此时攻击者让 DNS 服务器返回解析结果为 127.0.0.1 且 TTL 为 0
  11. 接着由于 TLS 会话重用,受害者客户端会使用之前的 session_id 也就是 payload ,带着这个 payload 与 127.0.0.1:11211 进行 TLS 会话重用尝试
  12. payload 被发送至 127.0.0.1:11211 ,达到攻击者目的,但是因为会话重用失败,受害者客户端会得到一个 TLS Error 的错误。至此完成所有攻击步骤。

Setup

好了,接下来就让我们自己动手试一试吧!由于我是使用的是两个 VPS ,没有像原作者一样使用 docker ,所以使用 docker 的同学还请自行摸索。并且由于国内复杂的网络环境,为了避免不必要的麻烦,我使用的是两台国外的 VPS 作为学习使用。

我准备的是一个域名、两台 VPS ,其中一台 VPS 作为 DNS 服务器,另外一台作为 TLS 服务器,两台 VPS 均是 ubuntu 20.04 LTS 。作者仓库对于各自 VPS 的准备工作已经介绍得比较全面了,详细可以参阅:https://github.com/jmdx/TLS-poison/#instructions ,这里就不再赘述了。

唯一可能需要说一下的就是证书申请,这部分作者没有提到,为了避免一些同学不清楚的,可以参考一下,知道了的同学就可以跳过了。在 TLS 服务器上的证书准备工作:

  • 按照 https://certbot.eff.org/instructions 安装对应的 cerbot
  • 然后推荐使用 DNS changllenges 的方式弄证书,当然其他方式也可以。certbot --manual --preferred-challenges dns certonly
  • 按照自己的信息填好后,会提示需要到你的 DNS 域名管理商处增加一个 TXT 记录,直接添加上去就好了

然后最后我们在域名的 DNS 上配置一下如下的基本设置,这里以域名为 example.com 举例:

TypeNameValue
Adns(Your DNS VPS IP)
NStlsdns.example.com
TXT_acme-challenge(这里是 cerbot 验证用的字符串)

然后就先在 DNS VPS 上运行作者的 alternate-dns.py

sudo python3 alternate-dns.py tls.example.com,127.0.0.1 -b 0.0.0.0 -t Your_TLS_IP -d 8.8.8.8

现在就万事俱备啦!

Let’s Try!

Original Method

接下来我们在 TLS VPS 进行一下操作,首先由于作者原来是使用的是 docker ,所以有些配置我们需要更改一下:

  • 在 client-hello-poisoning/custom-tls/src/main.rs#642 文件中,需要把redis://redis/更改为自己 TLS 服务器上的 redis 地址,比如默认的是 127.0.0.1:6379 ,这里我们就改成redis://127.0.0.1:6379/
  • 在原作者的方法中, payload 的设置需要在 redis 当中设置,所以我们可以在 redis 设置好 payload ,如:set payload "\r\nset foo 0 0 12\r\ntls session \r\n"
    • 其中 payload 是 redis 当中的 key ,value 是真正发送给 memcached 的内容
    • Memcached 需要以 CRLF 作为整个命令的结束,并且可以忽略之前的语句错误,而原作者的文档中给出的 payload 是以 \r 结束,会导致写不进 memcached ,所以我们需要注意一下,我们这里可以使用"\r\nset foo 0 0 13\ni in ur cache\r\n"作为 payload 。
      • 这里如果要修改的话记得改 set 命令的第三个数字参数,该参数为设置数据的长度,否则也会发生错误写不进 memcahed,比如i in ur cache这里是 13 个字节,前面的第三个数字参数就需要为 13 ;前面的命令与数据间的换行我们可以使用 LF 作为换行,可以节省一个字节长度。
      • 这里建议先使用echo -e "\r\nset foo 0 0 13\ni in ur cache\r\n"|nc 127.0.0.1 11211来尝试自己的 pyaload 是否能够写入 memcached ,这样如果后面失败了,对后面的排错很有用!至少能排除一个可能存在的问题!
  • 在原作者的方法中,为了让 TLS 进行 Session Resumption ,作者使用了 HTTP 301 跳转的方式让 TLS 进行恢复会话,而跳转的地址则也需要我们通过 redis 来设置,设置的 key 为 redirect ,以 example.com 举例,我们可以这样进行设置:set redirect "https://tls.example.com:11211/" 。(这里是原作者没有在文档中做出说明的地方,得需要阅读 main.rs 代码才能看出来)
  • 还有一点就是作者还提供了一个延迟的选项,以免请求过快(这里有一些其他原因在此处,后续我们会分析到)。我们还是可以通过 redis 来设置 sleep 这个参数,可以达到延迟多少 ms 的效果,如果不设置的话,默认值为 2000 ms ,一般这个数值就可以了。

我们这里以攻击本地的 memcached 为例,在 ubuntu 20.04 LTS 上默认使用apt install memcached安装完成之后,其默认监听在 11211 端口上。之后我们按照作者的说明,在 TLS VPS 上运行 TLSServer :

target/debug/custom-tls -p 11211 --verbose --certs /path/to/fullchain.pem --key /path/to/privkey.pem http

好了,现在才终于是一切准备就绪了,我们就可以直接使用 curl 进行测试了。这里为了直观查看 TLS Session Resumption 的效果,我们可以打开两个 wireshark 来观察,一个 wireshark 监听在你对外通信的网络接口上,一个 wireshark 监听在本地回环接口上。运行一下命令访问你的 TLSServer :

curl https://tls.example.com:11211/ -Lv

PS: 注意这里需要加上-L参数允许 curl 进行重定向。

接着如果第一次 DNS 就把我们的域名解析到 127.0.0.1的话,可以多试几次直到域名解析到你的 TLS VPS IP 上为止,然后等待 1 min 左右,如果网络顺畅,一切顺利的话,在 1min 左右就可以完成本次攻击。攻击效果如下图所示:

其中可能会产生几种结果

  • 第一种最常见的结果就是curl: (47) Maximum (50) redirects followed,表示重定向次数已经超过了 curl 默认的最大深度 50 次,说明 DNS Rebinding 没有将 127.0.0.1 返回到给 Client ,这里因素也比较多,后文会提到,可以说运气比较差,多试几次就行了。
  • 第二种结果是curl: (35) error:1408F10B:SSL routines:ssl3_get_record:wrong version number,如果上面的信息你还看到了Trying 127.0.0.1,那么恭喜你,很有可能这次就成功了,赶紧去 memcached 看看吧

The Details OF Original Method

在整理这篇文章之前,我也参考了很多文章,虽然这些文章没有明确的说明这个攻击的攻击载体,也就是 Payload 最大长度是 32 字节,但是都给了让我产生了一种先入为主的概念,在我印象当中它就只局限于 32 字节,然后我去详细去看作者公开的 PDF 时,发现作者在演示攻击 SMTP 时有一个图是这样的:

其中我们可以看到这个 payload 已经远远超过了 32 字节,然后我抱着疑惑去反复查看作者公布的演讲录像,其中他有这么说到(但愿我的英语听力以及 Youtube 字幕足够准确):

​ It might seem too good to be ture but TLS actually provides us exactly that in the form of a session id. Most clients even persist this field for later use. Session ids are limited to 32 bytes as you can see but depending on the implementation you might have session ticket. This one is about 200 bytes but these can be up to around 65 kilobytes. But session ids and session tickets are mechanisms for a TLS client to go. Remember that cryptographic key exchange we did earlier let’s just keep using that key in this new connection. TLS 1.3 includes a slightly more complicated but similar mechanism called a pre-shared key identity which pretty much does the smae thing for our purposes. All of these are about optimization since key exchange can be time consuming. But they provide a certain way for a server to tell whatever is connecting to it to persist some data for lighter use, almost like a cookie that lives in plain text. So it’s perfect for the surf attack we’re trying to do.

也就是说这个攻击本来就可以做到超过 32 字节的做法,也就是我们之前所说的那几种 Session Resumption ,只有 SessionID 局限于 32 字节以下。

并且按照上面我们的实验结果其中我们可以看到,按照原来作者在其仓库 README 描述的方法,我们可以从实践中看出如下几个点:

  • 双方默认使用 TLS 1.3 进行握手数据交互,当然这里需要你的 curl 支持 TLS 1.3
  • TLS 1.3 使用 PSK 进行 Session Resumption
  • PSK 后面还包含了很多个 0x00 ,总长度远远超过了 32 字节

所以作者原仓库实现的是基于 TLS 1.3 的 Session Resumption ,后面我们具体看其实现代码发现也确是如此,作者只实现了基于 PSK 的会话恢复 SSRF ,其他几类都没有实现。

所以,感觉一切都有些明朗了,那我们再来尝试一下超越 32 字节的 payload ,看看到底能不能实现。依旧按照如上的流程做好 setup ,只不过不同的是我们需要在 TLS VPS 的 redis 上修改一下 payload ,这次我们随意修改一下,只要够长就好,这里一定要注意把要发给 memcached 的 payload 当中的set指令的第三个参数修改成其 value 的长度,比如说我们这里修改 payload 为:set payload "\r\nset foo 0 0 79\nThis time I'm really in your cache and I can write what I want into your cache.\r\n"。(当然还是建议在实验之前使用之前的方法测试一下自己的 payload 能否写入到 memcached 当中)

Ok. Here we go.

当当,我们已经可以写入超过 32 字节的数据啦!

The Bad Code IN Original Method

经过上述两个实验,我已经猜到可能大多数同学的实验并不是一次都成功,很多人可能都是得到第一种结果,也就是超过了 curl 最多重定向次数而失败,而导致这个结果的原因一般来说就是 DNS Rebinding 失败,没有在第二次 DNS 查询的时候返回 127.0.0.1 ,这样一直在与 TLSServer 进行通信导致最后重定向次数过多而失败。

让我们简单看看作者的 DNS Rebinding 怎么写的,我们重点关注以下代码:

spoof_count = 0
start = time()
last_ip = None

def get_spoofed_IP(domain, ip):
    global spoof_count
    global start
    global last_ip
    start = time()
    last_ip = ip

    for d in HOSTS_LIST:
        if re.match(d[0], domain.lower()) or True:
            spoof_count = (spoof_count + 1) % 3
            # The below line will result in the answer switching after 30 seconds,
            # instead of alternating
            # return d[1] if (time() - start > 30) else args.TARGET
            return d[1] if spoof_count == 0 else args.TARGET
    return None

这是作者自己写的代码,这段代码的意思大概就是通过记录 DNS 查询的总次数,如果查询总次数模 3 为 0 的话,也就是 3 的倍数,返回d[1],也就是 127.0.0.1 ,否则就返回我们的 TLS VPS IP 。这种方式我们暂且不谈,先不关注。

我们再来看看其注释的方式,也就是我们可以使用注释当中的方式,DNS Server 启动 30s 之前返回 TLS VPS IP ,只需要在 30s 之后, DNS 就会一直返回我们的目标地址 127.0.0.1 。

我们现在先接受一个结论,就是 curl 对于一个域名有一定的 DNS 缓存时间,那么如果我们想让 curl 第一次 DNS 查询得到 TLS VPS IP ,在缓存时间结束之后查询的结果返回 127.0.0.1 ,那么注释当中的方式是最好不过的了。

好了,接下来就是公开处刑(我也不知道作者是不是粗心大意写出这样的代码):

spoof_count = 0
start = time()
last_ip = None

def get_spoofed_IP(domain, ip):
    global spoof_count
    global start
    global last_ip
    start = time()
    last_ip = ip

    for d in HOSTS_LIST:
        if re.match(d[0], domain.lower()) or True:
            spoof_count = (spoof_count + 1) % 3
            # The below line will result in the answer switching after 30 seconds,
            # instead of alternating
            return d[1] if (time() - start > 30) else args.TARGET
    return None

让我们再来回顾这段代码:嗯,写的非常的棒,用time()计时,超过 30s 就返回目标 IP ,太对了哥。如果还没看出来的同学,我再简化一下:

spoof_count = 0
start = time()
last_ip = None

def get_spoofed_IP(domain, ip):
    global spoof_count
    global start
    global last_ip
    start = time()
    if time() - start > 30:
    	return d[1]
    else:
    	return args.TARGET

我觉得只要你的机器稍微正常一点,正常执行几行赋值语句的 python 代码的速度应该不会超过 30s 吧?这个在使用time()函数对start变量赋值之后,又使用了time()函数获取时间戳与start变量相减。啊这,反正我的机器是减不出 30s ,这段代码就让我感觉到作者写的非常的离谱…当然这里纯属个人吐槽,这里我们只需要把函数内对于start变量再次赋值的操作删除就行了,这样start得到的时间就是程序启动的时间了。(除非我哪里误解了这段代码)

当然我建议时间间隔在 10s 这样就行,这样就能比较精准的让 curl 在第二次 DNS 查询的时候拿到 127.0.0.1 的响应了。(一只黄色的哆啦A梦:这人也太菜了.jpg

当然,或者你也可以使用总次数的方式,如果总次数超过一次立即返回 127.0.0.1 也是可以的,不过万一被别人查了第一次就尴尬了,但是一般来说不会。

The Optimization Method OF DNS Rebinding

就像我们上文所提到的,这个攻击主要取决于两个点,一个是 TLS Session Resumption ,另一个就是 DNS Rebinding 。一个好的 DNS Rebinding 可以起到事半功倍的效果。

我询问了一些 CTFer ,也查证了一些 DNS Rebinding 的工具,诸如 rbndr / ceye 等比较常用的工具,一般统一做法都是每次返回一个 TTL 较小的 A 记录,只不过每次返回的记录不同,大部分都是在用户指定的 A 记录当中随机概率返回一个。这样如果使用这些 DNS Rebinding 工具在这里就不是一个非常好的选择。

随后,@zhaojin 在 基于 A 和 AAAA 记录的一种新 DNS Rebinding 姿势–从西湖论剑2020 Web HelloDiscuzQ 题对 Blackhat 上的议题做升华 提到新的方法,似乎可以使用一个域名同时分配一个 A 记录跟一个 AAAA 记录,因为 curl 会优先使用 AAAA 记录,而当 AAAA 记录的 IPv6 无法建立连接时,会尝试跟 A 记录的 IPv4 进行建立连接。我觉得是个很有趣的想法,整个思路大致如下:

  • 第一次让 curl 去访问恶意的 HTTPS 服务器,拿到一个恶意的 SessionID
  • 然后使恶意的 HTTPS 服务器无法接收新的连接
  • 这时恶意的 HTTPS 给出第一次返回的结果,使其进行同域名跳转
  • 跳转时会尝试进行新连接,发现恶意的 HTTPS 服务器无法连接。
  • 则会尝试连接这个域名下的其他记录所指向的地址,并带上 SessionID

​ 在 CURL 中,对于一个域名,如果同时具有 A 记录和 AAAA 记录,那么 CURL 会去优先请求 AAAA 或者 A 记录所指向的地址,如果这些地址无法连接,则会尝试连接同时得到的 A 记录或者 AAAA 记录。

在某些情况下,会出现:

AAAA 记录地址不通,会连接到 A 记录地址上。

A记录地址不通,会连接到 AAAA 记录地址上。

但是比较遗憾的是,如果对于一个域名进行这样的设置,AAAA 记录指向 TLS VPS 的 IPv6 地址上,A 记录指向 127.0.0.1 地址,实际测试 当中, curl 总是优先 127.0.0.1 ,并且没有随机的做法;如果对于一个域名进行这样的设置,AAAA 记录指向 ::1 地址上,A 记录指向 TLS VPS 的 IPv4 地址上,curl 总是优先 ::1

{% colorquote success %}

2021/05/14 更新:

感谢 @chuye 指出此处错误,原文已更正。

之前的错误:

如果对于一个域名进行这样的设置,AAAA 记录指向 ::ffff:127.0.0.1 或者 ::1 地址上,A 记录指向 TLS VPS 的 IPv4 地址上,虽然似乎可以做到 curl 因优先 AAAA 记录与 TLS VPS 建立连接获得恶意 Session ID ,但是实际测试默认 memcached 配置,无法通过这两个(::ffff:127.0.0.1 或者 ::1)地址进行写入 memcached 。

更正:curl 以 ::1 优先,而且原来的语序产生了错误。以及 Memcached 实际上是接受从 ::ffff:127.0.0.1 或者 ::1 写入的,详见 Additional Remarks

{% endcolorquote %}

虽然有些遗憾无法复现 @zhaojin 所说的这个方法,但是我觉得是个很有趣的想法,以及 @Ivan Komarov 提到 DNS 对于一个域名可以返回双 A 记录,也能有类似的效果,所以我们可以这样进行尝试:

  • 首先,默认配置下, 我们也可以通过 0.0.0.0:11211 这个地址与 memcached 通信
  • 对于返回的双 A 记录,一个配置为 TLS VPS IP ,另一个为 0.0.0.0 ,curl 每次都会优先使用 TLS VPS IP ,如果不能通信才会接着使用 0.0.0.0:11211

那根据已知信息,那么与上文的做法类似,我们是不是也可以让 curl 第一次建立链接,拿到我们分配的 payload 后,断开连接,让其第二次连接不通而去选择另外一个 IP 呢?

事实说明,我们也确实可以这么做。基于 @Ivan Komarov 提供的 DNS Server 以及 @zhaojin 使用的 proxy.py ,我们可以拉取我整理的仓库 https://github.com/ZeddYu/TLS-poison 进行实验。

这里 DNS VPS 的 setup 我们需要使用 client-hello-poisoning/new-custom-dns/alternate-dns.py 进行我们域名的解析:

python3 alternate-dns.py tls.example.com --ip x.x.x.x --mode static_zero

x.x.x.x 为你 TLS Server IP ,mode 有两种,rebinding模式则为普通的重绑定解析模式,static_zero则是我们这次测试的双 A 记录。

TLS VPS 的 setup 还是与之前一样,只不过这次我们需要把 TLS Server 的端口绑定在 11210 上,使用 proxy.py 将 11211 的流量转发到 TLS Server 上,而且该转发只转发一次,所以可以造成 curl 第二次连接失败,也就能让其切换到另一个 IP 进行连接了。

target/debug/custom-tls -p 11210 --verbose --certs /root/tls/fullchain.pem --key /root/tls/privkey.pem http

Ok. Let’s have a try.

可以看到这里 curl 不需要再经过多次重定向,只需要一次重定向就可以稳定进行 SSRF ,也不再需要依靠 DNS Rebinding 的稳定性了。

Use The Optimization Method To Deal With TLS 1.2 Session Ticket

尽管我们可以使用 TLS 1.3 PSK 进行 Session Resumption ,但是根据 https://caniuse.com/?search=tls%201.3 的数据显示,目前 TLS 1.3 全球覆盖率为 91.12% ,而 TLS 1.2 的覆盖率为 98.4% ,并且目前淘宝、百度等知名主站均还没有完全支持 TLS 1.3 ,所以有些时候还是需要我们使用TLS 1.2 ,而作者又偷了一点懒,没有实现任何 TLS 1.2 相关的利用方式,而 @zhaojin 使用的 tlslite-ng 目前还并未支持 TLS 1.2 当中的 Session Ticket ,而又正好作者修改的 rustls 实现的功能比较完善,所以我只能硬头皮刚了一波 rust ,为原作者的 TLSServer 增加了基于 TLS 1.2 Session Ticket 的修改功能。

依旧使用我修改的仓库 https://github.com/ZeddYu/TLS-poison ,对于 DNS Server setup 还是使用优化的双 A 记录的方法,但是对于 TLS Server setup 我们需要改变一下:

target/debug/custom-tls -p 11210 --verbose --certs /root/tls/fullchain.pem --key /root/tls/privkey.pem --tickets --protover 1.2 http

使用参数--tickets,允许 TLS Server 使用 Session Tickets 进行 Session Resumption ;使用--protover 1.2参数,只允许 TLS Server 最高使用 TLS 1.2 版本。并在 redis 设置 ticket_payload ,这个与之前设置的 payload 值一样,是用来对 memcached 进行利用的,我们这里就区别于之前随便设置一下就可以了,例如set ticket_payload "\r\nset foo 0 0 41\nThis is a TLS 1.2 session ticket example.\r\n"

对于 Client ,我们不能再使用 curl ,因为其并没有实现 Ticket Session 插件…所以我选用了老版本 Chrome 70.0.3538.77 for Ubuntu 进行学习。(提醒这里不要用 Firefox ,因为作者在 PDF 中表示过 Firefox 没有这个“特性”)

依旧在 TLS Server 上使用 proxy.py ,接着确定准备好就可以在本地打开 chrome 访问自己 TLS Server 的地址:

因为 Chrome 的一个特殊机制,使用 Chrome 测试我们并不能像 curl 那样一次就能成功,这里我们最好使把 Chrome 的 Keep local data only until you quit your browser 选项打开以及设置每次打开 Chrome 时就打开我们的 TLS Server 地址,这样实验就比较方便了,多试几次我们就可以看到上图这个效果。

Use The Optimization Method To Deal With TLS 1.2 Session ID

那要是遇到只能用 curl/libcurl ,而且 TLS 1.3 又不支持的情况,那就只能通过 TLS 1.2 Session ID 来实现我们注入 payload 实现 SSRF 了,我们能做的就是使用双 A 记录的方法来提高攻击稳定性。

由于作者写的不是很好,并没有对 TLS 1.2 进行适配,而我也并不熟悉 rust ,只能勉强找到作者代码库的问题,大概就是由于作者在 main.rs#730 循环中每次都使用了新的 config ,如下代码所示:

loop {
  poll.poll(&mut events, None)
  .unwrap();

  config = make_config(&args, session_id_generator.clone());
  tlsserv.tls_config = config;

  for event in events.iter() {
    match event.token() {
      LISTENER => {
        if !tlsserv.accept(&mut poll) {
          break;
        }
      }
      _ => tlsserv.conn_event(&mut poll, &event)
    }
  }
}

导致了在 rustls/src/server/hs.rs#718 中每次根据client_hello.session_id.get_encoding()都取不到存储的 Session ID

// Perhaps resume?  If we received a ticket, the sessionid
// does not correspond to a real session.
if !client_hello.session_id.is_empty() && !ticket_received {
  let maybe_resume = sess.config.session_storage
  .get(&client_hello.session_id.get_encoding())
  .and_then(|x| persist::ServerSessionValue::read_bytes(&x));

  if can_resume(sess, &self.handshake, &maybe_resume) {
    return self.start_resumption(sess,
      client_hello, sni.as_ref(),
      &client_hello.session_id,
      maybe_resume.unwrap());
  }
}

所以我们只需要把 main.rs#734 以下两行进行注释即可:

  // config = make_config(&args, session_id_generator.clone());
  // tlsserv.tls_config = config;

虽然可以 debug 找到问题所在,但是并不能完美地修正这个问题,只能做个临时的处理办法来解决这个问题,而且自己时间精力并不太足够,所以如果有同学有比较好的办法解决这个问题欢迎提 PR 修正这个问题!

然后在 redis 中修改 payload 为 32 字节长度:set payload "\r\nset foo 0 0 13\nI am so poor.\r\n"。好了,那就让我们来试一下吧!

虽然我们只能使用 32 字节的 Session ID ,但是对于 memcahed ,我们可以使用append命令进行追加,例如:set payload "\nappend foo 0 0 11\nSo so poor.\r\n"。并且因为我们注释了那两行,所以对于重新设置了的 payload ,我们需要重新启动一次 TLS Server

好了,以上就是我们关于 TLS Poison 漏洞复现的部分。

Attack Impact

在作者的报告中指出如下的 Client 对 TLS 会话进行了缓存:

作者还给出了在内网中易受攻击的服务:

More Than TLS Poison

接下来就是在复现、探索 TLS Poison 攻击遇到的一些问题以及相应的探索,我觉得其中有一些会比攻击本身更值得探究。

cURL DNS Cache

关于 curl 存在 DNS 缓存的问题,当时复现的时候,即使我们已经设置了 DNS 记录 TTL 为 0 , curl 也会有一定时间的缓存,这让我当时感到非常诧异,特别想了解 curl 对于 DNS 缓存的处理。

于是乎,直接 build 了当时最新的 curl 7.75.0 ,抄起 gdb 就开始一顿 debug ,一开始我跟一位 C 语言审计带师调的时候觉得 curl 的代码应该会比较简单…可谁知弄起来还比较麻烦,尤其是对于我这种不是特别熟悉 C 语言的 Web 选手来说,调试坑还是挺多的,尤其 curl 还使用了多线程处理…我在调试的时候还是相当懵逼的,期间也让我学习到了很多调试小技巧。

curl 在 lib/hostip.c#575 当中会调用Curl_resolv函数,其中又会调用Curl_getaddrinfo函数启动一个新线程去查询 DNS ,其中主要使用了Curl_getaddrinfo_ex封装了getaddrinfo这个 libc 函数,得到的结果会使用Curl_cache_addr进行缓存,而进行缓存主要操作就是 curl 自己维护了一个哈希表,第一次查询得到结果就会使用Curl_cache_addr放入哈希表当中,主要函数实现如下:

/*
 * Curl_cache_addr() stores a 'Curl_addrinfo' struct in the DNS cache.
 *
 * When calling Curl_resolv() has resulted in a response with a returned
 * address, we call this function to store the information in the dns
 * cache etc
 *
 * Returns the Curl_dns_entry entry pointer or NULL if the storage failed.
 */
struct Curl_dns_entry *
Curl_cache_addr(struct Curl_easy *data,
                struct Curl_addrinfo *addr,
                const char *hostname,
                int port)
{
  char entry_id[MAX_HOSTCACHE_LEN];
  size_t entry_len;
  struct Curl_dns_entry *dns;
  struct Curl_dns_entry *dns2;

#ifndef CURL_DISABLE_SHUFFLE_DNS
  /* shuffle addresses if requested */
  if(data->set.dns_shuffle_addresses) {
    CURLcode result = Curl_shuffle_addr(data, &addr);
    if(result)
      return NULL;
  }
#endif

  /* Create a new cache entry */
  dns = calloc(1, sizeof(struct Curl_dns_entry));
  if(!dns) {
    return NULL;
  }

  /* Create an entry id, based upon the hostname and port */
  create_hostcache_id(hostname, port, entry_id, sizeof(entry_id));
  entry_len = strlen(entry_id);

  dns->inuse = 1;   /* the cache has the first reference */
  dns->addr = addr; /* this is the address(es) */
  time(&dns->timestamp);
  if(dns->timestamp == 0)
    dns->timestamp = 1;   /* zero indicates permanent CURLOPT_RESOLVE entry */

  /* Store the resolved data in our DNS cache. */
  dns2 = Curl_hash_add(data->dns.hostcache, entry_id, entry_len + 1,
                       (void *)dns);
  if(!dns2) {
    free(dns);
    return NULL;
  }

  dns = dns2;
  dns->inuse++;         /* mark entry as in-use */
  return dns;
}

其中会使用dns->inuse变量起到一个引用计数的作用,在经过第一次查询得到 DNS 记录之后,再进行 DNS 查询时, curl 会优先在自己的哈希表中查询,如果查到了则又会维护dns->inuse将其加一。

/*
 * Curl_resolv_unlock() unlocks the given cached DNS entry. When this has been
 * made, the struct may be destroyed due to pruning. It is important that only
 * one unlock is made for each Curl_resolv() call.
 *
 * May be called with 'data' == NULL for global cache.
 */
void Curl_resolv_unlock(struct Curl_easy *data, struct Curl_dns_entry *dns)
{
  if(data && data->share)
    Curl_share_lock(data, CURL_LOCK_DATA_DNS, CURL_LOCK_ACCESS_SINGLE);

  freednsentry(dns);

  if(data && data->share)
    Curl_share_unlock(data, CURL_LOCK_DATA_DNS);
}

/*
 * File-internal: release cache dns entry reference, free if inuse drops to 0
 */
static void freednsentry(void *freethis)
{
  struct Curl_dns_entry *dns = (struct Curl_dns_entry *) freethis;
  DEBUGASSERT(dns && (dns->inuse>0));

  dns->inuse--;
  if(dns->inuse == 0) {
    Curl_freeaddrinfo(dns->addr);
    free(dns);
  }
}

通过Curl_resolv_unlock函数在每次通信结束后调用freednsentrydns->inuse减一,但是此时结束之后dns->inuse的值仍为 1 ,所以下次进行 DNS 查询的时候还会使用 hash 里的 DNS 记录,此时的调用栈如下所示(我们这里称为调用栈 A ):

#0  freednsentry (freethis=0x5555555d8728) at hostip.c:838
#1  0x00007ffff7f0b20e in Curl_resolv_unlock (data=0x5555555d8728, dns=0x5555555d1d38) at hostip.c:828
#2  0x00007ffff7f2720e in multi_done (data=0x5555555d8728, status=CURLE_OK, premature=false) at multi.c:620
#3  0x00007ffff7f2a902 in multi_runsingle (multi=0x5555555d4d18, nowp=0x7fffffffdae0, data=0x5555555d8728) at multi.c:2227
#4  0x00007ffff7f2af09 in curl_multi_perform (multi=0x5555555d4d18, running_handles=0x7fffffffdbd0) at multi.c:2412
#5  0x00007ffff7ef75fb in easy_transfer (multi=0x5555555d4d18) at easy.c:606
#6  0x00007ffff7ef7875 in easy_perform (data=0x5555555d8728, events=false) at easy.c:696
#7  0x00007ffff7ef78e0 in curl_easy_perform (data=0x5555555d8728) at easy.c:715
#8  0x000055555557abb9 in serial_transfers (global=0x7fffffffde20, share=0x5555555d26d8) at tool_operate.c:2326
#9  0x000055555557b099 in run_all_transfers (global=0x7fffffffde20, share=0x5555555d26d8, result=CURLE_OK) at tool_operate.c:2504
#10 0x000055555557b3d9 in operate (global=0x7fffffffde20, argc=3, argv=0x7fffffffdf98) at tool_operate.c:2620
#11 0x0000555555570991 in main (argc=3, argv=0x7fffffffdf98) at tool_main.c:277

并且我们也注意到只有当dns->inuse为 0 的时候, curl 才会将dns->addr释放掉,所以对于我们来说,需要关注什么时候才能让dns->inuse为 0 ,也就是说什么时候 curl 会执行两次freednsentry 函数将其减少到了 0 。(因为 curl 当中只有freednsentry函数对dns->inuse进行了减一操作)

经过调试,发现 curl 在经过上面的调用栈后,再次经过下面的调用栈(我们这里称为调用栈 B )会再次调用freednsentry函数将dns->inuse减少到 0 。

#0  freednsentry (freethis=0x5555556bab48) at hostip.c:838
#1  0x00007ffff7f08c9f in hash_element_dtor (user=0x5555555d2758, element=0x5555555d2b48) at hash.c:41
#2  0x00007ffff7f1d4a5 in Curl_llist_remove (list=0x5555555d19e8, e=0x5555555d2b48, user=0x5555555d2758) at llist.c:130
#3  0x00007ffff7f09283 in Curl_hash_clean_with_criterium (h=0x5555555d2758, user=0x7fffffffd830, comp=0x7ffff7f0a3e9 <hostcache_timestamp_remove>) at hash.c:249
#4  0x00007ffff7f0a494 in hostcache_prune (hostcache=0x5555555d2758, cache_timeout=60, now=1615184672) at hostip.c:217
#5  0x00007ffff7f0a546 in Curl_hostcache_prune (data=0x5555555d8728) at hostip.c:241
#6  0x00007ffff7f2722c in multi_done (data=0x5555555d8728, status=CURLE_OK, premature=false) at multi.c:623
#7  0x00007ffff7f2a902 in multi_runsingle (multi=0x5555555d4d18, nowp=0x7fffffffdae0, data=0x5555555d8728) at multi.c:2227
#8  0x00007ffff7f2af09 in curl_multi_perform (multi=0x5555555d4d18, running_handles=0x7fffffffdbd0) at multi.c:2412
#9  0x00007ffff7ef75fb in easy_transfer (multi=0x5555555d4d18) at easy.c:606
#10 0x00007ffff7ef7875 in easy_perform (data=0x5555555d8728, events=false) at easy.c:696
#11 0x00007ffff7ef78e0 in curl_easy_perform (data=0x5555555d8728) at easy.c:715
#12 0x000055555557abb9 in serial_transfers (global=0x7fffffffde20, share=0x5555555d26d8) at tool_operate.c:2326
#13 0x000055555557b099 in run_all_transfers (global=0x7fffffffde20, share=0x5555555d26d8, result=CURLE_OK) at tool_operate.c:2504
#14 0x000055555557b3d9 in operate (global=0x7fffffffde20, argc=3, argv=0x7fffffffdf98) at tool_operate.c:2620
#15 0x0000555555570991 in main (argc=3, argv=0x7fffffffdf98) at tool_main.c:277

而这两次调用栈的主要关系在multi_done函数中,我们可以在 lib/multi.c#619 处找到如下代码:

if(conn->dns_entry) {
  Curl_resolv_unlock(data, conn->dns_entry); /* done with this */
  conn->dns_entry = NULL;
}
Curl_hostcache_prune(data);
Curl_safefree(data->state.ulbuf);

所以讲道理的话,在经过调用栈 A 之后,也就是经过Curl_resolv_unlock函数将inuse减一之后,理论上应该又会经过Curl_hostcache_prune函数将inuse减一才对,但是实际上并没有。而Curl_resolv_unlock函数我们上面看过,并没有什么多余的操作,所以我们需要看一下Curl_hostcache_prune为什么没有将inuse减一。

/*
 * Library-wide function for pruning the DNS cache. This function takes and
 * returns the appropriate locks.
 */
void Curl_hostcache_prune(struct Curl_easy *data)
{
  time_t now;

  if((data->set.dns_cache_timeout == -1) || !data->dns.hostcache)
    /* cache forever means never prune, and NULL hostcache means
       we can't do it */
    return;

  if(data->share)
    Curl_share_lock(data, CURL_LOCK_DATA_DNS, CURL_LOCK_ACCESS_SINGLE);

  time(&now);

  /* Remove outdated and unused entries from the hostcache */
  hostcache_prune(data->dns.hostcache,
                  data->set.dns_cache_timeout,
                  now);

  if(data->share)
    Curl_share_unlock(data, CURL_LOCK_DATA_DNS);
}

好了,看到time_t now就差不多猜到大概了,所以按照之前的现象,Curl_hostcache_prune函数没有将inuse减一,可能是在第一个判断语句就直接返回了,所以接下来我们先检查第一个条件语句当中的条件。

第一个比较重要的参数data->set.dns_cache_timeout,在 curl 源码当中唯二对其进行赋值操作的地方在 lib/setopt.c#173 :

  case CURLOPT_DNS_CACHE_TIMEOUT:
    arg = va_arg(param, long);
    if(arg < -1)
      return CURLE_BAD_FUNCTION_ARGUMENT;
    data->set.dns_cache_timeout = arg;
    break;

根据文件名以及上下文,我们不难查到这是对于用户传入的参数进行的配置 CURLOPT_DNS_CACHE_TIMEOUT explained ,并不是我们需要的。还有一处赋值语句则是在 lib/url.c#511 处,在结构体当中对其进行了赋值。

set->dns_cache_timeout = 60; /* Timeout every 60 seconds by default */

并且我们这里还能从注释中获得信息,也就是说如果不是用户自定义参数赋值,默认 DNS 缓存时间为 60s 。

另一个参数data->dns.hostcache,我们之前在Curl_hash_add函数中,用作表示哈希记录,这里不为空。也就是说,一般情况下,Curl_hostcache_prune中的第一个条件语句不会直接return,所以我们还需要继续跟进该函数,接下来就到了hostcache_prune函数。

/*
 * Prune the DNS cache. This assumes that a lock has already been taken.
 */
static void
hostcache_prune(struct Curl_hash *hostcache, long cache_timeout, time_t now)
{
  struct hostcache_prune_data user;

  user.cache_timeout = cache_timeout;
  user.now = now;

  Curl_hash_clean_with_criterium(hostcache,
                                 (void *) &user,
                                 hostcache_timestamp_remove);
}

感觉看到这里已经能很明显的看到 curl 做法的意图了,传入函数的三个参数,第三个参数为现在的时间,第二个参数这里为默认的 DNS 缓存时间为 60s ,进行赋值操作后,进入到hostcache_timestamp_remove函数:

/* Cleans all entries that pass the comp function criteria. */
void
Curl_hash_clean_with_criterium(struct Curl_hash *h, void *user,
                               int (*comp)(void *, void *))
{
  struct Curl_llist_element *le;
  struct Curl_llist_element *lnext;
  struct Curl_llist *list;
  int i;

  if(!h)
    return;

  for(i = 0; i < h->slots; ++i) {
    list = &h->table[i];
    le = list->head; /* get first list entry */
    while(le) {
      struct Curl_hash_element *he = le->ptr;
      lnext = le->next;
      /* ask the callback function if we shall remove this entry or not */
      if(comp == NULL || comp(user, he->ptr)) {
        Curl_llist_remove(list, le, (void *) h);
        --h->size; /* one less entry in the hash now */
      }
      le = lnext;
    }
  }
}

/*
 * This function is set as a callback to be called for every entry in the DNS
 * cache when we want to prune old unused entries.
 *
 * Returning non-zero means remove the entry, return 0 to keep it in the
 * cache.
 */
static int
hostcache_timestamp_remove(void *datap, void *hc)
{
  struct hostcache_prune_data *data =
    (struct hostcache_prune_data *) datap;
  struct Curl_dns_entry *c = (struct Curl_dns_entry *) hc;

  return (0 != c->timestamp)
    && (data->now - c->timestamp >= data->cache_timeout);
}

在这里一切真相大白,正如代码所示,在hostcache_timestamp_remove中会使用现在的时间与 curl 哈希表中记录的时间相减,结果是否大于data->cache_timeout,在这里也就是默认的 60s ,如果大于则在Curl_hash_clean_with_criterium清空对应的哈希表,接着 curl 才会进行下一次真正的 DNS 查询。

所以,从代码层面来看,如果没有用户特殊配置, curl 会自己维护一个 DNS 哈希表,其中的记录过期时间为 60s 。

官网文档也有过相关说明 CURLOPT_DNS_CACHE_TIMEOUT explained ,其中描述如下:

​ Pass a long, this sets the timeout in seconds. Name resolves will be kept in memory and used for this number of seconds. Set to zero to completely disable caching, or set to -1 to make the cached entries remain forever. By default, libcurl caches this info for 60 seconds.

The name resolve functions of various libc implementations don’t re-read name server information unless explicitly told so (for example, by calling res_init(3)). This may cause libcurl to keep using the older server even if DHCP has updated the server info, and this may look like a DNS cache issue to the casual libcurl-app user.

Note that DNS entries have a “TTL” property but libcurl doesn’t use that. This DNS cache timeout is entirely speculative that a name will resolve to the same address for a certain small amount of time into the future.

更一步说明了, curl 完全忽视得到的 DNS 查询得到的 TTL 缓存时间,使用自己的 DNS 缓存策略。虽然 curl 没有守护进程等什么手段的来维护自己的 DNS 哈希表,但是为了解决在复杂网络请求时避免过多 DNS 请求,还是选择了每次使用一个哈希表来优化。其主要的设计思路主要基于两层缓存,第一层缓存由系统决定,也就是通过getaddrinfo获取,如果这时候过期了则由系统去获取 DNS 记录,如果没有过期则直接使用系统的 DNS 记录;第二层就是 curl 自己的 DNS 哈希表,在进行多次请求时能体现出使用自己哈希表的优势,进一步省去了从系统获取 DNS 记录的时间。当然还有一种可能就是为了在特定场合在一定程度上缓解 DNS Rebinding 的攻击。

cURL Session Ticket

关于 Session Ticket ,虽然该标准有很多的优点,主要实现了 Stateless 的特点,减缓了服务器的负载压力,但是似乎 curl with openSSL 并不打算支持 Session Ticket 这一 extension 。

虽然曾经有人尝试在 curl 中支持这一 TLS 1.2 的特性,但是根据 Re: Question about SSL Session Tickets 当中的讨论,TLS Session ticket support 构建的 curl 并不可以正常的使用,所以当时被 curl 官方人员放弃了该 PR Merged ,之后也没有再打算支持这一特性了,当然也没有在 curl 的 TODO 看到相关的支持计划了。当然如果你非常倾向于使用 CLI 工具去实验该特性,可以尝试使用 openssl s_client 或者 curl build with GnuTLS ,他们都支持这一特性。

以下是在互联网上一些关于 curl 支持 TLS 1.2 Session Ticket 的讨论:

https://curl.se/mail/lib-2019-08/0056.html

https://github.com/curl/curl/issues/3202

https://github.com/curl/curl/pull/2220

https://github.com/curl/curl/issues/1109#issuecomment-274346447

https://stackoverflow.com/questions/19939247/ssl-session-tickets-vs-session-ids

IP Sort IN cURL

好了,接下来就是对于 IP 选择的问题了,前面我们说过 curl 在获取 DNS 记录的时候,使用的是getaddrinfo函数,该函数位于 glibc 当中,为了弄清楚这个函数对于 IP 选择的问题,我一个 Web 选手也只能硬着头皮去调试 glibc 了,何时受过这个苦!下面就是在调试 glibc 2.31 过程当中的一些记录与结果。

getaddrinfo 的手册中,我们可以了解到:

​ Given node and service, which identify an Internet host and a service, getaddrinfo() returns one or more addrinfo structures, each of which contains an Internet address that can be specified in a call to bind(2) or connect(2). The getaddrinfo() function combines the functionality provided by the gethostbyname(3) and getservbyname(3) functions into a single interface, but unlike the latter functions, getaddrinfo() is reentrant and allows programs to eliminate IPv4-versus-IPv6 dependencies.

其函数功能主要是用来查询给定域名对应的 IP ,用来替代之前的gethostbynamegetservbyname函数,并且均支持 IPv4 与 IPv6 。在手册中,我们还可以看到如下描述:

​ There are several reasons why the linked list may have more than one addrinfo structure, including: the network host is multihomed, accessible over multiple protocols (e.g., both AF_INET and AF_INET6); or the same service is available from multiple socket types (one SOCK_STREAM address and another SOCK_DGRAM address, for example). Normally, the application should try using the addresses in the order in which they are returned. The sorting function used within getaddrinfo() is defined in RFC 3484; the order can be tweaked for a particular system by editing /etc/gai.conf (available since glibc 2.5).

以及在其配置文件 gai.conf 帮助文档当中也可以看到如下描述:

​ A call to getaddrinfo(3) might return multiple answers. According to RFC 3484 these answers must be sorted so that the answer with the highest success rate is first in the list. The RFC provides an algorithm for the sorting. The static rules are not always adequate, though. For this reason, the RFC also requires that system administrators should have the possibility to dynamically change the sorting. For the glibc implementation, this can be achieved with the /etc/gai.conf file. Each line in the configuration file consists of a keyword and its parameters. White spaces in any place are ignored. Lines starting with ‘#’ are comments and are ignored.

也就是说,getaddrinfo函数会使用 gai.conf 以及 RFC 3484 来对其得到的结果进行排序,而 gai.conf 文件中的可以由用户手动配置,我们暂且搁置,先来看看这个传说中的 RFC 3484 所描述的规则。这里我们需要注意的是, RFC 3484 已经被 RFC 6724 取代,So let’s take a look at the RFC 6724.

RFC 6724 的介绍中,我们可以看到如下的描述:

​ For example, when DNS name resolution yields both IPv6 and IPv4 addresses and the network protocol stack has available both IPv6 and IPv4 source addresses. In such cases, a simple policy to always prefer IPv6 or always prefer IPv4 can produce poor behavior. As one example, suppose a DNS name resolves to a global IPv6 address and a global IPv4 address. If the node has assigned a global IPv6 address and a 169.254/16 auto-configured IPv4 address [9], then IPv6 is the best choice for communication. But if the node has assigned only a link-local IPv6 address and a global IPv4 address, then IPv4 is the best choice for communication. The destination address selection algorithm solves this with a unified procedure for choosing among both IPv6 and IPv4 addresses.

这里正好举了一个例子,例如当 DNS 解析同时产生 IPv6 和 IPv4 地址,而网络协议栈同时拥有 IPv6 和 IPv4 源地址时,在这种情况下,一个简单的策略,即总是优先选择 IPv6 或总是优先选择 IPv4 ,可能会产生不良行为。举个例子,假设一个 DNS 解析到一个全局 IPv6 地址和一个全局 IPv4 地址,如果节点已经分配了一个全局 IPv6 地址和一个 169.254/16 自动配置的 IPv4 地址,那么 IPv6 是通信的最佳选择。但如果节点只分配了一个链路本地 IPv6 地址和一个全局 IPv4 地址,那么 IPv4 是通信的最佳选择。目的地址选择算法解决了这一问题,在 IPv6 和 IPv4 地址中进行统一的选择。

这个介绍看起来似乎正是我们需要的,能帮助我们解决对于两个 IP 的排序问题。在大概通读这篇 RFC 后,该 RFC 主要提出了几种地址选择算法,包括源地址选择算法、目的地址选择算法等,能够帮助我们解决很多平时的疑惑,比如有多个源 IP 为什么使用其中一个特定的源 IP 等,个人建议如果有类似关于网络通信疑问或者需要了解网络通信内容的同学可以进一步学习一下该 RFC 。这里我们需要关注的是主要是目的地址选择算法,不会介绍其他算法,并且也只是做个简单介绍。

对于目的地址选择算法需要按顺序遵守十条规则,并且只要前任一规则起到排序作用,则后续规则不起作用。下文以 DS 表示 Destination S 。

  • 规则1:避免不可用的目的地。如果已知 DB 是不可到达的,或者如果 Source(DB) 是未定义的,那么首选DA。同理,如果已知 DA 是不可到达的,或者 Source(DA) 是未定义的,那么首选 DB 。
  • 规则2:优先选择匹配的范围。如果 Scope(DA) = Scope(Source(DA)) ,且 Scope(DB) <> Scope(Source(DB)) ,则优先选择 DA 。同理,如果 Scope(DA) <> Scope(Source(DA)) ,且Scope(DB) = Scope(Source(DB)) ,则优先选择DB。
  • 规则3:避免使用废弃的地址。如果 Source(DA) 被弃用而 Source(DB) 没有被弃用,那么首选 DB 。同理,如果 Source(DA) 没有被废弃,而 Source(DB) 被废弃,那么优先选择 DA 。
  • 规则4:优先选择家庭地址。如果 Source(DA) 同时是家庭地址和照顾地址,而 Source(DB) 不是,那么首选 DA 。同理,如果 Source(DB) 同时是家庭地址和照顾地址,而 Source(DA) 不是,那么首选DB。如果 Source(DA) 只是一个家庭地址,而 Source(DB) 只是一个照顾地址,那么就选择 DA 。同理,如果 Source(DA) 只是一个照顾地址,而 Source(DB) 只是一个家庭地址,那么首选 DB 。
  • 规则5:优先选择匹配的标签。如果 Label(Source(DA))=Label(DA) 且 Label(Source(DB)) <> Label(DB) ,则首选 DA 。同理,如果 Label(Source(DA)) <>Label(DA) ,且Label(Source(DB))=Label(DB),则优先选择 DB 。
  • 规则6:优先选择较高的优先级。如果 Precedence(DA)> Precedence(DB) ,则优先选择 DA 。同理,如果 Precedence(DA) < Precedence(DB) ,则优先选择 DB 。
  • 规则7:优先选择本地传输。如果 DA 是通过封装过渡机制(如IPv4中的IPv6)达到的,而 DB 不是,那么优先选择 DB 。同理,如果 DB 是通过封装到达,而 DA 不是,那么优先选择 DA 。
  • 规则8:优先选择较小的范围。如果 Scope(DA) < Scope(DB) ,则首选 DA 。同样,如果Scope(DA)>Scope(DB) ,则选择 DB 。
  • 规则9:使用最长的匹配前缀。当DA和DB属于同一个地址族(都是IPv6或都是IPv4)。如果CommonPrefixLen(Source(DA), DA) > CommonPrefixLen(Source(DB), DB),则首选DA。同理,如果CommonPrefixLen(Source(DA), DA) < CommonPrefixLen(Source(DB),DB),则优先选择DB。
  • 规则10:否则,保持顺序不变。如果DA在原列表中先于DB,则优先选择DA。否则,优先选择DB。
  • 如果执行有其他方法对目的地地址进行排序,规则9和10可以被取代。例如,如果实施机构知道哪些目的地址会带来 “最佳 “的通信性能,则规则9和10可以被取代。

更简单来说遵守以下规则:

  • 避免使用不可用的目的地址。如果一个地址是可到达的(堆栈有通往特定地址的路由),而另一个地址是不可到达的,那么将可到达的目的地址放在不可到达的地址之前。
  • 优先选择匹配的范围。如果一个地址的作用域与其源地址的作用域相匹配,而另一个地址不符合这个标准,那么就把作用域相匹配的地址放在另一个目的地址之前。
  • 目的地址及其相关的源地址的范围由地址的高阶位决定。目的地址可以是多播地址或单播地址。为了比较范围,单播链路本地地址被映射到多播链路本地地址,单播全局地址被映射到多播全局地址。
  • 避免使用废弃的地址。如果一个地址是废弃的,而另一个地址是非废弃的,那么非废弃的地址放在另一个地址之前。
  • 优先选择匹配的标签。如果一个目标地址的标签与其关联的源地址的标签相匹配,而另一个目标地址的标签与其关联的源地址的标签不相匹配,那么具有匹配标签的目标地址被放在另一个地址之前。有关标签如何与目标地址关联的信息,请参见IPv6默认地址选择的策略表和配置默认地址选择的策略表。
  • 优先选择较高的优先级。如果一个地址的优先级高于另一个地址的优先级,那么优先级较高的地址将被放在另一个目标地址之前。请参阅 IPv6默认地址选择的策略表配置默认地址选择的策略表,以了解更多关于优先级值如何与目的地址关联的信息。
  • 优先范围较小。如果一个地址的作用域小于另一个地址的作用域,则作用域较小的地址放在另一个目的地址之前。
  • 使用最长的匹配前缀。如果一个目标地址与其关联的源地址的 CommonPrefixLength 比另一个目标地址与其源地址的 CommonPrefixLength 长,那么 CommonPrefixLength 长的地址放在另一个地址之前。
  • 保持顺序不变。没有规则选择这两个地址中较好的地址,则它们选择权重相等。选择第一个地址作为这两个地址中较好的地址,顺序不变。

我认为 RFC 决定了代码应该怎么去执行,但是终究还是代码决定了怎么去执行。(纯粹是我也没怎么看懂

于是,我们可以在这里找到getaddrinfo函数的实现:https://code.woboq.org/userspace/glibc/sysdeps/posix/getaddrinfo.c.html#2479 ,关键点就在这,可以看到在getaddrinfo函数拿到结果后使用了rfc3484_sort对结果进行排序:

      /* We got all the source addresses we can get, now sort using
         the information.  */
      struct sort_result_combo src
        = { .results = results, .nresults = nresults };
      if (__glibc_unlikely (gaiconf_reload_flag_ever_set))
        {
          __libc_lock_define_initialized (static, lock);
          __libc_lock_lock (lock);
          if (__libc_once_get (old_once) && gaiconf_reload_flag)
            gaiconf_reload ();
          __qsort_r (order, nresults, sizeof (order[0]), rfc3484_sort, &src);
          __libc_lock_unlock (lock);
        }
      else
        __qsort_r (order, nresults, sizeof (order[0]), rfc3484_sort, &src);

rfc3484_sort函数主要实现对应了 RFC 6742 当中的十条规则:

static int
rfc3484_sort (const void *p1, const void *p2, void *arg)
{
  //...代码段过长这里省略

  /* Rule 10: Otherwise, leave the order unchanged.  To ensure this
     compare with the value indicating the order in which the entries
     have been received from the services.  NB: no two entries can have
     the same order so the test will never return zero.  */
  return idx1 < idx2 ? -1 : 1;
}

rfc3484_sort的函数执行完后,回调到__qsort_r,也就是qsort_r

#define __qsort_r qsort_r

__qsort_r (order, nresults, sizeof (order[0]), rfc3484_sort, &src);

接着查阅手册,我们可以看到 qsort_r 函数的描述:

Synopsis

#include <stdlib.h>

void qsort(void *base, size_t nmemb, size_t size,
        int (*compar)(const void *, const void *));

void qsort_r(void *base, size_t nmemb, size_t size,
        int (*compar)(const void *, const void *, void *),
        void *arg);

Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

  • qsort_r(): _GNU_SOURCE

Description

The qsort() function sorts an array with nmemb elements of size size. The base argument points to the start of the array.

The contents of the array are sorted in ascending order according to a comparison function pointed to by compar, which is called with two arguments that point to the objects being compared.

The comparison function must return an integer less than, equal to, or greater than zero if the first argument is considered to be respectively less than, equal to, or greater than the second. If two members compare as equal, their order in the sorted array is undefined.

The qsort_r() function is identical to qsort() except that the comparison function compar takes a third argument. A pointer is passed to the comparison function via arg. In this way, the comparison function does not need to use global variables to pass through arbitrary arguments, and is therefore reentrant and safe to use in threads.

从中我们可以明白,qsort_r 函数是返回升序的数组结果,并且对于使用的比较函数的返回值是否大于零来决定传递的参数大小,如果返回值大于零,意思就是第一个参数需要排在第二个参数之后。

接下来我们就具体来调试一下这个函数吧,期间 setup 等调试 glibc 并非本文范围,而且网络也有比较多的文章,这里就不再做记录。

这里我们以一个 A 记录为 127.0.0.1 ,另一个 A 记录为我其中一个 VPS 地址为例子,具体来看看这个函数的主要判断形式。 gbd 直接下断点到rfc3484_sort,在进入到函数后,我们可以通过以下形式查看相应的地址:

(gdb) p ((struct sockaddr_in*)((*a1->dest_addr)->ai_addr))->sin_addr
$3 = {s_addr = 3183758381}
(gdb) p ((struct sockaddr_in*)((*a2->dest_addr)->ai_addr))->sin_addr
$4 = {s_addr = 16777343}

得到 a1 与 a2 代表的地址后,转成十进制后再倒叙一下,即 a2 代表的是 127.0.0.1 , a1 代表的是我们真正 vps 的地址。然后通过 gdb 单步执行,我们在 Rule 8 的时候跳出了rfc3484_sort函数:

  /* Rule 8: Prefer smaller scope.  */
  if (a1_dst_scope < a2_dst_scope)
    return -1;
  if (a1_dst_scope > a2_dst_scope)
    return 1;

跳出rfc3484_sort进入到 qsort_r 函数,也就是在此处执行完比较就直接返回到了qsort函数,并且从执行结果来看,是因为 a1_dst_scope > a2_dst_scope 比较结果执行了return 1。所以按照qsort函数文档说明,如果返回了一个大于 0 的值,则说明要将第一个参数需要排在第二个参数之后,这里 a1 代表的我们的 vps 地址需要排在 a2 代表的 127.0.0.1 地址之后,所以经过rfc3484_sort函数返回的顺序则是 127.0.0.1 作为首选地址,VPS 地址其次。

于是我接下来依次对 IPv4 当中各种排列组合进行了测试:

  • a1 127.0.0.1 vs a2 0.0.0.0。适用于 scope 判断,127 优先

  • a1 ipv4 vps vs a2 0.0.0.0。适用于 scope 判断,ipv4 vps 优先

  • a1 169.254.1.1 vs a2 0.0.0.0。适用于 scope 判断,169 优先

  • a1 127.0.0.1 vs a2 169.254.0。适用于 scope 判断,127 优先

  • a1 ipv4 vps vs a2 ipv4 vps。并不适用任何判断,函数执行完成返回 -1 ,表示保持原顺序,体现为随机。

再次重申,这里的 IPv6 指的是IPv4-mapped IPv6 addresses 这类地址

对于 IPv6 我们可以使用如下形式在 gdb 打印对应的地址:

p ((struct sockaddr_in6*)((*a1->dest_addr)->ai_addr))->sin6_addr->__in6_u->__u6_addr8

于是我接下来依次对 IPv4 与 IPv6 当中各种排列组合进行了测试:

  • IPv6 vps vs IPv4 vps。并不适用任何判断,函数执行完成返回 -1 ,表示保持原顺序,体现为随机。
  • IPv6 vps vs 127.0.0.1。适用于 scope 判断,127 优先
  • ::1 vs 45.76.196.189。适用于 prec 判断,::1 优先
  • ::1 vs 127.0.0.1。适用于 prec 判断,::1 优先
  • 127.0.0.1 vs :: 。适用于 scope 判断, :: 优先
  • 0.0.0.0 vs IPv6 vps。适用于 scope 判断, IPv6 VPS 优先。
  • 0.0.0.0 vs ::1。适用于 scope 判断,::1 优先
  • IPv4 vps vs ::。适用于 scope 判断,IPv4 vps 优先
  • IPv4 vps vs ::1。适用于 price 判断, ::1 优先

总结起来我们可以得到以下这个表格:

对比形式A1A2优先值
v4127-v40127.0.0.10.0.0.0A1
v4vps-v40vps0.0.0.0A1
v4vps-v4127127.0.0.1vpsA1
v4vps-v4vpsvpsvps随机,保持原顺序
v4vps-v6vpsvpsvps随机,保持原顺序
v4127-v6vps127.0.0.1vpsA1
v4127-v60127.0.0.1::A1
v4127-v6127127.0.0.1::1A2
v40-v6vps0.0.0.0vpsA2
v40-v61270.0.0.0::1A2
v4vps-v6127vps::1A2
v4vps-v60vps::A1
V4127map-v4psvps::ffff:7f00:0001随机,保持原顺序

整体来说,我们可以大致从表中看出,含有双 A 记录的情况,如果出现 127.0.0.1 或者 ::1 ,都会以这两者优先,而两者中又会以 ::1 优先,所以对于前文的 IP 选择排序问题我们这里也就得到了一个大体的答案。

{% colorquote success %}

2021/05/14 更新:

增加了 127.0.0.1 使用 IPv4-mapped 表示 IPv6 时,与 v4 vps 地址的对比,详细说明见 Additional Remarks

{% endcolorquote %}

IP Sort IN Chromium

当然在 Chromium 中,我们也可以看到相应的 IP 选择实现方式:

https://source.chromium.org/chromium/chromium/src/+/master:net/dns/address_sorter_posix.cc;l=39

// Address sorting is performed according to RFC3484 with revisions.
// http://tools.ietf.org/html/draft-ietf-6man-rfc3484bis-06
// Precedence and label are separate to support override through /etc/gai.conf.

// Returns true if |p1| should precede |p2| in the table.
// Sorts table by decreasing prefix size to allow longest prefix matching.
bool ComparePolicy(const AddressSorterPosix::PolicyEntry& p1,
                   const AddressSorterPosix::PolicyEntry& p2) {
  return p1.prefix_length > p2.prefix_length;
}

https://source.chromium.org/chromium/chromium/src/+/master:net/dns/address_sorter_posix.cc;l=200 也实现了基于 RFC 3484 对应的 IP 排序方法。

但是实际测试中, chrome 有个非常奇特的现象,就是对于返回多个 IP ,chrome 会立马挨个对其发起请求,我找了一些朋友询问了相关情况,有些表示没有关注过,有位朋友则表示他过去发现过这个情况,说是 chrome 有个 IP 选优的情况,会对多个 IP 进行测速,以最快的结果作为最佳选择。关于这个结论我在 chromium.org 等相关文档进行了查找,但是并没有找到关于该现象的说明。如果有朋友知道该现象的原因或者相关信息的话,也欢迎提出评论。

Something else

Bad-behaved Application

在 RFC 6724 当中,有着这么一段话

​ Well-behaved applications SHOULD NOT simply use the first address returned from an API such as getaddrinfo() and then give up if it fails. For many applications, it is appropriate to iterate through the list of addresses returned from getaddrinfo() until a working address is found. For other applications, it might be appropriate to try multiple addresses in parallel (e.g., with some small delay in between) and use the first one to succeed.

也就是说应用程序不应该简单地使用从 API(如getaddrinfo)返回的第一个地址,失败了就放弃改地址。对于许多应用程序来说,它们都选择从getaddrinfo返回的地址列表中进行挨个测试,直到找到一个正常的地址。对于其他应用程序,可能适合并行尝试多个地址(例如,中间有一些小的延迟),并使用第一个地址成功。(可能 Chrome 就是基于这个表现)

然而 curl 就是这么一个 Bad-behaved Application ,从getaddrinfo函数中取得的地址链表,依次挨个建立连接,一个不行就换下一个直到成功或链表为空为止。

Some History of getaddrinfo

一段关于 getaddrinfo 排序有趣的历史,以下是个人对其抽取的理解,原文见:glibc’s getaddrinfo() sort order

在过去,主机名到 IP 地址的查询到一般都是用gethostbyname来完成的,gethostbyname的地址是按照服务器返回的地址顺序排列的(除非在本地做了特殊的配置)。当一个查询有多个地址时,基本上所有 NS 服务器都会安排对返回的地址进行"轮流 “或 “循环”:每个查询都会得到一个新的排序。这是为了让一个服务名可以引用多个物理网络接口(或许在不同的主机上),并在这些接口上分担负载,这就是所谓的 “基于DNS的负载均衡”。如果协议是像邮件这样的协议,如果第一个地址不成功,发送者可能会尝试多个地址,这也给了你一个故障转移(failover)。

gethostbyname 理论上可以支持 IPv6 ,但每次调用只能返回一种地址类型。虽然有一种方法可以将 IPv4 地址嵌入到 IPv6 地址中,但对于这样的情况,没有明确的方法告诉 gethostbyname ,调用的应用程序(以及应用程序所依赖的其他堆栈)将应对得到一堆 AF_INET6 而不是 AF_INET 的返回。因此对于 IPv6 ,需要一个新的接口。这个接口(在 RFC3493 s6.1和它的前身中定义)就是 getaddrinfo 。

它有几个新特性,其中大部分与这里无关。关键的新功能是:getaddrinfo 允许应用程序指定它是否只想获得 IPv4 地址,还是也想获得 IPv6 地址,如果获得混合的地址,那么是将其编码为 AF_INET ,还是将其编码为 “v6-mapped “AF_INET6(这里就是 IPv4-mapped IPv6 Addresses )。结合其他各种新的特点,这使得将一个仅有 IPv4 的应用程序转换为 IPv6 功能变得相当简单。所以,总的来说:getaddrinfo 是用来取代 gethostbyname 的。

然而,另外,人们意识到,如果 getaddrinfo 可以返回 IPv4 和 v6 地址的混合物,那么有必要指定它们的返回顺序。当 RFC3484 编写时,作者显然认为最好的方法是在所有地址上定义一个比较函数,它将定义哪个地址是首选。 RFC3484 的作者不管上面所描述的对 DNS 循环功能的影响,指定(第6条规则9)所有地址应该按照与做出选择的主机的 “接近度 “进行排序–其中 “接近度 “被定义为普通初始地址前缀的长度。

这在 3484 制定之时,对于 IPv6 的真实网络近似性可能是一个有争议的定义,但现在很明显,在真实的IPv6互联网中,它不是这样的衡量标准,在IPv4互联网中也从来没有这样的衡量标准。所以 RFC3484 s6 规则 9 就是错误的,因为它背后的原因即使曾经适用现在也不再适用了。然而,比这更糟糕的是:规则 9。试图改变现有系统的行为。如果我们同意规则 9,那么它也应该同样适用于使用 gethostbyname。的应用程序。所有使用 gethostbyname 的现有应用程序都不符合规则9。也许可以修改 gethostbyname,使其根据 RFC3484 s5 和 s6 对地址进行排序。但这是一个好主意吗?不,显然不是。这将改变所有目前使用 gethostbyname 的应用程序的行为。目前,这些应用程序 “随机 “地选择地址(根据 DNS 循环)。规则 9 会让应用程序根据最长通用前缀来选择地址,这将破坏基于 DNS 的负载均衡机制。那么 getaddrinfo 呢? 我们没有理由为了增加新功能彻底地改变该 API 。事实上,我们看到,我们自己服务器的 DNS 负载均衡已经被这个变化打破了! 也就是说,应用程序从使用非规则 9 的 gethostbyname 改为规则 9 的 getaddrinfo ,服务器会出现比较严重的非负载均衡。

RFC 试图规定以可预测的顺序返回地址是不合理的,因为几十年来整个互联网所依赖的既定行为是地址不是以可预测的顺序返回的,并且这里的不可预测性不是偶然的。在 DNS 轮询出现之前,地址总是按照 DNS 区域管理员指定的顺序返回,特殊的代码被添加到 namervers 中 (几十年前),以 “随机化 “这个顺序。 getaddrinfo 不应该取消这个措施。正如上面所展示的,RFC是错误的,与现有的实践不一致,而且所提出的遵守它的方式导致我们有两个类似的接口( gethostbyname 和 getaddrinfo ),它们的行为不一致,定义 getaddrinfo 的文档是 RFC 3493,而 RFC 3493 没有参考 RFC 3484 ,它根本没有提到排序。请注意,RFC3484 不是一个标准。它是一个 “proposed standard”–最早的 IETF 标准跟踪文件状态的东西。当然,我们应该建议 RFC3484 规则 9 应被废弃,并应被废除(IPv4 应该要废除这个规则,IPv6 可能也需要)。

Additional Remarks

IPv4-Mapped 127.0.0.1

2021/05/14 更新:

对于 IPv4-mapped IPv6 这类地址,我认为并不是真正意义上的 v6 地址,真正 v6 的 loopback 地址为 ::1 ,IPv4-mapped IPv6 只是 IPv4 地址写成 IPv6 形式。那么既然并不是真正的 IPv6 ,但是写作了 IPv6 ,我觉得对于getaddrinfo来说,他确实是 IPv6 的结构,但是对于 IPv6 来说,真正的回环地址并不是这个,所以他可能被视为普通的 IPv6 地址处理。

我们可以简单验证一下自己的想法:直接在一个域名上同时设置 AAAA 记录为 ::ffff:7f00:0001 ,A 记录为随意一个 IPv4 公网地址。

使用 curl 请求我们设置的域名,得到的结果如下图所示:

可以看到,这两个地址是随机返回的。

并且我们也可以重新 debug 一遍 getaddrinfo 函数,验证一下自己的想法。于是,重新 debug 了一遍 libc 当中的 rfc3484_sort 函数,对于 ::ffff:7f00:0001 地址来说, getaddrinfo 使用了 IPv6 结构体处理这个地址,但是最后结果是按照 DNS 得到的原顺序返回,也就是说,这个地址与 IPv4 vps 地址一起出现时,对于getaddrinfo 来说是随机返回。所以从结果来看,他确实被视为了跟 v4 vps 相同优先级的地址。

但是我们实际上应不应该这么处理呢?按照 RFC 6742 的说法:

​ IPv4-compatible addresses [RFC4291], IPv4-mapped [RFC4291], IPv4-converted [RFC6145], IPv4-translatable [RFC6145], and 6to4 addresses [RFC3056] contain an embedded IPv4 address. For the purposes of this document, these addresses MUST be treated as having global scope.

IPv4-mapped 这类地址应该被视为拥有 global scope ,而真正意义上的回环地址应该是被作为 link-local scope 处理的,这也就基本能解释为什么他会被作为与 v4 vps 这类地址一样的优先级了。

所以之前文中的说法不应该把 ::ffff:7f00:0001 与 127.0.0.1 视为同类地址,这一点是错误的。

IPv4-Mapped With Memcached

2021/05/14 更新:

我们可以尝试一下使用 IPv4-Mapped 这类地址进行实验看看能不能写入 Memcached,同样的 DNS 配置:在一个域名上同时设置 AAAA 记录为 ::ffff:7f00:0001 ,A 记录为随意一个 IPv4 公网地址。

然后其他操作基本同双 A 记录实验方式一致,使用 proxy.py 拒绝客户端对 TLS 服务端的第二次请求以达到 DNS Rebinding 的效果,于是得到的实验结果如下图所示:

说明 Memcached 可以从 IPv4-Mapped 这类地址写入。

Mitigation

纵观整个漏洞,我觉得其实造成这个漏洞是由于比较多复杂的因素导致的,从协议角度来说,在 RFC 当中也并没有提到 Client 到底应该怎么存储 Server 给予的相关凭证,也并没有说明清楚怎么与对应的 TLS 会话绑定,所以导致了 Client 在支持 TLS 会话重用的产生了各种实现;而从 Client 客户端漏洞来说,也是开发者们没有对进行 TLS 会话重用的地址进行二次校验,导致了可以使用 DNS Rebinding 来轻松绕过。

所以,对于该类漏洞首要的防护手段就是需要确认进行 TLS 会话重用的地址与之前的地址一致,而这个实现途径有很多种方式,比如说防御 DNS Rebinding 的方式能在一定程度上缓解到整个漏洞利用,但是对于 DNS Rebinding 的传统防守方式有些小伙伴可能会说 Client 只做一次 DNS 解析,之后就一直使用这个 IP 解析结果,但是如果 Client 存在像文中那样的挨个尝试建立连接后使用第一个连接成功的 IP 的现象的话,我们就可以使用上文提到的双 A 记录的方式来绕过该类限制。

那怎么做才是最好呢?作者在防御章节做出了自己阐述:

作者建议将 (hostname, port, ip_addr) 作为缓存键值对进行绑定,但是我个人认为这并不是一个良好的缓解措施,因为在当今 CDN 大流行的情况下,很难说这个域名会一直使用到同一个 IP ,如果将域名、端口、IP 三者绑定的话,对于 CDN 之类的服务会话重用机制消耗将会大大提升。

所以我觉得更好的做法应该是正如 Wiki 上介绍的,使用 IP 与对应的端口进行绑定 :

​ The client associates this session id with the server’s IP address and TCP port, so that when the client connects again to that server, it can use the session id to shortcut the handshake.

使用 IP 与对应的端口作为缓存键值对进行绑定,这样的话就不会对 CND 之类的服务造成太大影响,而且也降低了被攻击的风险,就不会将重用会话发送到其他 IP 上了。当然这里也只是我个人片面的想法,可能还有很多这么做会受到影响的地方,比如说可能 Server 对于 Host 域名有校验而拒绝重用会话等等。所以,这里可能并没有一个十全十美的缓解该攻击方式的问题等等。

当然你也可以选择直接禁用掉 TLS 会话重用的功能,有些 Client 上就提供了 Disable outbound TLS session resumption 的设置(除了 chrome 等其他一些) :

  • libcurl: CURLOPT_SSL_SESSIONID_CACHE=false
  • firefox: security.ssl.disable_session_identifiers=true
  • Tor browser: disabled by default
  • Java, Nodejs, Chrome, others: no option

Conclusion

整个漏洞弄下来还是非常有意思的,尽管其利用条件比较苛刻,需要有一个可以被攻击的服务 + Client 需要支持 TLS Session Resumption + DNS Rebinding(万一只查询一次 DNS 就没了),在当今全是从数学算法角度去 Hack TLS 的情况下,作者能从一个比较 Web 的角度来 Hack TLS ,我觉得是一个非常独到的视角,是非常值得学习的地方,尤其是这种协议设计的问题上。

还有,我觉得作者的标题也取的很棒,《When TLS Hacks You》,我们可以想一下为什么不是《When HTTPS Hacks You》呢?卖个关子,如果还有后续的进展,我也会同步到自己的个人博客上,欢迎关注,以及前来交流:https://blog.zeddyu.info/

以上就是本篇文章的全部内容了,如果对该篇文章有任何疑问或者质疑,欢迎来信:echo emVkZHl1Lmx1QGdtYWlsLmNvbQ==|base64 -d

欢迎大家指出文章的错误,也欢迎对协议安全等内容感兴趣的同学一起交流学习!

Acknowledgements

非常感谢在整个流程中帮助我的同学、朋友们,特别感谢 @George 在调试过程中对于我这个萌新不耐我烦的帮助,感谢 @Joshua Maddux @zhaojin 等人在此前对该类型攻击做出的贡献,感谢 @rebirth @chuye 对本文的 Review 指出了本文之前不少的错误。

另外感谢一下小编负责且耐心的编辑,编辑这么长的文章真是辛苦小编了。

References

When TLS Hacks You

DEF CON Safe Mode - Joshua Maddux - When TLS Hacks You

TLS Poison

rustls

RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2

RFC 5077: Transport Layer Security (TLS) Session Resumption without Server-Side State

RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3

RFC 3484: Default Address Selection for Internet Protocol version 6 (IPv6)

RFC 6724: Default Address Selection for Internet Protocol Version 6 (IPv6)

Transport Layer Security

TLS Stateful vs Stateless Session Resumption

TLS Session Resumption

Default destination address selection

When TLS hacks you: TLS + SSRF = RCE by ducnt

hxp 2020

基于 A 和 AAAA 记录的一种新 DNS Rebinding 姿势–从西湖论剑2020 Web HelloDiscuzQ 题对 Blackhat 上的议题做升华