
wasm2c sandbox escape. Недоверенный WebAssembly-модуль совершает побег из сгенерированной C-песочницы и выполняет произвольную shell-команду на хосте.
Читайте блог: trustsig.eu/blog
Функция wasm_rt_allocate_funcref_table() в wasm2c/wasm-rt-impl-tableops.inc устанавливает 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
Образ клонирует wabt по тегу 1.0.41 из апстрима, собирает 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 завершается успешно, и баг никогда не срабатывает; сборки macOS для arm64 всегда позиционно-независимы, а в 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). calloc на 68 ГБ завершается неудачей, data равен NULL, size остаётся 2147483648, и индексы таблицы становятся абсолютными адресами.table.get по адресу got_slot/32 читает GOT эмбеддера через сломанную таблицу, а table.set сохраняет результат в собственные глобальные переменные модуля, где wasm-код может прочитать его как целое число. Так утекает адрес malloc из libc, а system находится на фиксированном расстоянии от него в libc целевой системы.wasm_rt_funcref_t в четырёх последовательных глобальных переменных: func_type, указывающий на копию хеша типа из места вызова (func_types_eq_slowpath сравнивает его с помощью , поэтому контролируемые гостем байты проходят проверку), = и = строка команды, также хранящаяся в глобальных переменных.Единственный факт раскладки, зашитый в модуль, — это адрес экземпляра модуля; он является глобальным и поэтому фиксирован в эмбеддере без PIE. ASLR остаётся включённым; адрес libc утекает во время выполнения.
Выделение памяти для таблицы должно завершиться неудачей. PoC использует ulimit -v 1000000 — ограничение адресного пространства, которое хост, выполняющий недоверенный код, как правило, устанавливает. Выделение также не удаётся на 32-битных хостах, где оно вообще не может быть удовлетворено, при vm.overcommit_memory=2 или при достаточном давлении на память.
На стандартном 64-битном Linux с эвристикой overcommit по умолчанию выделение успешно и память никогда не затрагивается, поэтому при обычном тестировании баг не проявляется.
Все выпуски, в которых есть таблицы 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 генерирует гостевой модуль, собирает эмбеддер, определяет три константы из собранного бинарника и libc целевой системы с помощью nm и readelf, и запускает его. Создаваемый им эмбеддер не содержит вспомогательного кода для эксплойта.memcmpfuncsystemmodule_instancecall_indirect по адресу globals_addr/32. wasm2c генерирует ((t)entry.func)(entry.module_instance, ...), поэтому вызывается system(command).