深入分析CVE-2021-43848漏洞

fanyeee
接收方应使用后一个字段来检查RESET_STREAM是否是在所有流数据发送完毕后发送的。如果它确实拥有所有的数据,它可以将RESET_STREAM解释为一个设置了FIN标志的、长度为零的STREAM帧;也就是说,它可能会表现得好像流是完全发送的,而没有中止。

本文将为大家介绍Web服务器H2O的QUIC(HTTP/3)协议实现中的一个安全漏洞。我们之所以对这个漏洞感兴趣,因为它会以某种方式影响Fastly,攻击者可以利用该漏洞从节点的未初始化内存中窃取随机请求和响应,这有点类似于CloudBleed漏洞(与CloudBleed漏洞不同的是,该漏洞要求攻击者执行特定的操作)。

最初,我是想在HTTP/3协议实现中寻找HTTP请求走私漏洞的。我以前对HTTP/2协议曾经做过类似的研究,并且发现的一个安全漏洞与Cloudflare有关——Cloudflare是一个著名的CDN/anti-DDoS服务,在其客户端后面充当反向代理。由于QUIC(HTTP/3的底层协议)最近变成了RFC9000草案,我决定研究一下该协议的实现代码,看看能否找到一些安全漏洞。

搭建测试环境

Fastly是一种CDN和anti-DDoS服务,作为反向代理,接受用户的请求,根据一套复杂的规则进行处理,并通过其缓存提供服务,或将其转发给上游——藏在Fastly后面的真正服务器。

创建Fastly服务是非常简单的事情。人们可以注册一个帐户,购买一个域名,并设置DNS记录以指向Fastly服务器。之后,人们还需要购买一个TLS证书,因为HTTP/2和HTTP/3需要对所有数据进行加密。

值得注意的是,启用HTTP/3支持是需要手动完成的:人们需要写一张支持票证,并等待回复。然而,事实证明,为了进行安全测试,我们根本就无需订阅启用HTTP/3的特定服务,相反,只要找到任何支持QUIC的Fastly服务器并向其发送请求就够了。虽然真正的浏览器不会这样做,因为缺少Alt-Svc头部,但直接发送到QUIC端口的请求也会得相应的处理——不管控制面板中的设置如何,实际上,对于我们要进行的安全测试来说,这就足够了。

由于www.fastly.com本身启用了HTTP/3,所以,我们可以通过解析这个域名来收集一些支持HTTP/3的IP。

$host-t A www.fastly.com

www.fastly.com is an alias for prod.www-fastly-com.map.fastly.net.

prod.www-fastly-com.map.fastly.net has address 151.101.113.57

之后,您可以使用工具http2smugl向它发送HTTP/3请求,方法是伪造:authority头部(是的,该工具支持HTTP/3,尽管它的名称是http2smugl):

$http2smugl request https+h3://151.101.113.57/":authority:a-domain-behind-fastly.com"

:status:200

content-length:0

...

如果我们在上游服务器的http端口运行netcat,我们将收到一个连接,表明Fastly的确代理了该请求:

$nc-l 80

GET/HTTP/1.1

host:a-domain-behind-fastly.com

content-length:0

user-agent:Mozilla/5.0

Fastly-SSL:1

Fastly-Client-IP:

X-Forwarded-For:

X-Forwarded-Server:cache-fjr7923-FJR

X-Forwarded-Host:a-domain-behind-fastly.com

X-Timer:S1637693479.183877,VS0

X-Varnish:2313353937

Fastly-FF:PPtav1cHmKdVa+PX0PZLG1dkeRjY/RpDKLvKU7LtCKo=!FJR!cache-fjr7923-FJR

CDN-Loop:Fastly

...

太好了。

检测使用了哪些软件

据Fastly披露,它使用Varnish来处理缓存事宜和复杂的请求。然而,Varnish并不支持HTTP/3协议;因此,一定还有其他软件来“转译”HTTP/3:它接受来自用户浏览器的HTTP/3连接,对其进行解码,并转发到Varnish。

当我在请求头部的名称或值中加入换行符时,Fastly会返回出错信息,这说明HTTP请求走私漏洞根本就不可能存在:

$http2smugl request https+h3://151.101.113.57/":authority:a-domain-behind-fastly.com""eldushechka:n"

:status:400

content-length:42

content-type:text/plain;charset=utf-8found an invalid character in header value

有次来看,寻找HTTP请求走私的路是走不通了。

好消息是,我们可以通过搜索引擎来考察这个错误,并发现它来自H2O:一个最近开始支持QUIC协议的小型HTTP/Web服务器。因此,我们现在知道了攻击的对象是谁,并可以深入研究其源代码。此外,我们可以编译其源代码,并在本地设置反向代理:因为发行版中提供了所有必要的配置示例。

测试很快就变得有趣起来——仅需一个带有CONNECT方法以及非零值content-length的请求就能使服务器崩溃:

$http2smugl request https+h3://127.0.0.1:8443/":method:CONNECT""content-length:10"...at another tab...h2o:../lib/http3/server.c:462:void shutdown_stream(struct st_h2o_http3_server_stream_t*,int,int,int):Assertion`stream->state<H2O_HTTP3_SERVER_STREAM_STATE_CLOSE_WAIT’failed.

received fatal signal 6

./h2o(backtrace+0x5b)[0x47bacb]

./h2o[0x932766]

...

虽然这只是一个简单的断言,并且除了DoS之外,没有任何安全影响,但这意味着QUIC代码没有进行全面的模糊测试,所以,它很可能存在其他安全漏洞。断言信息提到了一个recvstate类型的对象;我决定在它周围寻找更多可利用的东西。

QUIC流

HTTP/3是第一个没有使用TCP协议的HTTP版本。相反,它使用了一个全新的传输协议QUIC,该协议使用UDP来传输其数据。由于UDP不仅没有提供可靠性,还无法保证数据的顺序,同时也没有拥堵控制,因此,QUIC的实现必须自己处理这些问题。然而,这意味着QUIC的实现必须比HTTP/Web服务器软件处理更多的东西。

H2O使用了一个专门设计的QUIC实现,并将其分离到Quicly库中。该库处理与QUIC有关的一切:连接的加密握手、重传、流量控制等。

建立在QUIC连接之上的、用于数据传输的逻辑抽象被称为流。一个QUIC连接通常用于传输多个数据流。单个流有点像TCP连接:数据可以在一个流的两个方向上传输,交付是有保证的,而且数据的顺序也保持不变。当QUIC用于HTTP/3时,每个流携带一个单一的HTTP请求:客户端发送请求头部和正文到一个新的流,服务器发送响应,然后流将被关闭。需要说明的一点是,有些流可以由服务器发起(所谓的“推送”),但我们在这篇文章中用不到它们。

传输过程中,QUIC数据被编码在所谓的帧中。在RFC9000中,定义了20种帧类型,看起来有点多;然而,如果我们将连接建立、流控制、路径探测、交付确认和加密等工作留在幕后,那么,就只剩下三种与数据传输直接相关的帧类型:

STREAM帧,它携带流数据;

RESET_STREAM,表示不再发送流数据;客户端可以发送此帧,来指示以前启动的请求不再需要了;

STOP_SENDING,它是由不希望在流中获得更多数据的接收者发送的。

只有前两种类型会影响流的接收方的状态,它被存储在Quicly中的recvstream结构中。为了进一步考察这里发现的漏洞,我们需要知道这些帧包含什么内容,以及它们是如何工作的。

数据传输

通过QUIC流传输的数据,在传输之前并不需要知道数据的长度——这一点与HTTP/1.0不同,它需要通过Content-Length头部指出数据的长度。相反,QUIC总是以块的形式传输,类似于HTTP/1.1的分块传输编码。在QUIC世界中,块就是STREAM帧。

每个STREAM帧都包含多个字段:

流ID;

帧的偏移量,它指示该特定块在该传输方向的整个半流(即客户端→服务器或服务器→客户端)中的偏移量;

帧的长度;

FIN标志,指示此帧是否在这个流方向的半流中为最终帧。如果该标志被设置,该帧还包含这次传输的数据的总长度;

流数据本身。

第二个字段(偏移量)是必须的,因为QUIC帧是通过UDP传输的,而UDP并不能保证数据包将以发送顺序到达。与TCP不同,接收方可以丢弃乱序的数据,但是,QUIC接收方必须提供一个缓冲区来保存乱序数据,以便在丢弃前缀(missing prefix)到达时使用。

当流的一方(通常是客户端)决定不再进行传输时,将发送RESET_STREAM帧。语义上,它意味着不会再有数据朝这个方向发送。例如,如果浏览器用户单击停止按钮以取消发送数据,则可以发送这种帧。但是,即使服务器接收到RESET_STREAM,它仍然可能会处理该请求:例如,服务器可能在RESET_STREAM到达之前就已经开始处理了。在这种情况下,该协议允许通过流来发送响应。

RESET_STREAM帧包含三个有意义的字段,分别是:

流ID;

错误代码,这不是我们感兴趣的;

到此刻为止流中所发送的字节总数。

接收方应使用后一个字段来检查RESET_STREAM是否是在所有流数据发送完毕后发送的。如果它确实拥有所有的数据,它可以将RESET_STREAM解释为一个设置了FIN标志的、长度为零的STREAM帧;也就是说,它可能会表现得好像流是完全发送的,而没有中止。

漏洞详情

本节及以下有关H2O的信息,仅对d1f0f65269及以前的版本有效。这个版本并不是对这里描述的漏洞的修复,而是对此前的一个漏洞的修复;之所以使用这个版本,是因为我的测试环境中使用的就是这个版本。关于本文所述漏洞的修复,请见a68cabaeb1版本。

正如我之前提到的,H2O使用一个单独的库来处理QUIC协议,这个库叫做Quicly。与其他实现不同的是,Quicly并没有提供缓冲区来处理乱序的数据;相反,它从用户那里触发一个回调函数,接收数据偏移量作为参数,同时接收数据大小和数据本身。因此,使用Quicly的应用程序(在我们的例子中是H2O本身)需要保存那些比前面的一些字节早到而不能使用的字节。

然而,Quicly不仅会存储已经收到的字节的位置(开始和结束偏移量),并提供了一个接口来访问这些范围内的位置。特别是,它定义了quicly_recvstate_bytes_available函数,用于返回连续前缀的总长度。从应用程序的角度来看,这相当于在假定正确存储了以前通过回调发送的所有数据的情况下已经可以使用的字节数。

另一方面,负责存储已经到达的数据的H2O并不将其保存在块中。相反,它使用一个连续的缓冲区(即st_h2o_http3_server_stream_t->recvbuf),并将所有分段存储在那里,并必要时调整缓冲区的大小。因此,对应于尚未到达的数据的字节来说,它们在这个缓冲区中是未初始化的。

这里要注意的是,H2O当然不会存储它已经处理过的数据。相反,它会移动对应于缓冲区开始位置的偏移量,并从收到的STREAM帧的偏移量中减去相同的值。这个细节对我们没有任何影响,在此给出是为了说明:如果一个实现存储所有的流字节,包括那些它不再需要的字节,那将带来巨大的隐患。

实际上,这种行为已经暗地里为我们提供了分配包含大量未初始化数据的缓冲区的方法:我们可以直接发送一个STREAM帧,并使其偏移量与之前发送的最后一个字节之间留一个间隙。现在,我们需要让H2O使用它,这时候,RESET_STREAM帧就派上用场了。

RESET_STREAM的处理方式如下所示:

如果它已经知道流的总长度(要么来自带FIN标志的STREAM帧,要么来自先前收到的RESET_STREAM帧),它将检查帧内“total size”字段中的值是否与已知的值一致;

丢弃所有关于先前收到的字节范围的信息。

后者意味着:在处理过一个RESET_STREAM帧之后,H2O就无法区分stream->recvbuf中哪些字节是由客户的数据初始化的,哪些是原封不动的。更令人兴奋的是,调用quicly_recvstate_bytes_available函数会返回流的总长度,就好像所有留在缓冲区的数据都是之前被STREAM帧设置的一样。

要想成功利用这个漏洞,还必须满足一个要求:发送RESET_STREAM帧之后,设法让系统将recvbuf内容发送到上游。这个问题很棘手,取决于H2O如何将请求代理给上游。特别是,我们需要知道请求的正文(body)是如何被缓存的。

当H2O收到一个HTTP/3请求后,如果头部信息表明它包含一个正文,那么,它并不会立即开始处理该请求。相反,它将缓冲正文,直到它达到一个特定的限制,默认为10240字节。在收到第10240字节的正文后,它将切换到流模式(在这个模式下,Transfer-Encoding:chunked将被发送给HTTP/1.1上游),并继续处理请求。

任何合理的代理实现,都必须处理上游读取数据的速度比客户端发送数据的速度慢的情况。其中,一种方法是在每次向上游发送数据后,检查客户端的缓冲区是否包含更多字节,而这正是H2O采取的方法。因此,如果我们能使RESET_STREAM帧在请求开始被发送到上游后,但在所有可用数据被发送之前被处理,H2O将检查stream->recvbuf是否含有更多的数据。由于quicly_recvstate_bytes_available函数的返回值是不正确的,检查会成功通过,H2O会继续转发数据到上游,而不知道这些数据是未初始化的。幸运的是,这里并不会检查流是否已经在代码路径上被RESET_STREAM撤销了。

为了总结上述内容并给出源代码参考,让我们重复一下我们想要组合使用的、Quicly/H2O中的三个漏洞:

1.当收到RESET_FRAME时,位于deps/quicly/lib/quicly.c中的quicly_recvstate_reset只是清除了st_quicly_recvstate_t结构的接收范围。随后对quicly_recvstate_bytes_available的调用将返回流中仍然剩余的字节总数(无论它们是否真的被接收)。

2.lib/http3/server.c中的函数handle_buffered_input并没有检查一个流是否被撤销(quicly_stop_requested函数只检查发送部分是否被取消,而没有检查接收部分)。因此,它将继续处理stream->recvbuf中未初始化的部分,就像它们是客户端发送的一样。

3.lib/http3/server.c中的proceed_request_streaming函数会调用handle_buffered_input,并且没有检查流是否被撤销。这个函数被设置为回调函数,当反向代理请求在流模式下发送更多数据时,这个函数将被调用。

漏洞利用思路

鉴于上面的描述,我们可以得出以下的漏洞利用思路:

攻击者建立一个与H2O实例的连接,并进行QUIC握手。

攻击者向实例发送请求头部,然后是10239字节的请求正文。同时,H2O会接收它们并进行确认。

漏洞利用代码发送一个特制的数据报,其中包含三个QUIC帧。

——STREAM帧,位于正文的第10240字节处(即offset字段设置为headers长度+10239,数据长度为1)。

——STREAM帧,位于正文的第30000字节处,同时也设置了FIN标志(即偏移字段设置为headers长度+29999,数据长度为1,设置FIN标志,最终size字段设置为报头长度+30000)。

——RESET_STREAM帧,最终size字段等于报头长度+30000

这里的值30000是任意选择的。我们将为这个值泄露30000-10240=19760字节的未初始化数据。在实际的漏洞利用过程中,我为这个参数尝试了多个不同的值,并泄露了不同类型的数据。

H2O处理数据报时,将执行以下步骤:

——当处理第一个STREAM帧时,将HTTP请求切换到流模式,并发起与上游的连接;

——在处理了位于接收到的正文的第30000个字节处的帧后,它将stream->recvbuf扩大到30000,并写入其中的最后一个字节,而其余部分并未进行初始化。它还将eos值设置为30000,因为帧中的FIN标志已被设置。

——在调用quicly_recvstate_reset中接收到RESET_STREAM帧后,并没有关注stream->recvbuf的哪些字节被初始化,哪些字节没有被初始化。

H2O反向代理模块向上游发送前10240字节,并通过调用proceed_request_streaming请求更多数据。后面的函数将剩下的19760字节转发给上游,其中只有最后一个字节被设置过,其余的都是泄露的未初始化的数据。

在上面的描述中完全忽略了一件事,即HTTP/3级帧的处理。实际上,在QUIC流上传输的数据被打包成HTTP/3帧,这些帧不同于QUIC帧,而类似于HTTP/2帧。对于HTTP/3级帧来说,除了需要调整底层QUIC流中的偏移量之外,并不会给攻击增加任何其他困扰。

漏洞利用过程

为了执行上面的计划,我们需要精心构造的QUIC帧。刚开始,我打算从头开始创建一个简单的QUIC实现,经过几次尝试后我就放弃了,转而开始转向quic-go。实际上,我们只需要重命名internal目录,就可以构造自己的帧,同时,我们还可以为sendStream结构添加一个函数,用于将我们的帧直接添加到队列中以供发送。

经过几轮调试,它在我的本地H2O设置中成功实现了漏洞的利用。我的本地Web服务器收到了一些看起来像未初始化的内存数据:如内存地址、可读字符串等。我使用exploit对Fastly发动攻击,第一次尝试就得手了!在上游服务器中,我收到了一个来自Fastly的请求,其正文包含一个HTTP响应,该响应显然是要发送给另一个用户的(与使用Fastly的另一个网站相关),并与二进制数据混在一起。我运行了几次,收到了不同的东西:图像、Fastly内部统计数据的转储、cookie以及其他有趣的东西。

披露时间线

我已经向Fastly披露了这个安全漏洞,他们很快做出了反应:他们在几天内就对生产版本进行了热修复,然后,我们协调了披露事项(例如,这篇文章)。整个时间表如下所示:

11月23日(2021年):报告该漏洞;

12月1日:在一个实例中部署了热修复程序;

12月8日:该安全问题得到完全解决;

1月31日:公开披露。

这个H2O中的漏洞的编号为CVE-2021-43848。

小结

在本文中,我们为读者详细介绍了通过模糊测试在HTTP/3和QUIC发现的一个内存漏洞,及其利用方法;我期待着将来能够在开源的QUIC实现中找到更多令人兴奋的安全漏洞。

本文翻译自:https://medium.com/ emil.lerner/leaking-uninitialized-memory-from-fastly-83327bcbee1f

THEEND

最新评论(评论仅代表用户观点)

更多
暂无评论