
Educational proof-of-concept for CVE-2024-4947, a V8 Maglev type confusion, demonstrating a full chain from trigger to arbitrary code execution on a non-sandboxed d8 build.
A complete, self-contained exploitation chain for CVE-2024-4947 (V8 Maglev type
confusion), from the initial type-confusion trigger all the way to arbitrary code
execution. The PoC runs against a non-sandboxed d8 build and prints
CVE-2024-4947-PWNED to stdout as proof of code execution.
⚠️ This is a research / educational PoC for a patched, public vulnerability. It targets a development build of the V8 shell (
d8 --allow-natives-syntax) — not a real Chrome browser. See Disclaimer.
$ ./v8-build-nosandbox.sh # build the vulnerable d8 (WSL2 Ubuntu, ~10-20 min)
$ d8 --allow-natives-syntax --module exploit/exploit_rce.mjs
[engine] dblData0=0004f470 class=0
[inst] addr=0x001dc274 trusted_data(tagged)=0x00202bd5 td=0x00202bd4
[jt] jump_table_start = 0x00000967426cd000 (external code space)
[bridge] memory0_start -> jt, memory0_size -> huge (r/w reach jt+off)
[slot0] before: e9 3b 08 00 00
[shell] wrote 52 bytes @ jt+0x0100: VERIFIED
CVE-2024-4947-PWNED
$ echo $? # 0
The 52-byte shellcode is write(1, "CVE-2024-4947-PWNED\n", 20); exit_group(0).
CVE-2024-4947 is a type confusion in the V8 Maglev JIT compiler, fixed in Chrome
125.0.6422.60 (commit b3c01ac1e60a). It was exploited in the wild by the Lazarus
APT group. When Maglev compiles a store to a module namespace object
(JSModuleNamespace), it uses an incorrect AccessInfo — the store is compiled as a
plain mov [[obj + 4], rax] that writes a controlled value into the map field of an
adjacent object instead of going through the proper property-store path.
The exploitation proceeds by:
NAME_DICTIONARY_TYPE (0xB2) map, which
changes where V8 stores the object's hash.new WeakRef(...) so the hash write lands in an adjacent object's length
slot → out-of-bounds access.From there we get the classic V8 toolkit: addrOf/fakeObj, then arbitrary in-cage
read/write.
CVE-2024-4947 trigger (fake NAME_DICTIONARY map + WeakRef hash write)
└─► OOB write ─► corrupt doubleArray length
└─► in-cage 4/8-byte arbitrary R/W (the "engine")
└─► overwrite WasmTrustedInstanceData.memory0_start (+0x18)
& memory0_size (+0x20) → huge
└─► wasm load8_u / store8 = clean 64-bit R/W bridge
(no software bounds check in compiled code)
└─► read jump_table_start (+0x38) — external code space
└─► jump table region is RWX (this build)
└─► write shellcode into the slack (jt+0x100)
└─► repoint func0's `e9 rel32` slot at it
└─► call func0 → shellcode → RCE
| # | Stage | Detail |
|---|---|---|
| 1 | Trigger | opt() writes a fake map via the confused store; new WeakRef(m) OOBs corruptArray.length. |
| 2 | Engine | addrOf/fakeObj; corrupt doubleArray's length → arbitrary 4-byte R/W at any 4-aligned caged address. |
| 3 | 64-bit bridge | WasmTrustedInstanceData.memory0_start (+0x18) is a raw 64-bit pointer used by compiled wasm i32.load8_u/i32.store8 with no software bounds check (out-of-range hits a guard page → SIGSEGV → wasm trap). Redirect it to any address + set memory0_size (+0x20) huge → arbitrary 64-bit R/W. |
| 4 | Find code space | jump_table_start (+0x38) is a raw 64-bit pointer into the external code space (outside the 4 GB cage). |
| 5 | Write shellcode | On this build the jump-table region is RWX (V8 patches entries at runtime): write the 52-byte shellcode into the slack at jt+0x100. |
| 6 | Repoint slot | func0's jump-table slot is a 5-byte e9 <rel32> (target = slot + 5 + rel32). Set rel32 → jt+0x100. |
| 7 | Trigger dispatch | inst.exports.r(0) → JSToWasmWrapper dispatches via slot 0 → shellcode executes. |
Most public CVE-2024-4947 PoCs target the sandboxed d8 or a real Chrome renderer
(v8_enable_sandbox=true) and stop at the type-confusion / OOB primitive. This PoC
targets a no-sandbox d8 and carries the chain all the way to code execution. The
differences that matter:
| Dimension | Common public-PoC approach | This PoC |
|---|---|---|
| Target build | sandboxed d8 / Chrome renderer | v8_enable_sandbox=false d8 — trusted pointers are direct, external code space is on |
| 64-bit R/W bridge | overwrite JSTypedArray.external_pointer | That path crashes when reading the code range (verified empirically); instead overwrite memory0_start and use raw wasm load/store |
| Final code-exec route | code space is W^X → JIT-spray + indirect redirect | jump-table region is RWX → direct shellcode write + e9 rel32 slot patch |
| Jump-table slot format | docs commonly assume movabs rax, imm64; jmp rax (12 bytes) | measured: 5-byte e9 <rel32>, target = slot + 5 + rel32 |
| Field offsets | sandboxed-build layout | no-sandbox WasmTrustedInstanceData: jump_table_start@+0x38, memory0_start@+0x18, memory0_size@+0x20; WasmInstanceObject.trusted_data@+0x0c |
| Clean exit | — | d8 is multi-threaded: must use exit_group (231), not exit (60), or the process hangs |
See docs/walkthrough.md for the full technical write-up, including the empirical findings behind each row.
.
├── exploit/
│ ├── Module.mjs # module namespace object corrupted by the trigger
│ ├── exploit_rce.mjs # the full chain (trigger → arbitrary R/W → RCE)
│ └── shellcode.S # assembly source for the 52-byte payload
├── build/
│ └── v8-build-nosandbox.sh # build the vulnerable no-sandbox d8 from V8 source
└── docs/
└── walkthrough.md # deep dive: bridge mechanics, layouts, gotchas
The exploit imports Module.mjs (the vulnerable module namespace object) from its own
directory, so keep the two files together (or adjust the import path).
git, and ~10 GB free diskdepot_tools (git clone https://chromium.googlesource.com/chromium/tools/depot_tools)# 1. fetch V8 at the vulnerable tag (12.4.254.16 is the pre-fix release)
export PATH="$HOME/depot_tools:$PATH"
cd ~/v8w && fetch v8 && cd v8
git checkout 12.4.254.16 # or the commit just before b3c01ac1e60a
# 2. first build a normal release d8 (needed to seed args.gn), then:
./v8-build-nosandbox.sh # copies args.gn and appends v8_enable_sandbox = false
v8-build-nosandbox.sh uses ninja -C out.gn/x64.release_nosandbox -j6 d8.
out.gn/x64.release_nosandbox/d8 --allow-natives-syntax --module exploit/exploit_rce.mjs
Expected output ends with:
CVE-2024-4947-PWNED
and the process exits with status 0.
--allow-natives-syntaxis required for the%PrepareFunctionForOptimization/%OptimizeMaglevOnNextCallruntime calls. This is why the PoC cannot run in a real browser — it is a d8 shell proof-of-concept by design.
The genuinely reusable (and non-obvious) pieces for V8 exploitation research:
JSTypedArray.external_pointer
redirection fails on a target region, redirect WasmTrustedInstanceData.memory0_start
instead. Compiled wasm byte loads/stores carry no software bounds check; the signal
trap handler converts only guard-page faults, so mapped-but-non-writable regions crash
rather than trap.try/catch; a wasm "out of bounds" trap means a
guard page (unmapped), a real SIGSEGV means mapped-but-RX.e9 rel32 slot format — 5-byte relative jump, not the movabs form assumed by
many write-ups. Verified against --print-wasm-code disassembly.This repository is provided for educational and defensive-security research only. It
demonstrates exploitation of a patched vulnerability against a development-only
V8 build. It is not a weapon against modern Chrome, does not bypass the V8 sandbox,
and requires the non-default --allow-natives-syntax flag. The author is not responsible
for any misuse.
The trigger primitive (fake NAME_DICTIONARY_TYPE map + WeakRef hash write) follows
the public analyses of the in-the-wild Lazarus exploit published after the patch —
including Google's and Exodus Intelligence's CVE-2024-4947 write-ups. The later stages
(jump-table probing, bridge, slot patching) were derived empirically against this specific
build during this work. The JIT-spray fallback concept draws on public V8 exploit
techniques (e.g. the CVE-2024-5830 make_array pattern).
MIT — see LICENSE.