Heap use-after-free in [@ ModuleLoaderBase::ImportMetaResolveImpl] when import.meta.resolve outlives its module
Categories
(Core :: JavaScript Engine, defect, P1)
Tracking
()
People
(Reporter: bugmon, Unassigned)
References
(Blocks 1 open bug)
Details
(6 keywords, Whiteboard: [prefs-checked][bugmon:bisected,confirmed])
Attachments
(3 files)
|
814 bytes,
text/html
|
Details | |
|
6.15 KB,
text/plain
|
Details | |
|
3.75 KB,
patch
|
Details | Diff | Splinter Review |
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
- A page creates an iframe containing
<script type="module">. - The module accesses
import.meta.resolve, causingHostPopulateImportMetato create the resolve function and stash a rawModuleScript*in its reserved slot (no AddRef). - The module assigns
import.meta.resolveto a property on the parent window, creating a cross-global strong reference to the function object — but still only a weak reference to theModuleScript. - The parent removes the iframe. The iframe's global becomes unreachable.
- GC collects the iframe's module record;
HostReleaseTopLevelScriptdrops the last ref on theModuleScript. - The cycle collector's
SnowWhiteKillerfrees theModuleScript(192-byte allocation). - The parent calls the saved
resolve("./test.js").ImportMetaResolvereads the stale pointer from the reserved slot and passes it toImportMetaResolveImpl. - Line 382 constructs a
RefPtr<ModuleScript>from the freed pointer, callingAddRef()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
ModuleScriptobject 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.
| Reporter | ||
Comment 1•7 months ago
|
||
| Reporter | ||
Comment 2•7 months ago
|
||
Updated•7 months ago
|
Comment 4•7 months ago
|
||
Yoshi, can you confirm this is a duplicate of bug 2027541? Also maybe the test case here is better?
Comment 5•7 months ago
|
||
Marking as S2, P1 for now.
Updated•7 months ago
|
Comment 6•7 months ago
|
||
Comment 7•7 months ago
|
||
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.
| Reporter | ||
Comment 9•7 months ago
|
||
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)
Comment 10•7 months ago
|
||
(In reply to Yoshi Cheng-Hao Huang [:allstars.chh][:allstarschh][:yoshi] from comment #8)
I can reproduce the crash with
fuzz.enabledset 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.
| Reporter | ||
Comment 11•7 months ago
|
||
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.
Updated•1 month ago
|
Description
•