V8 入门概念笔记 —— patch / BUILTIN / 浮点编码 / pwntools

pwn.college v8 exploitation level1做题过程中,学习了很多v8入门概念:怎么读 patch、BUILTIN 是什么、为什么 shellcode 能伪装成小数、pwntools 的 p64 和 struct 的关系。便作此笔记记录。


一、怎么读 patch

patch 的格式(30 秒学会)

1
2
3
-这行被删掉了
+这行是新加上的
这行没变(只是帮你定位的上下文)

@@ -24,6 +24,8 @@ 这种是行号信息,不用管。一份 patch 分成若干「块」,每块对应一个被改的文件。

读 patch 只需要抓三样东西

每拿到一道 V8 题的 patch,脑子里就问三个问题:

问题 关键词 含义
1. 攻击面:新加了什么 JS 函数? BUILTIN( 这是新加/被改的底层函数,漏洞入口
2. 约束:用它要满足什么条件? if (...) 括号里的条件就是 exploit 必须满足的限制
3. 效果:调用后发生什么? mmap / memcpy / cast / 调用 告诉你利用后能干嘛

实战套路

1
2
3
4
5
1. 打开 patch
2. 只看带 + 的行(新加的)
3. 找 BUILTIN( —— 漏洞入口
4. 找 if —— 约束
5. 找 mmap / memcpy / ((...)()) —— 效果

完全不需要懂 C++ 语法,只要认出 if(判断)、括号里的条件(约束)、大括号里的操作(效果)。

Level 1 的例子

1
2
3
4
5
6
7
BUILTIN(ArrayRun) {                          // 新函数 = 攻击入口
if (kind != PACKED_DOUBLE_ELEMENTS) {...} // 约束:必须是小数数组
if (length*8 > 4096) {...} // 约束:最多 512 个元素
double *mem = mmap(NULL,4096,7,0x22,-1,0); // 效果:申请可读可写可执行内存(7=R+W+X)
for (...) mem[i] = elements->get_scalar(i);// 效果:把小数字节拷进去
((void (*)())mem)(); // 效果:当机器码执行!
}

流程图:

1
2
3
4
5
6
7
8
9
10
11
JS Array

ArrayRun builtin

检查 double array

mmap RWX内存

把数组写进内存

执行这段内存

结论:arr.run() 把小数数组当 shellcode 执行 → 这是出题人留的后门。


二、BUILTIN 是什么

大白话

你在浏览器控制台写 [1,2,3].push(4),这个 push 不是 JS 写的,是藏在 V8 引擎深处的 C++ 代码实现的。V8 用 BUILTIN(xxx) 标记「这是一段 C++ 代码,JS 可以直接调用」。

比喻:JS 是前厅菜单,BUILTIN 是后厨厨师。你点 arr.push(4)(点菜),V8 把订单传到后厨,对应的 BUILTIN(厨师)来做。

调用过程

1
2
3
4
5
[1, 2, 3].push(4)        // JS 表面写法

V8 找到 push 对应的 BUILTIN

直接执行那段机器码(C++ 编译出来的)

注意:V8 不是逐句解释 JS,而是直接跳到预先写好的 C++ 机器码去跑,所以快。

为什么要有 BUILTIN

push 这种函数每秒被调用亿万次,用纯 JS 写太慢,所以 V8 工程师用 C++ 手写并编译成机器码。BUILTIN = 「用 C++ 重写的 JS 标准库函数」的标志。

为什么是考点

CTF 出题人打 patch 最爱干两件事:

  1. 新写一个 BUILTIN(如 BUILTIN(ArrayRun))→ 塞后门;
  2. 改现有 BUILTIN → 比如改 Array.prototype.sort 留下越界 bug。

所以读 patch 看到 BUILTIN( 就警觉:这是能从 JS 调到的 C++ 函数被动了,就是攻击入口。


三、为什么 shellcode 能伪装成小数

核心思想:同一串字节,看你怎么「解释」

计算机里一切都是字节。同样 8 个字节:

  • CPU 指令看 → 是一条机器码;
  • IEEE-754 公式看 → 是一个小数;
  • UTF-8 看 → 是一段文字。

比喻:同一个二维码,美团扫出外卖链接,微信扫出名片。图没变,解释方式不同。

1
2
3
4
5
8 字节:48 b8 2f 63 68 61 6c 6c

解释 A(CPU):movabs 指令
解释 B(小数):1.91...e+214
解释 C(文字):H¸/chall

一个 double 占 8 字节

小数(double)按 IEEE-754 标准存,固定 8 字节。所以:任意 8 字节都能当成一个小数,反过来一个小数也对应固定的 8 字节。

实操:用 Python 互转

1
2
3
4
5
6
7
8
9
import struct

chunk = bytes.fromhex('48b82f6368616c6c') # shellcode 的 8 字节

d = struct.unpack('<d', chunk)[0] # 字节 → 小数
# d = 1.9108516107993792e+214

back = struct.pack('<d', d) # 小数 → 字节
# back.hex() = '48b82f6368616c6c' ← 一字节不差

那些奇怪的数字(如 8.5e-315)是怎么来的

不是我们选的,是 shellcode 字节按 IEEE-754 公式算出来就长那样。e 是科学计数法(8.5e-315 = 8.5×10⁻³¹⁵)。这个数学值毫无意义——我们只关心它在内存里的那 8 个原始字节就是 shellcode。数字只是包装纸,字节才是货。

在 V8 题里的完整链条

1
shellcode 字节 ──struct──→ 奇怪小数 ──写进JS数组──→ V8按小数存内存(还是那些字节) ──run()──→ 当机器码执行

四、pwntools 的 p64 和 struct 的关系

它们都是「数字 ↔ 字节」的翻译机

函数 来自 作用 方向
p64(x) pwntools 数字 → 8 字节 数字→字节
u64(b) pwntools 8 字节 → 整数 字节→数字
p32 / u32 pwntools 同上,4 字节 互转
struct.pack('<Q', x) 标准库 数字 → 8 字节(整数) 数字→字节
struct.unpack('<Q', b) 标准库 8 字节 → 整数 字节→数字
struct.pack('<d', x) 标准库 小数 → 8 字节 数字→字节
struct.unpack('<d', b) 标准库 8 字节 → 小数 字节→数字

pwntools 就是 struct 的快捷封装

1
2
p64(x)   ==  struct.pack('<Q', x)        # 数字 → 8字节
u64(b) == struct.unpack('<Q', b)[0] # 8字节 → 数字
  • p = pack(打包成字节),u = unpack(拆回数字)
  • 64 = 64位 = 8字节,32 = 4字节
  • pwntools 帮你把 '<Q' 这种格式码省掉了

struct 的格式码

格式码 含义 字节数
< 小端序(x86 都用这个)
Q 无符号 64 位整数 8
I 无符号 32 位整数 4
d 小数(double) 8
f 小数(float) 4

关键Qd 都是 8 字节,但 Q 把字节看成整数,d 看成小数。底层字节可以一样,解释方式不同。

入门 pwn vs V8 题的区别

1
2
3
4
5
# 入门 pwn:处理地址/整数,把数字写成字节喂给程序
payload = p64(0x4040e0) # 数字 → 字节

# V8 题:把 shellcode 字节读成小数,写进 JS 数组
d = struct.unpack('<d', chunk)[0] # 字节 → 小数
  • 整数/地址场景 → 用 p64/u64 顺手
  • 小数场景(V8) → pwntools 没有现成的小数快捷函数,直接用 struct'<d'

一句话记忆

p/u 和 pack/unpack 都是「数字↔字节」翻译机。<Q 翻成整数,<d 翻成小数,字节可以一样、解释方式不同。入门 pwn 处理地址用 p64,V8 处理 shellcode 进小数数组用 struct unpack '<d'


五、BUILTIN 怎么接到 arr.run()(接线三步)

BUILTIN(ArrayRun){...} 只是写了函数实现,但 JS 里凭空是叫不到它的。要让 arr.run() 能触发它,还要两步「接线」,三步都在 patch 里。

比喻:招一个新厨师要三步——① 教会他做菜(写实现)② 登记进员工花名册(注册)③ 在菜单上写一道菜并标明由他做(挂到 JS)。

步骤 patch 里的代码 在哪个文件 作用
① 写实现 BUILTIN(ArrayRun) { ... } builtins-array.cc 函数本体
② 注册 CPP(ArrayRun) builtins-definitions.h 加进 V8 内置总表,生成 Builtin::kArrayRun
③ 挂到 JS SimpleInstallFunction(proto, "run", Builtin::kArrayRun, ...) bootstrapper.cc 关键:把名字 run 挂到 Array.prototype

第③步拆开看:

1
2
SimpleInstallFunction(isolate_, proto, "run", Builtin::kArrayRun, 0, false);
// ↑proto ↑名字 ↑对应的实现
  • proto = Array.prototype(数组的原型)
  • "run" = JS 里的方法名
  • Builtin::kArrayRun = 背后的实现

翻译:「在 Array.prototype 上安装一个叫 run 的方法,实现是 ArrayRun」。这就是 arr.run() 生效的地方。

为什么所有数组都能 .run()?—— 原型链

只在 Array.prototype 上装了一次,但每个数组都「继承」自 Array.prototype。你写 [1.5,2.5].run(),数组自己身上没有 run,JS 就顺着原型链往上找,在 Array.prototype 上找到了,于是能用。push/pop/map 全是这个机制——出题人就是照着 push 依葫芦画瓢加了一行 run

完整调用链

1
2
3
4
5
6
[1.5, 2.5].run()
↓ 数组自身找不到 run,沿原型链往上
↓ Array.prototype.run ← ③ SimpleInstallFunction 装的
↓ 绑定到 Builtin::kArrayRun ← ② CPP(ArrayRun) 注册的
↓ 执行 BUILTIN(ArrayRun){...} ← ① 函数实现
↓ 把小数字节当机器码跑

读 patch 的通用技巧

看到 BUILTIN(X),就去 patch 里搜 kX 或小写名字,准能找到 SimpleInstallFunction(...) 那行,它告诉你:挂在哪个对象上、JS 里叫什么名——这样就知道 exploit 该怎么触发它。


六、读 V8 源码的「黑话」速查

V8 的 C++ 满屏都是这些词,认识了 patch 就不吓人了。读 patch 时可以直接忽略它们的细节,只抓逻辑。

黑话 大白话
isolate 一个独立的 V8 实例(一份堆、一套全局)。几乎每个函数都带它,当背景噪音忽略即可
Handle<X> 指向堆对象的「安全指针」。V8 有 GC(垃圾回收)会移动对象,Handle 保证移动后还能找到它。读 patch 时把 Handle<JSArray> 直接理解成「一个数组」
HandleScope 管理上面那些 Handle 生命周期的作用域,函数开头常见 HandleScope scope(isolate);忽略
Tagged<X> 前面讲的「带标签的值」(最低位区分整数/指针)。Tagged<Object> ≈「一个 JS 值」
Cast<X>(obj) 类型转换,「把 obj 当作 X 来用」。Cast<JSArray>(receiver) =「把接收者当数组」
receiver 你调方法时的那个对象。arr.run()arr 就是 receiver
factory 用来「造」新对象的工具(造字符串、造数字等)
THROW_NEW_ERROR... 抛出一个 JS 异常(相当于报错 return)
elements 数组真正存数据的那块内部存储(见第八节)

读 patch 心法:先把这些黑话当透明的,盯住 if(约束)、赋值/循环/调用(效果)。逻辑骨架抓住了,黑话不影响理解。


七、小端序(little-endian)

你在 struct.unpack('<d', ...) 里写的那个 < 就是「小端序」。

小端 = 低位字节放在低地址(前面)。 x86/x64 全是小端。

例子:数字 0x4040e0 存成 8 字节,字节顺序是反的

1
2
数字:    0x00000000004040e0
内存里: e0 40 40 00 00 00 00 00 ← 低位 e0 在最前

所以你之前看到 struct.pack('<Q', 0x4040e0) 出来是 e040400000000000,不是 0000...4040e0——这不是 bug,是小端序的正常表现。

这也是为什么 shellcode 里 movabs rax, "/bin//sh" 要把字符串字节按特定顺序排——内存里是小端的。pwntools 的 p64/u64 默认就是小端,所以你入门 pwn 时没怎么操心过,但 V8 里手动拼字节时要记得。


八、Elements Kind 再深入(最实用,避免卡壳)

第一关栽在这里的人最多:「为什么 [1,2,3].run() 报错,[1.1,2.2].run() 才行?」

数组内部其实是两层

1
2
JSArray(数组对象本体,存长度等)
└─ elements(真正存数据的那块内存) ← patch 里的 array->elements()

elements 根据装的东西不同,有不同「柜子」:

Elements Kind 什么时候 elements 里存什么
PACKED_SMI_ELEMENTS 全是小整数 [1,2,3] 存整数
PACKED_DOUBLE_ELEMENTS 全是小数 [1.1,2.2] 裸的 8 字节 double(我们要的)
PACKED_ELEMENTS 混了对象/字符串 [1,{},"a"] 存 tagged 指针
HOLEY_* 有空洞 [1,,3] 同上但带空洞标记

规律:从「全整数」→「全小数」→「啥都有」,柜子越来越通用,但也越来越「不裸」。我们偏偏需要最裸的 PACKED_DOUBLE,因为只有它把 8 字节原样存着。

为什么 [1,2,3] 不行

1,2,3 是整数 → 数组是 PACKED_SMI_ELEMENTS,过不了 patch 的 if (kind != PACKED_DOUBLE_ELEMENTS) 检查。

怎么保证是 PACKED_DOUBLE(实战要点)

  • 数组里全部写成小数[1.1, 2.2, 3.3]
  • 别混整数:[1, 2.2] 可能变别的 kind ✗
  • 别混对象/字符串:[1.1, "x"] → 变 PACKED_ELEMENTS
  • 别留空洞:[1.1, , 3.3] → 变 HOLEY

好消息:我们的 shellcode 翻译出来的小数都是 8.5e-315 这种奇形怪状的浮点,天生全是 double,所以数组自动是 PACKED_DOUBLE_ELEMENTS,不用特意处理。

Kind 只会「升级」不会「降级」(埋个伏笔)

往 double 数组里塞个对象,它会「升级」成 PACKED_ELEMENTS,但反过来不会自动降回去。这个「kind 转换」机制是后面好几关类型混淆漏洞的温床,到时候细讲。


九、mmap 参数解码

patch 里:mmap(NULL, 4096, 7, 0x22, -1, 0)

参数 含义
addr NULL 让内核自己挑地址
length 4096 申请 1 页(4KB)
prot 7 权限:4(可执行) + 2(可写) + 1(可读) = RWX
flags 0x22 MAP_PRIVATE(0x2) + MAP_ANONYMOUS(0x20):私有匿名内存
fd -1 匿名映射不关联文件
offset 0

为什么 RWX 危险:正常程序里「可写」和「可执行」是分开的(W^X 原则)——代码段可执行不可写,数据段可写不可执行,防止攻击者写入并执行 shellcode。这里 prot=7 同时给了写+执行,等于敞开大门:往里写字节就能直接当代码跑。这是这关「白送执行」的物理基础。


十、execve shellcode 在干嘛(按寄存器看)

我们的 shellcode 等价于这行 C:execve("/challenge/catflag", argv, NULL)
Linux x64 系统调用约定:rax=调用号,参数依次放 rdi, rsi, rdx

1
2
3
4
5
6
7
8
9
10
11
12
13
; 1. 把字符串 "/challenge/catflag" 拼到栈上,rdi 指向它
movabs rax, <8字节路径> ; push rax (重复几次,逆序push拼出完整字符串)
mov rdi, rsp ; rdi = 路径地址(execve 第1个参数)

; 2. 准备 argv 和 envp
xor edx, edx ; rdx = 0 → envp = NULL(第3个参数)
push rdx ; argv[1] = NULL(数组结尾)
push rdi ; argv[0] = 路径
mov rsi, rsp ; rsi = argv(第2个参数)

; 3. 发起系统调用
push 0x3b ; pop rax ; rax = 59 = execve 的调用号
syscall ; 进内核执行 execve

跟你入门 pwn 写的 execve("/bin/sh") 一模一样,只是路径换成 /challenge/catflag、并且老老实实传了 argv(有些程序读 argv[0],传 NULL 会崩,所以稳妥起见传上)。调用号 0x3b=59 是 x64 的 execve,记一下常用号:read=0, write=1, open=2, execve=59


十一、下一关会用到的调试:%DebugPrint

第一关白送执行,不用调试。但 level 2+ 是真漏洞,你需要看对象在内存里长什么样。V8 自带神器:

1
d8 --allow-natives-syntax exp.js

加了 --allow-natives-syntax 后,JS 里能用 % 开头的内部函数:

1
2
var a = [1.1, 2.2, 3.3];
%DebugPrint(a); // 打印 a 的地址、Map、elements kind、各字段

它会输出这个对象的:内存地址、Map 指针、elements kind(验证是不是 PACKED_DOUBLE)、elements 存储地址等。这是 V8 利用的「X 光机」,做 addrof/fakeobj、确认偏移时离不开它。配合 gdb + pwndbg 一起用。

注意:题目环境的 run 脚本跑 d8 时一般不带 --allow-natives-syntax(防作弊)。所以 %DebugPrint 是你本地调试用的——按 REVISION 自己编一个 d8,本地随便加这个 flag 分析,分析清楚了再把不含 % 的纯 exploit 拿到题目环境跑。


十二、完整 patch 全文逐行注释

下面是 Level 1 的完整 patch,每一段我都加了详细注释。结合前面各节,对着看一遍就全通了。

读法提醒:+ 开头 = 新增行, (空格) 开头 = 原有上下文。注释里我用 标出重点。

文件 1/5:src/builtins/builtins-array.cc(漏洞本体)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@@ -24,6 +24,8 @@
#include "src/objects/prototype.h"
#include "src/objects/smi.h"

+extern "C" void *mmap(void *, unsigned long, int, int, int, int);
+ // ← 声明 mmap 这个 C 库函数,好在下面调用它申请内存。
+ // extern "C" 表示按 C 的方式链接(V8 是 C++,要这样借用 C 函数)。

namespace v8 {
namespace internal {

@@ -407,6 +409,47 @@ BUILTIN(ArrayPush) {
return *isolate->factory()->NewNumberFromUint((new_length));
}
// ↑ 这是 V8 原有的 ArrayPush,结尾在这。
// 出题人紧跟其后插入了下面整个新函数。
+BUILTIN(ArrayRun) { // ← 攻击入口:新内置函数 ArrayRun(对应 arr.run())
+ HandleScope scope(isolate); // GC 句柄作用域,样板代码,忽略
+ Factory *factory = isolate->factory(); // 造对象的工具(这里只用来造报错字符串)
+ Handle<Object> receiver = args.receiver();
+ // ← receiver = 调用 .run() 的那个对象。arr.run() 里 receiver 就是 arr
+
+ if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+ // ← 约束①:receiver 必须是「普通数组」且元素存储简单。
+ // 不是数组 / 元素被搞复杂了,就抛错。
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Nope"))); // 抛 JS 异常 "Nope"
+ }
+
+ Handle<JSArray> array = Cast<JSArray>(receiver); // 把 receiver 正式当数组用
+ ElementsKind kind = array->GetElementsKind(); // 取这个数组的「柜子类型」(见笔记第八节)
+
+ if (kind != PACKED_DOUBLE_ELEMENTS) {
+ // ← 约束②:必须是「纯小数数组」(PACKED_DOUBLE_ELEMENTS)。
+ // 这就是为什么 [1,2,3].run() 不行、[1.1,2.2].run() 才行。
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Need array of double numbers")));
+ }
+
+ uint32_t length = static_cast<uint32_t>(Object::NumberValue(array->length()));
+ // ← 取数组长度(元素个数)
+ if (sizeof(double) * (uint64_t)length > 4096) {
+ // ← 约束③:长度×8字节 不能超过 4096。即最多 512 个 double。
+ // (sizeof(double)=8,8×512=4096)。shellcode 空间够用。
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("array too long")));
+ }
+
+ // mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ double *mem = (double *)mmap(NULL, 4096, 7, 0x22, -1, 0);
+ // ← 效果①:申请一块 4KB 内存,权限 7 = 可读+可写+可执行(RWX)。
+ // RWX 是关键:往里写字节就能当代码执行(违反 W^X,极危险,见笔记第九节)。
+ if (mem == (double *)-1) { // mmap 失败返回 -1
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("mmap failed")));
+ }
+
+ Handle<FixedDoubleArray> elements(Cast<FixedDoubleArray>(array->elements()), isolate);
+ // ← elements = 数组真正存数据的那块内部存储(FixedDoubleArray = 裸 double 数组)
+ FOR_WITH_HANDLE_SCOPE(isolate, uint32_t, i = 0, i, i < length, i++, {
+ // ← 这是 V8 版的 for 循环:i 从 0 到 length-1
+ double x = elements->get_scalar(i); // 取第 i 个 double 的「原始 8 字节」
+ mem[i] = x; // 效果②:原样拷进刚才那块 RWX 内存
+ });
+ // 循环跑完,整段 shellcode 字节就躺在 RWX 内存里了。
+
+ ((void (*)())mem)();
+ // ← 效果③:把 mem 当成「一个无参函数指针」,然后 () 调用它!
+ // 也就是跳到这块内存去执行 —— 你的 shellcode 在此刻运行。
+ return 0;
+}
+
namespace {

V8_WARN_UNUSED_RESULT Tagged<Object> GenericArrayPop(Isolate* isolate,

这一段就是全题核心:三个 if 约束(是数组 / 是小数数组 / ≤512 个)→ 申请 RWX 内存 → 拷入字节 → 当机器码执行。

文件 2/5:src/builtins/builtins-definitions.h(注册)

1
2
3
4
5
6
7
8
@@ -421,6 +421,7 @@ namespace internal {
TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel)
/* ES6 #sec-array.prototype.push */
CPP(ArrayPush) // ← V8 原有:把 ArrayPush 登记进内置总表
+ CPP(ArrayRun) // ← 新增:把 ArrayRun 登记进总表,生成 Builtin::kArrayRun
TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel)
/* ES6 #sec-array.prototype.shift */
CPP(ArrayShift)

CPP(...) = 声明这是个 C++ 写的内置(对应笔记第五节「接线」的第②步)。注册后 V8 内部才有 Builtin::kArrayRun 这个编号指向我们的函数。

文件 3/5:src/compiler/typer.cc(让 JIT 编译器认识它)

1
2
3
4
5
6
7
8
9
@@ -1937,6 +1937,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
return Type::Receiver();
case Builtin::kArrayUnshift:
return t->cache_->kPositiveSafeInteger;
+ case Builtin::kArrayRun: // ← 告诉类型推断器:ArrayRun 的返回值类型
+ return Type::Receiver(); // 当成「返回一个对象」处理

// ArrayBuffer functions.
case Builtin::kArrayBufferIsView:

这块是给 V8 的优化编译器(TurboFan)打个招呼:「调用 run() 时返回类型算 Receiver」。不写的话,代码被 JIT 优化时可能报错。对利用没影响,知道它是「让新函数能被优化编译」的样板即可。

文件 4/5:src/d8/d8.cc(堵掉作弊捷径)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@@ -3364,7 +3364,7 @@ ...
Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
- global_template->Set(Symbol::GetToStringTag(isolate),
+/* global_template->Set(Symbol::GetToStringTag(isolate), // ← 用 /* 注释掉一大段
String::NewFromUtf8Literal(isolate, "global"));
...
global_template->Set(isolate, "load",
- FunctionTemplate::New(isolate, ExecuteFile));
+ FunctionTemplate::New(isolate, ExecuteFile));*/ // ← 注释到这里结束
global_template->Set(isolate, "setTimeout", ...);
...
- if (!options.omit_quit) {
+/* if (!options.omit_quit) { // ← 又注释掉一段
global_template->Set(isolate, "quit", ...);
}
...
- }
+ }*/

出题人把 d8 自带的 load(加载执行别的文件)、quit(退出)、versiontestRunner 等便捷函数用 /* ... */ 注释掉了——防止你用这些现成工具走捷径。和漏洞无关,纯粹是收紧环境。

文件 5/5:src/init/bootstrapper.cc(挂到 JS,最关键的接线)

1
2
3
4
5
6
7
8
9
10
11
12
@@ -2533,6 +2533,8 @@ void Genesis::InitializeGlobal(...) {

SimpleInstallFunction(isolate_, proto, "at", Builtin::kArrayPrototypeAt, 1,
true); // ← V8 原有:装 Array.prototype.at
+ SimpleInstallFunction(isolate_, proto, "run",
+ Builtin::kArrayRun, 0, false);
+ // ← 新增(接线第③步,最关键):
+ // 在 Array.prototype 上装一个名叫 "run" 的方法,实现是 kArrayRun。
+ // 装完之后,所有数组都能 .run() —— 这就是 arr.run() 生效的地方。
SimpleInstallFunction(isolate_, proto, "concat",
Builtin::kArrayPrototypeConcat, 1, false);
SimpleInstallFunction(isolate_, proto, "copyWithin",

proto 这里就是 Array.prototype。这一行把名字 "run" 绑定到 Builtin::kArrayRun,于是 JS 里 arr.run() 顺着原型链就能找到并调用我们的 BUILTIN(详见笔记第五节)。

五个文件串起来

1
2
3
4
5
6
7
8
文件5 bootstrapper.cc  →  把 "run" 挂到 Array.prototype     (JS 能叫到)

文件2 definitions.h → 注册 Builtin::kArrayRun (V8 认识它)

文件1 builtins-array.cc → BUILTIN(ArrayRun) 的实现 (真正干活:RWX + 执行)

文件3 typer.cc → 让 JIT 编译器认识它(样板,可忽略)
文件4 d8.cc → 堵作弊捷径(与漏洞无关)

真正要盯的是文件 1(漏洞本体)和文件 5(怎么触发);文件 2 是注册,文件 3、4 是无关紧要的配套。