Closed Bug 2031152 Opened 7 months ago Closed 7 months ago

WeakRef.deref() bypasses nukeCCW — allows read/write access and code injection into nuked cross-compartment wrappers

Categories

(Core :: JavaScript Engine, defect, P3)

defect

Tracking

()

RESOLVED FIXED
151 Branch
Tracking Status
firefox-esr140 --- unaffected
firefox149 --- wontfix
firefox150 --- wontfix
firefox151 --- fixed

People

(Reporter: define.ctf, Assigned: jonco)

References

(Blocks 1 open bug, Regressed 1 open bug, Regression)

Details

(Keywords: regression, reporter-external, Whiteboard: [pp3])

Attachments

(3 files, 3 external files)

Attached file chain_poc.js —

User Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36

Steps to reproduce:

WeakRef.deref() bypasses nukeCCW(), allowing full read/write access and code
injection into cross-compartment objects that should be dead.

Tested on:

  • SpiderMonkey Nightly 151.0a1 (BuildID: 20260412075731, changeset 4181bc77e2e9)
  • SpiderMonkey Beta 150.0 (BuildID: 20260412090428, changeset 03829fdb62f2)

Steps:

  1. Create object in separate compartment via newGlobal({newCompartment: true})
  2. Create WeakRef to the CCW
  3. Call nukeCCW() on the CCW
  4. Call weakref.deref() — returns live, fully functional wrapper

Minimal reproduction:

  var g = newGlobal({newCompartment: true});
  g.eval("var obj = {secret: 'confidential'};");
  var ccw = g.obj;
  var wr = new WeakRef(ccw);
  nukeCCW(ccw);

  // Direct access correctly blocked:
  try { ccw.secret; } catch(e) { /* "can't access dead object" */ }

  // WeakRef BYPASSES the nuke:
  wr.deref().secret;  // Returns "confidential" — BUG

Run with: ./js --fuzzing-safe poc.js

Note: Bug 1655917 addressed "Nuking a CCW to a WeakRef clears the target
of the WeakRef" but this is the reverse — a WeakRef TO a nuked CCW still
returns a live wrapper. The fix for 1655917 did not cover this case.

Actual results:

WeakRef.deref() returns a fully functional wrapper after nukeCCW().
Through this wrapper, an attacker can:

  1. READ properties: wr.deref().secret → "confidential"
  2. WRITE properties: wr.deref().isAdmin = true (visible to other compartment)
  3. INJECT functions: wr.deref().handler = function(){...}
    (executes with other compartment's privileges when called)
  4. OVERRIDE toString/valueOf cross-compartment
  5. Object.defineProperty works cross-compartment
  6. 100/100 nuked CCWs remain accessible via WeakRef
  7. Survives multiple GC cycles

Expected results:

WeakRef.deref() should return undefined for CCWs that have been nuked,
consistent with how direct access throws "can't access dead object".

nukeCCW() should either:

  • Clear WeakRef entries pointing to the nuked wrapper, OR
  • WeakRef.deref() should check the wrapper's "dead" flag before returning it
Attached file poc.js —
Attached file validation_tests.js —

Source code analysis confirms the bug exists in real browser code paths:

  1. NukeRemovedCrossCompartmentWrapper (js/src/proxy/BaseProxyHandler.cpp:409)
    calls NotifyGCNukeWrapper which only does RemoveFromGrayList() —
    NO WeakRef cleanup anywhere.

  2. NukeCrossCompartmentWrappers (js/src/proxy/CrossCompartmentWrapper.cpp:402)
    the bulk version also has NO WeakRef cleanup.

  3. NotifyGCNukeWrapper (js/src/gc/Sweeping.cpp:1211) only removes
    from gray list — does not touch WeakRef entries.

  4. WindowDestroyedEvent::Run() (dom/base/WindowDestroyedEvent.cpp:134)
    calls NukeCrossCompartmentWrappers when a window is destroyed
    (tab close, navigation). This is a REAL BROWSER code path.

Root cause: When a CCW is nuked, the wrapper object is converted to a
DeadObjectProxy via ProxyObject::nuke(). Direct property access checks
for the dead proxy and throws. But WeakRef stores a reference to the
wrapper JSObject itself — and WeakRef.deref() returns it without
checking if it has been nuked/converted to DeadObjectProxy.

nukeAllCCWs() in the shell correctly prevents WeakRef.deref() from
returning the wrapper, but per-wrapper nukeCCW() and the browser's
NukeCrossCompartmentWrappers() do not.

Validation: 19/19 tests passed including cross-compartment read, write,
code injection, toString/valueOf override, prototype pollution, and
Object.defineProperty — all through the bypassed wrapper.

Full validation test suite attached below.

Group: firefox-core-security → javascript-core-security
Component: Untriaged → JavaScript Engine
Product: Firefox → Core

This is due to bug 1984091 which changed WeakRef and FinalizationRegistry to stop using CCWs internally. This was done to support symbols as weak ref keys, since we don't support CCWs to symbols.

Keywords: regression
Regressed by: 1984091

This is an interesting finding, but wrapper nuking is done to prevent memory leaks, not as a security measure.

Whiteboard: [pp3]
Assignee: nobody → jcoppeard

I was able to confirm this behavior in the real Firefox browser
(not just the JS shell).

Steps in Firefox Nightly browser console (F12):

  var f = document.createElement("iframe");
  f.src = "about:blank";
  document.body.appendChild(f);
  f.contentWindow.testObj = {secret: 42};
  var wr = new WeakRef(f.contentWindow.testObj);
  console.log("Before:", wr.deref()?.secret);
  f.src = "about:blank";
  setTimeout(() => {
      var d = wr.deref();
      console.log("After:", d ? d.secret : "cleared");
  }, 3000);

Result:
Before: 42
After: 42

Expected:
After: cleared (or undefined)

The WeakRef retains access to the cross-window object after
iframe navigation in the real browser, confirming this is not
shell-only behavior.

Blocks: sm-profiler
Severity: -- → S3
Priority: -- → P3

Thanks for the triage. I'd like to add some context that may be worth considering before settling on the severity.

I fully accept Comment 4 that wrapper nuking was not introduced as a security feature. My concern is narrower:
regardless of original intent, nukeCCW and NukeCrossCompartmentWrappers are implicitly invoked at several boundaries
where isolation between contexts matters, and consumers downstream appear to assume that "nuked" means "unreachable."
The WeakRef bypass breaks that observable property.

Call sites where the nuke happens at an isolation-relevant boundary (offered as factual references, not as a claim
about design intent):

  • nsGlobalWindowInner::FreeInnerObjects — runs on document navigation and calls js::NukeCrossCompartmentWrappers
    against the previous inner window.
  • WindowDestroyedEvent::Run (dom/base/WindowDestroyedEvent.cpp:134) — tab close / navigation.
  • xpc::NukeAllWrappersForRealm — invoked during realm teardown including principal changes.
  • WebExtension sandbox teardown in js/xpconnect/src/Sandbox.cpp.
  • BrowsingContext discard paths under Fission.

Whether or not these were designed as security boundaries, code on the other side of them does behave as if
reachability ends there. The PoC in Comment 6 reproduces in a real browser via the FreeInnerObjects path on iframe src
navigation.

Observable effects through the WeakRef-revived wrapper, after the nuke:

  • READ properties cross-compartment
  • WRITE properties cross-compartment
  • Install callable functions that later execute on the other compartment's side
  • Override toString / valueOf / accessors via Object.defineProperty

An inconsistency that may be useful for scoping a fix — the shell's nukeAllCCWs already produces the expected
behavior, while per-wrapper nukeCCW does not:

// nukeAllCCWs clears the WeakRef:
var g1 = newGlobal({newCompartment: true});
g1.eval("var o = {v:1};");
var wr1 = new WeakRef(g1.o);
nukeAllCCWs(g1); gc(); gc();
wr1.deref(); // undefined

// per-wrapper nukeCCW does not:
var g2 = newGlobal({newCompartment: true});
g2.eval("var o = {v:2};");
var ccw = g2.o;
var wr2 = new WeakRef(ccw);
nukeCCW(ccw); gc(); gc();
wr2.deref(); // still live

Possible fix directions, deferring to jonco on what's appropriate:

  • Have ProxyObject::nuke() / NotifyGCNukeWrapper clear matching WeakRef target entries, mirroring nukeAllCCWs.
  • Or have WeakRef::deref check IsDeadProxyObject(target) before returning it.

Re severity: I'm not pushing for a specific rating, just flagging that the bypass is reachable from ordinary
navigation in real Firefox and yields cross-compartment read/write/inject after the nuke completes — that may be worth
weighing before closing at S3/P3.

This adds a new header file js/public/friend/Wrapper.h and moves the nuking
APIs there. It also makes some APIs that are not called from the browser
internal and makes ReportAccessDenied (which is) public.

JS WeakRefs are not implemented in terms of CCWs so we have to also 'nuke' any
edges from these to their targets. The patch works in the same was as the
previous patch for CC integration.

I'm going to go ahead and unhide this. Any information that gets deleted by this could have easily been copied before the nuking happened. We also only do it for references from privileged code because we can't make it visible to web content.

Group: javascript-core-security
Status: UNCONFIRMED → ASSIGNED
Ever confirmed: true
Attachment #9569869 - Attachment description: (secure) → Bug 2031152 - Part 1: Move CCW nuking APIs to their own header file r?jandem

Thanks for the fix and for clarifying the threat model. Could you let me know whether this qualifies for the Client
Bug Bounty program given the [pp3] marking, or if unhiding places it outside bounty scope? Happy to defer to the
bounty committee either way.

If it is not a security bug, then it does not qualify for the security bug bounty program. It'll likely get marked as sec-bounty- in the next bounty meeting this week or maybe next week if the bug isn't closed until then.

Thanks again for the report, as it is definitely a bug. A subtle issue and I could see how it looked like it could be a security issue.

Set release status flags based on info from the regressing bug 1984091

Understood, thanks for the quick and clear response. Glad the report was useful regardless.

Attachment #9569870 - Attachment description: (secure) → Bug 2031152 - Part 2: Clear weak ref targets when CCWs are nuked r=sfink!
Attachment #9569871 - Attachment description: (secure) → Bug 2031152 - Part 3: Add testcase r?sfink
Status: ASSIGNED → RESOLVED
Closed: 7 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch

there is CVE?

This is not a security issue, so we won't issue a CVE.

thank you so much

Flags: in-testsuite+
See Also: → 2032328
Regressions: 2032512

There is bounty

Regressions: 2046893
Regressions: 2080740
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: