Closed Bug 2028013 Opened 7 months ago Closed 7 months ago

Heap use-after-free in [@ ModuleLoaderBase::ImportMetaResolveImpl] when import.meta.resolve outlives its module

Categories

(Core :: JavaScript Engine, defect, P1)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2027541

People

(Reporter: bugmon, Unassigned)

References

(Blocks 1 open bug)

Details

(6 keywords, Whiteboard: [prefs-checked][bugmon:bisected,confirmed])

Attachments

(3 files)

Summary

Heap use-after-free in /firefox/js/loader/ModuleLoaderBase.cpp at line 382. HostPopulateImportMeta stores a raw PrivateValue pointer to the ModuleScript C++ object in the import.meta.resolve function's reserved slot without taking a reference count. If script saves the resolve function to another global (e.g., a parent window) and the originating document is destroyed, the cycle collector frees the ModuleScript while the saved function still holds a dangling pointer. Invoking the saved function then dereferences freed memory.

Affected Code

File: js/loader/ModuleLoaderBase.cpp, lines 465-470

// Store the 'active script' of the meta object into the function slot.
// https://html.spec.whatwg.org/#active-script
RootedObject resolveFuncObj(aCx, JS_GetFunctionObject(resolveFunc));
js::SetFunctionNativeReserved(
    resolveFuncObj, static_cast<size_t>(ImportMetaSlots::ModulePrivateSlot),
    aReferencingPrivate);  // <-- stores raw pointer, no AddRef

File: js/loader/ModuleLoaderBase.cpp, lines 381-382

RefPtr<ModuleScript> script =
    static_cast<ModuleScript*>(aReferencingPrivate.toPrivate());  // <-- UAF: AddRef on freed object

The module record's private value is normally refcounted by the JS engine via HostAddRefTopLevelScript/HostReleaseTopLevelScript hooks tied to the module record's lifetime. However, the copy stored in the resolve function's reserved slot bypasses this mechanism entirely — it is a plain PrivateValue with no finalizer and no refcount increment. When the module record is GC'd, HostReleaseTopLevelScript drops the last reference and the cycle collector frees the ModuleScript, but the resolve function's slot still contains the stale pointer.

Correct Pattern

The reserved slot must own a reference. One approach is to AddRef when storing and Release via a finalizer:

// In HostPopulateImportMeta, before SetFunctionNativeReserved:
HostAddRefTopLevelScript(aReferencingPrivate);
js::SetFunctionNativeReserved(resolveFuncObj,
    static_cast<size_t>(ImportMetaSlots::ModulePrivateSlot),
    aReferencingPrivate);
// ...and add a finalize hook on the resolve function that calls
// HostReleaseTopLevelScript when the function is collected.

Alternatively, store the module record JSObject* (which the GC traces) in the slot instead of the raw C++ pointer, and recover the ModuleScript via GetModulePrivate() at call time — after checking the module is still alive.

Exploit Chain

  1. A page creates an iframe containing <script type="module">.
  2. The module accesses import.meta.resolve, causing HostPopulateImportMeta to create the resolve function and stash a raw ModuleScript* in its reserved slot (no AddRef).
  3. The module assigns import.meta.resolve to a property on the parent window, creating a cross-global strong reference to the function object — but still only a weak reference to the ModuleScript.
  4. The parent removes the iframe. The iframe's global becomes unreachable.
  5. GC collects the iframe's module record; HostReleaseTopLevelScript drops the last ref on the ModuleScript.
  6. The cycle collector's SnowWhiteKiller frees the ModuleScript (192-byte allocation).
  7. The parent calls the saved resolve("./test.js"). ImportMetaResolve reads the stale pointer from the reserved slot and passes it to ImportMetaResolveImpl.
  8. Line 382 constructs a RefPtr<ModuleScript> from the freed pointer, calling AddRef() which reads the freed refcount field → heap-use-after-free.

Steps to Reproduce

Requires a build with --enable-fuzzing and the fuzzing.enabled=true pref so that FuzzingFunctions.garbageCollect()/cycleCollect() are available to force deterministic collection. In a release build the same bug exists but triggers probabilistically under memory pressure rather than on demand.

Security Impact

Severity: High (sec-high). This is a web-triggerable use-after-free reachable from unprivileged JavaScript with no user interaction. An attacker who can run script in a page can:

  • Free a 192-byte ModuleScript object at a controlled time,
  • Spray the heap to reclaim the freed slot with attacker-controlled data,
  • Trigger AddRef() and subsequent virtual/method calls (IsModuleScript(), BaseURL(), ResolveModuleSpecifier) on the reclaimed memory.

Control over the reclaimed object contents can lead to arbitrary read/write and code execution in the content process. Combined with a sandbox-escape primitive this is a full RCE chain component.

Preconditions: None beyond the ability to run script and create/remove a same-origin iframe.

Suggested Fix

Store the module record JSObject* in the reserved slot instead of the raw C++ pointer. The GC will trace the slot and keep the module record alive as long as the resolve function lives, which in turn keeps the ModuleScript alive via the existing HostAddRefTopLevelScript hook:

// In HostPopulateImportMeta:
Rooted<JSObject*> moduleRecord(aCx, script->ModuleRecord());
js::SetFunctionNativeReserved(
    resolveFuncObj, static_cast<size_t>(ImportMetaSlots::ModulePrivateSlot),
    ObjectValue(*moduleRecord));

// In ImportMetaResolve:
RootedValue slotVal(cx, js::GetFunctionNativeReserved(&args.callee(),
    static_cast<size_t>(ImportMetaSlots::ModulePrivateSlot)));
RootedObject moduleRecord(cx, &slotVal.toObject());
RootedValue modulePrivate(cx, JS::GetModulePrivate(moduleRecord));
if (modulePrivate.isUndefined()) {
  JS_ReportErrorASCII(cx, "import.meta.resolve called after module was unloaded");
  return false;
}

This also gracefully handles the case where the module has been unlinked (private cleared) by throwing instead of crashing.

Attached file test.html —
Attached file crash_stack.txt —
Group: core-security → javascript-core-security

Yoshi, can you confirm this is a duplicate of bug 2027541? Also maybe the test case here is better?

Flags: needinfo?(allstars.chh)

Marking as S2, P1 for now.

Blocks: sm-security
Severity: -- → S2
Priority: -- → P1
Whiteboard: [prefs-checked]

Prefs: fuzzing.enabled is only used for FuzzingFunctions.garbageCollect()/cycleCollect() to time GC/CC deterministically. The UAF code path is not pref-guarded — supported config.

sec-high: Correct. Web-triggerable UAF, no user interaction. (Aside: csectype-sandbox-escape looks wrong — reproduced with crashed_parent=false, content process only.)

Fix: Reproduced the UAF on current main (heap-use-after-free at ModuleLoaderBase.cpp:382). The attached patch stores the module record JSObject* as an ObjectValue in the resolve function's reserved slot instead of the raw ModuleScript* PrivateValue. GC traces reserved slots, so the module record stays alive as long as the resolve function is reachable, which keeps the ModuleScript alive via the existing HostAddRefTopLevelScript hook. At call time the private is recovered via GetModulePrivate(), with a clean throw if it was explicitly cleared by Shutdown().

Verified on ASAN: testcase no longer crashes after the patch; import.meta.resolve still works in the normal path.

Note for Yoshi re comment 4: I can't access bug 2027541 to check for overlap — disregard this patch if that one already covers it.

This is the analysis tool's suggested fix. Feel welcome to adopt it as a starting point and evolve it as needed to meet our coding standards.

I can reproduce the crash with fuzz.enabled set to true.
My patch from Bug 2027541 also fixes the test html in this bug, and the exploit is similar, should I simply duplicate this bug with bug 2027541?

Asking because this one is sec-high, but bug 2027541 isn't.

Flags: needinfo?(allstars.chh)

Verified bug as reproducible on mozilla-central 20260331210827-1369941f425d.
Unable to bisect testcase (Testcase reproduces on start build!):

Start: 526b3d0ba7ebe24e98aad3e1535e586927651821 (20250402162225)
End: 1369941f425dab52c6a0a3de6b14d9de93376a04 (20260331210827)
BuildFlags: BuildFlags(asan=True, tsan=False, debug=False, fuzzing=True, coverage=False, valgrind=False, no_opt=False, fuzzilli=False, nyx=False, searchfox=False, afl=False)

Whiteboard: [prefs-checked] → [prefs-checked][bugmon:bisected,confirmed]

(In reply to Yoshi Cheng-Hao Huang [:allstars.chh][:allstarschh][:yoshi] from comment #8)

I can reproduce the crash with fuzz.enabled set to true.
My patch from Bug 2027541 also fixes the test html in this bug, and the exploit is similar, should I simply duplicate this bug with bug 2027541?

Asking because this one is sec-high, but bug 2027541 isn't.

Thanks for checking. Let's mark this as duplicate and I've added the sec-high keyword to bug 2027541.

Status: UNCONFIRMED → RESOLVED
Closed: 7 months ago
Duplicate of bug: CVE-2026-6754
Resolution: --- → DUPLICATE

No valid actions for resolution (DUPLICATE).
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.

Keywords: bugmon
Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: