Skip to main content

Turboflan picoCTF 2021 Solution

A browser engine exploitation challenge built around a custom-patched JavaScript runtime. Identify and leverage the introduced flaw.

Published: April 2, 2026Updated: August 13, 2026

Description

A very fast program. Find the vulnerability and exploit it to read the flag.

Download the V8 build and the challenge patch/diff.

bash
wget <url>/d8
bash
wget <url>/turboflan.diff
bash
chmod +x d8

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Read the V8 patch and identify the missing deopt guard
    Observation
    The challenge ships turboflan.diff alongside the d8 binary, so a code change introduced the vulnerability. Read the patch and find the removed DeoptimizeIfNot calls in LowerCheckMaps() to see exactly which protection was stripped.
    Open the diff and find the change inside src/compiler/effect-control-linearizer.cc, specifically the LowerCheckMaps() function. The patch removes calls to DeoptimizeIfNot(DeoptimizeReason::kWrongMap, ...). Normally, when TurboFan's JIT encounters an object whose map (hidden class) differs from what it observed during profiling, it bails out and re-interprets the code in the interpreter (deoptimizes). With that guard removed, the JIT keeps running its specialized, optimized path even when the actual object type has changed, leading to type confusion.
    bash
    less turboflan.diff
    bash
    # Find the LowerCheckMaps function in effect-control-linearizer.cc
    bash
    # The removed lines look like:
    bash
    #   DeoptimizeIfNot(DeoptimizeReason::kWrongMap, ...);
    What didn't work first

    Tried: Search the diff for 'CheckMaps' instead of 'DeoptimizeIfNot' to find the removed guard.

    The diff does reference CheckMaps nodes, but those are high-level IR nodes still present in the graph. The runtime bailout itself is the DeoptimizeIfNot calls inside LowerCheckMaps(). Searching for the node name finds the right function but not the removed lines, so look specifically for what DeoptimizeIfNot lost.

    Tried: Look for the bug in the TurboFan typer (typer.cc or type-narrowing passes) rather than the effect-control linearizer.

    Typer bugs, integer range confusion and incorrect widening, are a different class entirely, where the compiler miscomputes a value's range and drops bounds checks. This diff removes a map-check guard in the linearizer, which is hidden-class type confusion: the typer still sees correct types, but the runtime guard enforcing them is gone. Auditing typer.cc turns up nothing relevant.

    Learn more

    V8 Turbofan, not C heap."Turboflan" is a pun on Turbofan, V8's optimizing JIT compiler for JavaScript. The vulnerability lives in JS-level reasoning, not in glibc heap structures.

    Why LowerCheckMaps matters: During JIT compilation, Turbofan inserts CheckMaps nodes to verify that an object's map (hidden class) matches the type the compiler assumed during optimization. The Effect Control Linearizer lowers these high-level nodes into concrete machine-level guards. When the DeoptimizeIfNot calls are removed from LowerCheckMaps(), those guards disappear and the compiled code blindly assumes the original map is still valid, even after the caller switches to a different object type.

    The type confusion this enables: By calling an optimized function first with a float64 array (so the JIT specializes for floats) and then with a pointer-compressed object array (which has different element size and layout), the missing map check lets the JIT treat object pointers as raw 64-bit floats. Reading an element from the object array via the float-specialized path returns raw pointer bits as a double, leaking heap addresses. Writing via the same confused path corrupts an adjacent field, giving an arbitrary-read/write primitive.

    See real-world bug patterns for adjacent JS sandbox-escape ideas.

  2. Step 2Trigger JIT compilation and force the bug
    Observation
    The missing guard exists only on the Turbofan-compiled path, not in the interpreter or the baseline tier, so the exploit fires only once the target function is optimized. A warmup loop followed by %OptimizeFunctionOnNextCall forces that tier-up before the type-confusing input arrives.
    Standard recipe: write a small attack function, force optimization via repeated calls (or %OptimizeFunctionOnNextCall in d8 with --allow-natives-syntax), then call once with an input the typer mispredicts.
    bash
    # Run d8 with native syntax to force optimization:
    ./d8 --allow-natives-syntax exploit.js
    bash
    # Sketch of a typer-confusion exploit (specifics depend on the diff):
    # function leak(x) { return arr[x]; }
    # for (let i = 0; i < 0x10000; i++) leak(0);
    # %OptimizeFunctionOnNextCall(leak);
    # leak(badValue);   // typer thinks in-range, runtime is out-of-range -> OOB read

    Expected output

    picoCTF{...}
    What didn't work first

    Tried: Call the attack function with the bad value immediately without warming it up first, skipping the loop of 0x10000 calls.

    V8 only JIT-compiles a function once it has been called enough times to gather profiling feedback. Without the warmup loop, the function stays in the interpreter or the Maglev baseline tier, both of which still carry valid map checks. The confusion only appears in Turbofan-compiled code where the guard was removed, so skipping the warmup means never triggering the bug.

    Tried: Use %OptimizeFunctionOnNextCall without passing --allow-natives-syntax to d8.

    Percent-prefixed intrinsics like %OptimizeFunctionOnNextCall sit behind the --allow-natives-syntax flag at engine level. Without it, V8 reads the percent identifier as a syntax error and aborts before running anything. The script does not fail quietly, it does not run at all, so pass the flag on the d8 command line.

    Learn more

    From OOB read to flag. A typical chain: corrupt a JSArray's elements pointer (or its length) to fabricate a fake array that addresses arbitrary memory; build addrOf/read/write primitives; locate %FunctionPrototype% code object; flip a writable but executable page; drop shellcode; call. readFile("flag.txt") in d8 is often available too, sidestepping shellcode entirely.

Flag

Reveal flag

picoCTF{Good_job!_Now_go_find_a_real_v8_cve!_...}

Turboflan is a V8 Turbofan JIT type-confusion challenge triggered by a missing deoptimization guard in LowerCheckMaps(), not a typer-range bug or a glibc heap bug. Exploit details depend on the exact diff shipped, so treat this page as the framing rather than a turn-key exploit.

Key takeaway

JIT compilers optimize speculatively on observed types, inserting runtime guards that bail out when those assumptions break. Remove a guard like the one in LowerCheckMaps and the engine keeps running the optimized path after the object type changes, which is a type confusion letting an attacker read or write memory as the wrong type. The bug class has hit V8, JavaScriptCore, and SpiderMonkey in real browser exploits, and the underlying pattern, a trusted assumption violated by caller-controlled state, recurs across JITs, compilers, and anything that caches type information.

Related reading

Useful tools for Binary Exploitation

Where to go next