V8 入门概念笔记 —— patch / BUILTIN / 浮点编码 / pwntools
pwn.college v8 exploitation level1做题过程中,学习了很多v8入门概念:怎么读 patch、BUILTIN 是什么、为什么 shellcode 能伪装成小数、pwntools 的 p64 和 struct 的关系。便作此笔记记录。
一、怎么读 patch
patch 的格式(30 秒学会)
1 | -这行被删掉了 |
@@ -24,6 +24,8 @@ 这种是行号信息,不用管。一份 patch 分成若干「块」,每块对应一个被改的文件。
读 patch 只需要抓三样东西
每拿到一道 V8 题的 patch,脑子里就问三个问题:
| 问题 | 关键词 | 含义 |
|---|---|---|
| 1. 攻击面:新加了什么 JS 函数? | BUILTIN( |
这是新加/被改的底层函数,漏洞入口 |
| 2. 约束:用它要满足什么条件? | if (...) |
括号里的条件就是 exploit 必须满足的限制 |
| 3. 效果:调用后发生什么? | mmap / memcpy / cast / 调用 |
告诉你利用后能干嘛 |
实战套路
1 | 1. 打开 patch |
完全不需要懂 C++ 语法,只要认出 if(判断)、括号里的条件(约束)、大括号里的操作(效果)。
Level 1 的例子
1 | BUILTIN(ArrayRun) { // 新函数 = 攻击入口 |
流程图:
1 | JS Array |
结论: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 | [1, 2, 3].push(4) // JS 表面写法 |
注意:V8 不是逐句解释 JS,而是直接跳到预先写好的 C++ 机器码去跑,所以快。
为什么要有 BUILTIN
push 这种函数每秒被调用亿万次,用纯 JS 写太慢,所以 V8 工程师用 C++ 手写并编译成机器码。BUILTIN = 「用 C++ 重写的 JS 标准库函数」的标志。
为什么是考点
CTF 出题人打 patch 最爱干两件事:
- 新写一个 BUILTIN(如
BUILTIN(ArrayRun))→ 塞后门; - 改现有 BUILTIN → 比如改
Array.prototype.sort留下越界 bug。
所以读 patch 看到 BUILTIN( 就警觉:这是能从 JS 调到的 C++ 函数被动了,就是攻击入口。
三、为什么 shellcode 能伪装成小数
核心思想:同一串字节,看你怎么「解释」
计算机里一切都是字节。同样 8 个字节:
- 当 CPU 指令看 → 是一条机器码;
- 当 IEEE-754 公式看 → 是一个小数;
- 当 UTF-8 看 → 是一段文字。
比喻:同一个二维码,美团扫出外卖链接,微信扫出名片。图没变,解释方式不同。
1 | 8 字节:48 b8 2f 63 68 61 6c 6c |
一个 double 占 8 字节
小数(double)按 IEEE-754 标准存,固定 8 字节。所以:任意 8 字节都能当成一个小数,反过来一个小数也对应固定的 8 字节。
实操:用 Python 互转
1 | import struct |
那些奇怪的数字(如 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 | p64(x) == struct.pack('<Q', x) # 数字 → 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 |
关键:Q 和 d 都是 8 字节,但 Q 把字节看成整数,d 看成小数。底层字节可以一样,解释方式不同。
入门 pwn vs V8 题的区别
1 | # 入门 pwn:处理地址/整数,把数字写成字节喂给程序 |
- 整数/地址场景 → 用
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 | SimpleInstallFunction(isolate_, proto, "run", Builtin::kArrayRun, 0, false); |
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 | [1.5, 2.5].run() |
读 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 | 数字: 0x00000000004040e0 |
所以你之前看到 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 | JSArray(数组对象本体,存长度等) |
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 | ; 1. 把字符串 "/challenge/catflag" 拼到栈上,rdi 指向它 |
跟你入门 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 | var a = [1.1, 2.2, 3.3]; |
它会输出这个对象的:内存地址、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 | @@ -24,6 +24,8 @@ |
这一段就是全题核心:三个 if 约束(是数组 / 是小数数组 / ≤512 个)→ 申请 RWX 内存 → 拷入字节 → 当机器码执行。
文件 2/5:src/builtins/builtins-definitions.h(注册)
1 | @@ -421,6 +421,7 @@ namespace internal { |
CPP(...)= 声明这是个 C++ 写的内置(对应笔记第五节「接线」的第②步)。注册后 V8 内部才有Builtin::kArrayRun这个编号指向我们的函数。
文件 3/5:src/compiler/typer.cc(让 JIT 编译器认识它)
1 | @@ -1937,6 +1937,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) { |
这块是给 V8 的优化编译器(TurboFan)打个招呼:「调用
run()时返回类型算 Receiver」。不写的话,代码被 JIT 优化时可能报错。对利用没影响,知道它是「让新函数能被优化编译」的样板即可。
文件 4/5:src/d8/d8.cc(堵掉作弊捷径)
1 | @@ -3364,7 +3364,7 @@ ... |
出题人把 d8 自带的
load(加载执行别的文件)、quit(退出)、version、testRunner等便捷函数用/* ... */注释掉了——防止你用这些现成工具走捷径。和漏洞无关,纯粹是收紧环境。
文件 5/5:src/init/bootstrapper.cc(挂到 JS,最关键的接线)
1 | @@ -2533,6 +2533,8 @@ void Genesis::InitializeGlobal(...) { |
proto这里就是Array.prototype。这一行把名字"run"绑定到Builtin::kArrayRun,于是 JS 里arr.run()顺着原型链就能找到并调用我们的 BUILTIN(详见笔记第五节)。
五个文件串起来
1 | 文件5 bootstrapper.cc → 把 "run" 挂到 Array.prototype (JS 能叫到) |
真正要盯的是文件 1(漏洞本体)和文件 5(怎么触发);文件 2 是注册,文件 3、4 是无关紧要的配套。