你在跟我说话吗?| STAR实验室
目录
什么是WebDriver及其工作原理?
WebDriver是一种用于网页浏览器自动化的协议。它可以驱动浏览器对网页执行各种测试,就像真实用户在浏览一样。它允许模拟用户操作,如点击链接、输入文本和提交表单,有助于测试网站是否按预期工作。WebDriver通常用于无头环境中的前端测试和网络爬虫。WebDriver客户端(如Selenium WebDriver)与WebDriver服务器(如chromedriver、geckodriver)交互,以启动和控制浏览器。在夺旗(CTF)比赛中,WebDriver客户端通常扮演受害用户(又称XSS机器人)的角色,模拟用户交互以触发玩家提供的XSS载荷。
使用Selenium WebDriver启动Chrome并在example.com上执行JavaScript的Python脚本 让我用这段示例代码来解释WebDriver的工作原理。在第4行,Selenium WebDriver客户端与chromedriver通信,启动一个Chrome实例,指示Chrome访问example.com,执行一段JavaScript代码,然后退出浏览器结束所有操作。
WebDriver、chromedriver和Chrome之间交互的序列图 在这个过程中,WebDriver通过驱动程序将命令传递给浏览器,并通过同一路径接收信息。驱动程序负责控制实际的浏览器,利用浏览器内置的自动化支持。例如,chromedriver使用选项“–remote-debugging-port=0”启动Chrome实例。这导致Chrome实例在随机端口上启用远程调试功能,以便chromedriver控制它。
由于浏览器供应商自己创建大多数驱动程序,驱动程序与浏览器之间使用的协议可能有所不同。基于Chromium的浏览器使用Chrome DevTools Protocol,这是一组HTTP和WebSocket端点,默认监听端口9222,而Firefox使用自己的Marionette Protocol,通过TCP套接字发送和接收JSON编码的数据。除非另有指定,否则它监听端口2828。
这些驱动程序需要遵循W3C的WebDriver协议标准,并提供一致的REST API。
图片显示如果手动启动,驱动程序/浏览器将监听的默认端口,而在使用WebDriver时,端口是随机的,以避免冲突。
总之,当我使用WebDriver让浏览器访问某些网页时,通常会在localhost上打开两个端口,其中至少有一个是提供REST API的HTTP服务。(Safari是个例外,因为它的驱动程序和浏览器本身高度集成在macOS中,它们通过XPC服务相互通信)
为了提高可读性,下文中的“WebDriver”指的是WebDriver服务器,换句话说,是特定于浏览器的驱动程序(如chromedriver、geckodriver)。
谷歌的Chromedriver
基于先前的知识,我决定折腾这些WebDriver,看看从安全角度能走多远。我从一个非常简单的草图脚本开始:
脚本中只有两个条件:
- WebDriver启动的浏览器访问我们的网页
- 浏览器在页面上停留足够长的时间
经过两周的努力,我成功在Chrome(或更准确地说,基于Chromium的浏览器,包括MS Edge和Opera)和Firefox上实现了任意文件读取和RCE,并在Windows和Linux上进行了测试。
从Chrome开始,我首先检查了自动化的Chrome实例是否可以访问随机端口。答案是肯定的。chromedriver REST API和Chrome DevTools Protocol(CDP)服务器都暴露给Chrome实例本身。
通过Chrome DevTools Protocol实现潜在任意文件读取
根据CDP文档,/json/list端点返回调试信息列表。如果我能以某种方式读取列表中的webSocketDebuggerUrl值,我将能够执行CDP所能做的一切。例如,我可以使用Page.navigate访问任何URL,甚至是file://方案,然后使用Runtime.evaluate执行任意JavaScript。结合这两者,攻击者可以枚举本地目录列表,并将任何文件的内容外泄到远程服务器。
但是我们如何从http://127.0.0.1:
DNS重绑定呢?如果CDP服务器不检查Host头,我们可以使用DNS重绑定技术访问所有CDP端点。我尝试将host更改为域127.0.0.1.xip.io,它解析为127.0.0.1,而服务器响应“Host header is specified and is not an IP address or localhost”。通过检查相应的源代码,确认服务器对每个请求都执行Host头检查,我没有看到它被DNS重绑定绕过。
Chromedriver REST API中的RCE?
既然对CDP无能为力,我转向了chromedriver的REST API。在阅读其文档和源代码时,我发现了一些可能适合漏洞利用链的有趣端点:
- GET /session/{sessionid}/source 此端点在W3C的WebDriver标准中有文档记录。它返回当前活动文档的源代码。
- GET /sessions 这是chromedriver单方面实现的非W3C标准命令。它返回当前chromedriver进程启动的每个会话。我们可以在这里找到所有{sessionid}。
- POST /session 这是一个W3C标准命令,用于创建新会话。通过提供goog:chromeOptions对象,我们可以指定Chrome二进制路径,甚至为chromedriver提供参数以启动新的Chrome实例。
第三个看起来很有诱惑力。在strace的帮助下,我没花太多时间就弄清楚了如何通过POST请求执行任意命令。从下面的图片中可以看到,我们的-c<python代码>参数被完美解析和执行。chromedriver附加了一些其他Chrome参数,但Chrome二进制文件python忽略了它们。
哇,多么简单的RCE!利用只需要扫描chromedriver的端口,然后通过表单或JS fetch API发送POST请求!但我很快意识到这并不像我想的那么容易。浏览器发出的POST请求总是带有Origin头,指示请求的来源。Chromedriver对Host和Origin头有安全检查。
检查函数RequestIsSafeToServe的工作方式如下:
-
如果chromedriver启动时没有选项–allowed-ips:
- 对于所有请求,Host头应通过net::IsLocalhost检查
- 如果存在Origin头,其主机名部分应通过net::IsLocalhost检查
-
如果chromedriver启动时带有选项–allowed-ips=<any_ips>:
- 对于GET请求,不检查Host头
- 对于POST请求:
- 如果不存在Origin头,不检查Host;因此从浏览器发送没有Origin头的POST请求是不可能的。
- 如果Origin头格式为IP:port,IP必须是本地IP或在allowed_ips列表中。这种情况下不检查Host头。从浏览器发送没有scheme://的Origin头是不可能的。
- Host头和Origin头的主机名部分应通过net::IsLocalhost检查
DNS重绑定完成漏洞利用链
在我们可以从浏览器发送的所有请求中,如果chromedriver启动时带有–allowed-ips选项,可以通过DNS重绑定攻击绕过RequestIsSafeToServe。这意味着我们可以访问每个接受GET请求的chromedriver REST API,包括GET /sessions和GET /session/{sessionid}/source。通过结合这三者,我现在可以读取CDP的/json/list的内容。
图片显示了攻击者读取webSocketDebuggerUrl的整个过程。端口9515和9222仅用于演示目的。实际端口是随机的,可以通过JavaScript探测。有了webSocketDebuggerUrl,我们不仅可以读取任意文件,还可以导航到http://127.0.0.1:<开放端口>/并发送POST请求以触发RCE,因为从RequestIsSafeToServe的角度来看,Host和Origin头将是合法的。
PoC视频
Mozilla的Geckodriver
在chromedriver之后,我开始研究其他WebDriver以寻找类似的漏洞。Geckodriver是Mozilla的Firefox WebDriver。与Chrome DevTools Protocol不同,它用于与Firefox通信的协议文档很少。该协议称为Marionette,它只是TCP数据中的JSON编码文本。
无法从Firefox发送这种TCP数据包。它是一个网页浏览器,不是pwntools。我还尝试查看Marionette是否会像Redis那样忽略无法识别的消息,这样我就可以在Firefox可以发送的HTTP请求中夹带实际载荷,但这种方式行不通。
强化的REST API
我花了一些时间测试geckodriver的REST API。不幸的是,geckodriver一次只能启动一个会话,因此我们无法从我们的网页启动新会话,更不用说篡改Firefox二进制路径以执行命令了。尽管geckodriver不检查Host头,但它对Origin和Content-Type头实施了更严格的检查。
Origin头必须是本地地址,Content-Type不能是CORS安全列表中的类型。这一措施阻止了从DNS重绑定攻击发送的POST请求。至于GET请求,没有端点会返回sessionid(chromedriver中的GET /sessions不是W3C标准命令),因此DNS重绑定无能为力。
拆分请求体
到目前为止,geckodriver和Marionette似乎都不可利用。就在我准备放弃时,意外发生了。我尝试在HTTP请求中夹带Marionette命令;当我重复载荷字符串100,000次时,geckodriver记录了两个到Marionette的连接。
第一个连接是预期的;它是我们发出的POST请求。由于无法解析为Marionette的命令格式length:[type, message ID, command, parameters],抛出了错误。但第二个连接是从哪里来的?数据包是如何从我们重复的载荷字符串中间凭空开始的?我立即打开WireShark查看究竟发生了什么。结果发现,Firefox为我们的POST请求建立了两个TCP连接。第一个连接只包含32KB的HTTP请求体,第二个连接发送了剩余部分,没有任何HTTP头!
起初,我以为这是我遗漏的一些众所周知的知识,即浏览器会将大的HTTP请求体拆分成单独的TCP连接。我错了。经过一些测试,只有Firefox有这种行为。我意识到这个漏洞有多大潜力。它允许攻击者只需让受害者访问恶意网页,就可以从受害者的网页浏览器发送任意TCP数据包。
32KB的偏移量很容易通过表单类型“text/plain”设置,用于发送文本数据。当我们针对Redis服务器测试时,它工作得非常好。Redis服务器丢弃了第一个数据包,因为它以字符串“POST”开头,并且Redis对此类请求有保护。Redis接受了第二个数据包中的载荷。如果我们想发送二进制数据,“multipart/form-data”是选择。尽管随机生成的“boundary”字符串可能成为计算偏移量的变量,但它仍然可以在几次尝试中被暴力破解。
简易RCE
有了发送Marionette命令的能力,我们在chromedriver中用于读取文件的相同技术也在Firefox中有效。RCE呢?在Google上搜索firefox RCE带我到了这篇文章。我很快了解到Firefox在“chrome-privileged document”中有一个内置的子进程JS模块。我需要做的就是导航到“chrome://”文档并执行一行JavaScript代码。
PoC视频
不幸的是,TCP连接拆分漏洞已在Firefox 87.0中修复为CVE-2021-23982。Samy Kamkar在他的研究“NAT Slipstreaming”中更早发现了这个漏洞。
其他WebDriver
由于MS Edge和Opera基于Chromium,它们的驱动程序都源自chromedriver。经过轻微修改,我们为chromedriver的所有载荷在它们上都有效。至于safaridriver,我们没有发现它容易受到类似攻击,因为它对Host和Origin头有严格检查。
要点
自DNS重绑定攻击被发现并公之于众以来,已经过去了14年。今天,这种技术仍然时不时地在漏洞利用链中占有一席之地。通常,只监听本地地址的HTTP服务更容易受到DNS重绑定攻击。我们呼吁开发者在处理传入请求之前验证Host和Origin头。适当的验证可以防止本地HTTP服务被恶意网站利用。
时间线
- 23/03/2021 Firefox 87.0发布,消除了TCP连接拆分漏洞
- 05/04/2021 向Google报告了ChromeDriver权限提升
- 08/04/2021 报告被标记为与未解决的问题#3389重复
- 12/04/2021 博客文章发布