Back

/ 4 min read

Samsung TV V8 RCE #5 — DEP or shellcode? Verdict: ROP

I’ve already got arbitrary R/W (01), an ASLR-proof elf_base (04), and 395k gadgets. Before I start building the execution stage, there’s one question that decides the whole strategy, and it’s worth answering it for sure instead of in a rush:

Does the browser process have any RWX region (writable and executable)?

If the answer is yes, the path is the simplest in the world: you write ARM32 shellcode into that region and you jump. Zero ROP. If it’s no (W^X / DEP in force), then it’s ROP: chain gadgets to call mprotect(page, len, RWX), turn a controlled buffer executable, and only then jump to the shellcode.

Spoiler: for Chromium 120 the answer is there’s no RWX. And the nice part is I can claim that without even risking a crash on the TV — the binary and V8’s history tell me so.

Level 1 — what the binary already says (static, zero risk)

The dump of libchromium-impl.so (04) carries the program headers and the .dynamic flags. That alone pins down the prior with certainty:

SignalValueImplies
PT_GNU_STACKRW- (no X bit)Stack NOT executable (NX requested by the toolchain)
PT_GNU_RELROpresent, R--Full RELRO: the GOT becomes read-only after load
DT_FLAGS0x8 = DF_BIND_NOWBIND_NOW: there’s no lazy-binding to abuse
DT_FLAGS_10x1 = DF_1_NOWreinforces full RELRO
SegmentsR-- / R-X / RW- separatedstrict W^X in the library (nothing RWX in the .so)

The partial conclusion is safe: the binary was compiled for an environment with DEP/NX. The classic “return to the stack and execute shellcode there” is ruled out — the stack is RW-. The library doesn’t have a single RWX byte. But that’s the library’s toolchain; it proves nothing about V8’s JIT code-space in the live process. That was the one chance we had to skip ROP. And to close it I don’t need to touch the TV at all.

Level 2 — V8’s version cutoff: code-space R-X, not RWX

For years V8 kept its code-space (where the JIT code of Liftoff/TurboFan/wasm lives) in RWX: it was faster to patch code without an mprotect for every emission. That was exactly the crack that an infoleak + arbitrary-write turned into “write shellcode into the code-space and jump,” no ROP.

That model was removed. Starting with V8’s code-memory write-protection work (the old --write-protect-code-memory, then the migration to a permanently R-X code-space with a separate RW- alias for writing, and where supported, thread-isolation via PKU/MPK), the code-space stopped being RWX in production. The code pages stay R-X at rest and only flip to RW- transiently during code generation — there’s no permanent RWX window that can be raced reliably.

ARM32 doesn’t have PKU, so the build uses the mprotect-based toggle (RW↔RX), but the resting state is the same: R-X. Any write of mine to a JIT code page lands on a read-only-execute page → SIGSEGV. And Chromium 120.0.6099.5 is way past that cutoff, so measuring at runtime wouldn’t add any information: the result is already determined. Straight to ROP.

The decision tree (resolved)

RWX in the live process?
┌────┴─────────────────────────────┐
│ static .so: strict W^X, NX │
│ V8 ≥ W^X cutoff: code-space R-X │
└────┬─────────────────────────────┘
RWX = NO (determined, without measuring on the TV)
ROP: leak mprotect (GOT) → mprotect(buf, RWX) → jump → shellcode
ARM32 backconnect → /bin/sh against 192.168.100.80 (validation / report)

The chosen path is the classic ARM32 chain, and it does not depend on the stack being executable: leak mprotect from the GOT (already populated by BIND_NOW), pivot + ROP chain that builds mprotect(page, len, RWX) over a controlled buffer, and jump to the now-executable buffer with the shellcode. The fine detail of how I get the CPU to start executing my gadgets — the hijack — and the full exploitation chain go in entry 06, the finale.