多款浏览器WebDriver安全性分析

 

0x00 WebDriver背景介绍

WebDriver是用于浏览器自动化的一种协议,可以像真实用户操作浏览器一样,驱动浏览器在网页上来执行各种测试。该协议可以用来模拟用户操作,如点击链接、输入文本以及提交表单,便于测试网站是否正常工作。WebDriver通常用于headless(无头)环境下的前端测试以及web爬虫,WebDriver客户端(如Selenium WebDriver)会与WebDriver服务端(如chromedriver、geckodriver)交互,以便启动及控制浏览器。在CTF比赛中,WebDriver客户端通常扮演受害者的角色(比如XSS bot),模拟用户交互动作,以触发XSS payload。

图. python脚本使用Selenium Webdriver启动Chrome,执行example.com上的JavaScript

让我们以简单代码为例展示WebDriver的工作过程。在上图第4行,Selenium WebDriver客户端与chromedriver通信,启动Chrome实例,指导Chrome访问example.com,执行一段JavaScript代码,然后退出浏览器,结束运行。

图. WebDriver、chromedriver及Chrome的交互过程

在这个过程中,WebDriver通过驱动(driver)将命令传递给浏览器,也通过相同的路径接收信息。驱动负责使用浏览器内置的自动化功能来控制实际的浏览器。比如,chromedriver使用–remote-debugging-port=0选项来启动Chrome实例,使Chrome在随机端口上启动远程调试功能,方便chromedriver进行控制。

由于浏览器厂商自己会创建大多数驱动,因此驱动和浏览器之间使用的协议可能会有所不同。基于Chrome的浏览器使用的是Chrome DevTools Protocol,默认情况下会有一些HTTP及WebSocket端点在9222端口上监听。Firefox使用的是自己的Marionette Protocol,通过TCP socket发送和接收JSON数据,如果没有特殊指定,默认会在2828端口上监听。

这些驱动必须遵循WebDriver Protocol的W3C标准,提供一致的REST API。

上图显示了在手动启动的情况下,驱动/浏览器默认监听的端口。其他情况下使用WebDriver时,将采用随机端口,避免冲突。

总而言之,当我们使用WebDriver启动浏览器访问某些网页时,通常情况下localhost上将打开2个端口,其中至少1个端口承载的是用来提供REST API的HTTP服务(Safari比较特殊,它的驱动及浏览器本身在macOS上高度集成,通过XPC服务相互通信)。

为了让文章可读性更强,在下文中,“WebDriver”这个词用来指代WebDriver服务端,也就是特定于浏览器的驱动(如chromedriver、geckodriver)。

 

0x01 Chromedriver

了解基本知识后,我决定暂时不去区别这些WebDriver,先从安全性角度来看能研究多远。首先从一个非常简单的脚本开始:

脚本中只有2个条件:

1、由WebDriver初始化的浏览器访问我们的web页面;

2、浏览器在页面上停留足够长的时间。

经过2周的努力后,我终于在Windows和Linux系统上,在Chrome(或者更确切地说,基于Chromium的浏览器,包括MS Edge及Opera)和Firefox上实现了任意文件读取和RCE。

让我们从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:<CDP Port>/json/list读取webSocketDebuggerUrl呢?响应头很简单,只包含Content-Length以及Content-Type: application/json; charset=UTF-8。对于JSON而言,如果端点实现了JSONP回调功能,我们可以在任意web页面的script标签中加载,然后通过回调函数接收数据。我快速过了一些常见的参数,比如callbackcb以及_callback,但一无所获。深入分析源码后,我可以确认这里并没有实现回调方法。

那么DNS重绑定(rebinding)呢?如果CDP服务端没有检查Host头,那么我们可以使用DNS重绑定技术来访问所有的CDP端点。我尝试将主机更改为127.0.0.1.xip.io域名(解析到127.0.0.1),然而服务端返回的响应为“Host头未指定,且不是IP地址或者localhost”。检查对应的源码后,我确定服务端会检查每个请求的Host头,但我发现无法通过DNS重绑定来绕过。

chromedriver REST API中的RCE

由于我无法对CDP采取更有效措施,我继续研究chromedriver的REST API。读取相关文档和源码后,我发现了有些有趣的端点,可能构成利用链:

1、GET /session/{sessionid}/source。WebDriver的W3C标准中描述过这个端点,可以返回当前活动文档的源代码。

2、GET /sessions。这是chromedriver自己实现的非W3C标准命令,会返回当前chromedriver进程启动的每个会话。我们可以通过该端点找到所有的{sessionid}

3、POST /session。这是用来创建新会话的W3C标准命令,通过提供goog:chromeOptions对象,我们可以指定Chrome程序文件路径,甚至可以指定用来启动新Chrome实例的chromedriver参数。

第3个端点似乎很诱人。在strace的帮助下,我们很快就可以弄清楚如何通过POST请求来执行任意命令。如下图所示,我们的-c<python codes>参数会被成功解析及执行。chromedriver会附加其他一些Chrome参数,但这些参数会被Chrome程序忽略。

多么简单的RCE!我们只需要扫描chromedriver的端口,然后通过表单或者JS fetch API发送POST请求即可!但很快我就发现情况没那么简单。浏览器发起的POST请求始终包含Origin头,表明该请求从何处发送,而chromedriver会对HostOrigin头进行安全检查。

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列表中的IP,这种情况下不会检查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:<open port>/,发送POST请求,触发RCE。这是因为从RequestIsSafeToServe的角度来看,Host以及Origin头是合法的。

演示视频

可以参考演示视频1视频2

 

0x02 Geckodriver

研究完chromedriver后,我开始研究其他WebDriver是否存在类似漏洞。Geckodriver是Mozilla Firefox的WebDriver,与Chrome DevTools Protocol不同的是,并没有太多文档描述它用来与Firefox通信的协议。该协议名为Marionette,是由TCP数据承载的JSON编码文本。

我们无法通过Firefox发送这类TCP报文,毕竟这只是个浏览器,不是pwntool。我还试了一下Marionette是否会像Redis那样忽略未识别的消息,因此我在Firefox可以发送的HTTP请求中夹带了payload,但并没有奏效。

增强型REST API

我花了点时间来测试geckodriver的REST API。不幸的是,geckodriver一次只能启动一个会话,因此我们不能从web页面启动一个新会话,更不用去想篡改Firefox程序路径来执行命令了。尽管geckodriver并没有检查Host头,但还是对OriginContent-Type头实现了更为严格的检查机制。

Origin头必须为本地地址,Content-Type不能被纳入CORS安全列表中。这种机制可以阻止通过DNS重绑定攻击技术发送的POST请求。对于GET请求,没有任何端点可以返回sessionid(chromedriver中的GET /sessions不是标准的W3C命令),因此我们无法利用DNS重绑定攻击。

分割body

目前为止,geckodriver以及Marionette似乎都无法被攻击。就在我准备放弃时,出现了一些意料之外的情况。我尝试过在HTTP请求中夹带Marionette命令,当我把payload字符串重复100,000次时,geckodriver记录到向Marionette发起过2次连接。

<body>
  <form action="#" method="post" enctype="text/plain">
    <textarea name="aaaaaa0" value=""></textarea>
  </form>
    <script>
        let params = new URL(location.href).searchParams,

        port = 1 * params.get('port')

        document.forms[0].action = `http://127.0.0.1:${port}/`

        document.forms[0].aaaaaa0.value = '54:[0,1,"WebDriver:NewSession",{"browserName":"firefox"}]55:[0,2,"WebDriver:Navigate",{"url":"http://example.com"}]'.repeat(100000)

        document.forms[0].submit()
  </script>
</body>

第1个连接很正常,这是我们发起的POST请求。由于该请求无法解析成Marionette的命令格式长度:[type, message ID, command, parameters],因此会抛出一个错误。但第2个连接来自何处呢?为什么报文会在我们重复payload字符串操作的中间出现?我第一时间打开Wireshark,观察具体情况。事实证明,Firefox会为我们的POST请求创建2个TCP连接,第1个连接只包含32KB的HTTP请求body,第2个请求用来发送剩余部分,并且不包含任何HTTP头!

一开始我认为这是我不熟悉的一些浏览器背景知识,比如会将大型HTTP请求body拆分成独立的TCP连接,然而事实并非如此。经过一些测试后,只有Firefox存在这种行为,因此这个bug很可能威力巨大,可以允许攻击者从受害者浏览器发送任意TCP报文,只需要访问恶意web页面即可。

在发送文本数据时,我们可以通过text/plain格式来轻松设置32KB偏移,当我们针对Redis服务端进行测试时,一切工作非常完美。由于第1个报文没有以“POST”字符串作为开头,因此Redis服务端会丢弃该报文,并且Redis有针对这类请求的保护机制。Redis会接收第2个报文中的payload。如果我们想发送二进制数据,可以选择multipart/form-data。尽管随机生成的boundary字符串可能会对偏移值计算造成影响,但还是可以通过多次尝试来暴力枚举。

实现RCE

具备发送Marionette命令的能力后,我们可以使用之前在chromedriver中用过的相同技术,通过Firefox来读取文件。那么RCE呢?在Google上搜索Firefox RCE时我找到了一篇文章,很快我就了解到,Firefox在“chrome特权文档”中提供了一个内置的JS子流程模块。我们只需要导航至hrome://文档,执行一行JavaScript代码即可。

演示视频

参考RCE on Linux (Firefox 86.0.1)RCE on Windows (Firefox 86.0.1)这两个视频。

 

0x03 其他WebDriver

由于MS Edge及Opera都是基于Chromium的浏览器,它们的驱动都源自于chromedriver。稍加修改后,前面适用于chromedriver的payload同样适用于这两个浏览器。对于safaridriver,由于该浏览器会严格检查HostOrigin头,因此我们认为它不容易受类似攻击影响。

 

0x04 总结

DNS重绑定攻击自问世到现在已经14年,时至今日,这项技术依然在漏洞利用链中占有一席之地。通常情况下,只在本地地址上监听的HTTP服务更容易受DNS重绑定攻击影响。我们呼吁开发者在处理传入的请求时,要验证HostOrigin头。正确的验证流程可以避免攻击者通过访问恶意站点来攻击本地HTTP服务。

 

0x05 时间线

23/03/2021 Firefox 87.0发布,修复TCP连接拆分bug

05/04/2021 向Google反馈ChromeDriver特权提升问题

08/04/2021 问题报告被标记为与未解决的issue #3389重复

12/04/2021 本文发布

 

0x06 参考资料

https://labs.detectify.com/2017/10/06/guest-blog-dont-leave-your-grid-wide-open/

https://bluec0re.blogspot.com/2018/03/cve-2018-7160-pwning-nodejs-developers.html

https://bugs.chromium.org/p/project-zero/issues/detail?id=1471

加成券.JPG

(完)