Closed Bug 2066622 Opened 2 months ago Closed 1 month ago

WeakMap/WeakSet operations bypass dead-object access check on nuked CCW wrappers

Categories

(Core :: JavaScript: GC, defect, P3)

defect

Tracking

()

RESOLVED INVALID

People

(Reporter: nguyentuanhung1149, Unassigned)

Details

(Keywords: reporter-external, Whiteboard: [deduped][nosec])

Summary

After calling nukeCCW() (or nukeAllCCWs()) on a cross-compartment wrapper, property access correctly throws "can't access dead object". However, WeakMap and WeakSet operations (get, has, set, delete for WeakMap; has, add, delete for WeakSet) continue to work on the nuked (dead) wrapper without throwing.

WeakRef.deref() correctly returns undefined for nuked wrappers (safe - different code path).

Steps to Reproduce

// Run in SpiderMonkey shell
let victim = newGlobal({newCompartment: true});
victim.eval("var secretObj = {};");
let ref = victim.secretObj;

let wm = new WeakMap();
wm.set(ref, {sensitiveData: "private_token_12345"});

// Nuke the cross-compartment wrapper
nukeCCW(ref);
gc();

// Property access correctly blocked:
try { ref.toString(); } catch(e) { print("Blocked: " + e.message); }
// => "can't access dead object"

// WeakMap access NOT blocked - BUG:
let leaked = wm.get(ref);   // Returns {sensitiveData: "private_token_12345"}
print("has: " + wm.has(ref));  // true
print("leaked: " + JSON.stringify(leaked));

// WeakSet also affected:
let ws = new WeakSet();
let victim2 = newGlobal({newCompartment: true});
victim2.eval("var obj2 = {};");
let ref2 = victim2.obj2;
ws.add(ref2);
nukeCCW(ref2);
gc();
print("WeakSet.has: " + ws.has(ref2));  // true - BUG

Expected Behavior

WeakMap.get/has/set/delete and WeakSet.has/add/delete should throw TypeError ("can't access dead object") when called with a nuked CCW, consistent with all other operations on dead wrappers.

Actual Behavior

All WeakMap and WeakSet operations succeed silently on nuked CCWs. The dead wrapper's identity (GC cell address) is still used for HashMap lookup, bypassing the dead-object access check.

Root Cause Analysis

CanBeHeldWeakly() in js/src/gc/WeakMap-inl.h (line ~828) only checks value.isObject() - it does not check IsDeadProxyObject(). The individual WeakMap/WeakSet implementation functions (has_impl, get_impl, set_impl, delete_impl in js/src/builtin/WeakMapObject.cpp and WeakSetObject.cpp) also lack dead-object checks.

Additionally, WeakMap::get() / WeakMap::has() use the GC cell pointer directly for HashMap lookup, which succeeds because the nuked wrapper's cell is still alive (it's a DeadObjectProxy now, but the same JSObject allocation).

Affected Operations

  • WeakMap: get, has, set, delete
  • WeakSet: has, add, delete
  • NOT affected: WeakRef.deref (correctly returns undefined)

Browser Relevance

In the browser, NukeCrossCompartmentWrappers() is called during window destruction (dom/base/WindowDestroyedEvent.cpp:134). After a window is destroyed and its CCWs are nuked, any WeakMap/WeakSet entries keyed by those CCWs would still be accessible, potentially leaking data associated with the destroyed window's objects.

Suggested Fix

Add an IsDeadProxyObject() check either:

  1. In CanBeHeldWeakly() to reject dead proxies at the gate, or
  2. In each WeakMap/WeakSet impl function before the HashMap lookup.

Option 1 is cleaner:

// In CanBeHeldWeakly() or equivalent gate:
if (value.isObject()) {
  JSObject* obj = &value.toObject();
  if (js::IsDeadProxyObject(obj)) {
    // Throw or return false
  }
  // ... existing logic
}

Build Info

  • mozilla-unified rev ecd308329ad2 (2026-08-05)
  • BuildID 20260808210518
  • ASAN-enabled JS shell

Additional findings extending this bug:

1. FinalizationRegistry is also affected

FinalizationRegistry.register() accepts a nuked CCW as target without throwing:

let g = newGlobal({newCompartment: true});
g.eval("var obj = {};");
let ref = g.obj;
let fr = new FinalizationRegistry(held => {});
nukeCCW(ref);
gc();
fr.register(ref, "dead-target"); // succeeds, should throw

Similarly, FinalizationRegistry.unregister() works with a nuked CCW as the unregister token:

fr.unregister(nukedToken); // returns true

Same root cause: CanBeHeldWeakly() and the FR implementation don't check IsDeadProxyObject().

2. Proxy.revocable() exhibits the same pattern (standard web API)

The same WeakMap/WeakSet bypass works with Proxy.revocable(), which requires NO privileged/shell APIs:

let {proxy, revoke} = Proxy.revocable({}, {});
let wm = new WeakMap();
wm.set(proxy, {secret: "data"});
revoke();
gc();
proxy.anything; // throws "illegal operation attempted on a revoked proxy"
wm.get(proxy);  // returns {secret: "data"} - no throw
wm.has(proxy);  // true
wm.set(proxy, {new: "data"}); // succeeds

Note: The revoked proxy case may actually be spec-compliant (a revoked proxy is still a valid Object for CanBeHeldWeakly). But the nuked CCW case violates SpiderMonkey's security invariant that ALL operations on dead wrappers should fail.

3. Map/Set also preserve identity through nuke

Regular Map.get/has and Set.has work on nuked CCWs, but this is expected behavior since Map/Set use identity comparison, not weak references. The nuked wrapper's JSObject cell is the same allocation.

4. objectGlobal() returns a value for nuked CCW

objectGlobal(nukedCCW) returns the calling compartment's global (not the target's), so this is not an info leak, but it's inconsistent with the dead-object model.

Additional findings extending the scope of this bug:

1. WeakRef also bypasses dead-object checks (new)

let g = newGlobal({newCompartment: true});
g.eval("var obj = {secret: 42};");
let ref = g.obj;
nukeAllCCWs();
gc();

// Construction succeeds (CanBeHeldWeakly bypass)
let wr = new WeakRef(ref);

// deref() returns dead wrapper instead of undefined
let d = wr.deref(); // !== undefined!
d.secret; // throws "can't access dead object"

WeakRef.deref() should return undefined for dead objects (like it does for GC-collected objects), but instead returns the dead proxy wrapper. This means code checking wr.deref() !== undefined will incorrectly believe the referent is still alive.

2. WeakRef identity completely broken for nuked CCWs

let wr1 = new WeakRef(ref);
let wr2 = new WeakRef(ref);
nukeAllCCWs();
gc();

wr1.deref() === wr2.deref(); // false!
wr1.deref() === wr1.deref(); // false! Even same WeakRef, different call
ref === wr1.deref(); // false!

Every access to the nuked CCW creates a NEW DeadObjectProxy wrapper, breaking all identity semantics. Pre-nuke, all these comparisons return true.

3. Concrete data leak scenario via WeakMap + nuked keys

In browser context, a parent page may store sensitive data in a WeakMap keyed by objects from an iframe:

// Parent stores auth data keyed by iframe document
let authStore = new WeakMap();
authStore.set(iframe.document, {
  authToken: "Bearer sk-secret-token-12345",
  userId: 42, role: "admin"
});

// iframe navigates cross-origin -> CCWs nuked
let docRef = iframe.document; // still holds dead wrapper
nukeAllCCWs();
gc();

// Data STILL accessible via dead wrapper key!
let stolen = authStore.get(docRef);
// stolen.authToken === "Bearer sk-secret-token-12345"

WeakMap.get/has/delete all succeed with nuked keys because they resolve to the underlying GC cell identity. The nuked key acts as a valid lookup handle, leaking stored values.

4. new Proxy(nukedCCW, handler) also succeeds

You can wrap a dead object in a Proxy. Handler traps fire normally (has, get, ownKeys), though Reflect operations on the dead target throw. The Proxy wrapping a dead object is itself usable.

Summary of ALL affected APIs (CanBeHeldWeakly bypass):

  • WeakMap: set, get, has, delete (original report)
  • WeakSet: add, has, delete (original report)
  • FinalizationRegistry: register, unregister (comment 1)
  • WeakRef: constructor + deref returns dead wrapper (new)
  • Proxy: constructor accepts dead target (new)

Root cause (unchanged): CanBeHeldWeakly() (js/src/gc/WeakMap.cpp) does not check IsDeadProxyObject() before accepting objects as weak keys/targets.

HASH(0x5765018820d8)

HASH(0x5764f55cc868)

HASH(0x5764f7b03f40)

HASH(0x5d47f01da5a0)

Additional attack surface: WASM externref provides persistent, GC-resistant storage for nuked CCW references

All 4 WASM externref storage mechanisms preserve nuked CCW identity through nukeAllCCWs() + GC, enabling WeakMap/WeakSet info leak:

let g = newGlobal({newCompartment: true});
let ccw = g.eval("({})");
let wm = new WeakMap();
wm.set(ccw, {ssn: "123-45-6789"});

// Store in WASM externref global
let mod = new WebAssembly.Module(wasmTextToBinary(`
  (module
    (global $ref (mut externref) (ref.null extern))
    (func (export "store") (param externref) (global.set $ref (local.get 0)))
    (func (export "load") (result externref) (global.get $ref)))
`));
let inst = new WebAssembly.Instance(mod);
inst.exports.store(ccw);
ccw = null; gc();

nukeAllCCWs(); gc(); gc();

// Retrieve from WASM → bypass dead-object check → leak WeakMap value
let ref = inst.exports.load();
wm.get(ref); // => {ssn: "123-45-6789"} — LEAKED

All 4 WASM paths confirmed:

  • Path 1 (WASM externref global): LEAK ✓
  • Path 2 (WASM externref table): LEAK ✓
  • Path 3 (WASM GC struct with externref field): LEAK ✓
  • Path 4 (WASM GC array of externref, all slots): LEAK ✓
  • WeakSet.has also returns true for nuked ref from WASM

Direct property access on the nuked ref correctly throws "can't access dead object" — only the weak collection identity-based lookup bypasses the check.

This extends the attack surface significantly: WASM externref storage is persistent across GC cycles (the WASM runtime roots these references), making the leak reliable even under aggressive GC pressure.

Same root cause: CanBeHeldWeakly() at js/src/gc/WeakMap-inl.h:828 only checks value.isObject(), and DeadObjectProxy passes. The fix (adding IsDeadProxyObject check) would cover all paths including WASM.

Hi, requesting triage on this bug. Summary of the issue:

  1. WeakMap.get/set/has/delete, WeakSet.add/has/delete, WeakRef constructor+deref, and FinalizationRegistry.register/unregister all bypass the dead-object access check when operating on nuked CCW wrappers.

  2. The root cause is in CanBeHeldWeakly() (js/src/gc/WeakMap-inl.h) which only checks value.isObject() — DeadObjectProxy passes this check.

  3. WASM externref storage (globals, tables, GC struct/array fields) provides GC-resistant persistent storage for nuked CCW references, compounding the issue.

All evidence and PoCs are in the preceding comments. Happy to provide additional information if needed.

This is interesting. I'm not sure what the semantics should be. The current situation doesn't sound too bad though - we don't leak any information not already known to the current runtime.

CCWs and nuking them is not part of any spec and I think we want to avoid breaking spec behaviour (like setting/getting entries from a weak map) because the browser has asynchronously closed a window somewhere.

Steve what are you thoughts on this?

I was torn on this, but after looking at it for a while, I'm concluding that the current behavior is correct and better. (Different behavior would also be correct, but I think the current behavior is more consistent.)

I don't think of using a DeadObjectProxy as a WeakMap lookup key as "an operation on the key". It only matters if the key is the same or not, so I would expect WeakMap#get() to work the same as Map#get() and deadobj1 === deadobj2, and it does. It would also feel a little odd for CanBeHeldWeakly to change its answer over the lifetime of an object.

At the same time, I agree that the behavior appears surprising:

  • new WeakRef(deadobj) succeeds. I read this as "make a WeakRef with the target being an invalid object". This is using the object's identity only.
  • weakmap.set(deadobj, "foo") and map.set(deadobj, "foo"). Same idea.
  • new Proxy(deadobj, {}) - Same. This is very close to the WeakRef case, and it's good that they are consistent.
  • Proxy.revocable() - I had never heard of this, and it's fascinating. It's tempting to use a revoked proxy as a model for a dead object proxy. But they're not the same: revocation has a spec-defined effect on the internal slots of the Proxy exotic object (in particular it sets proxy.[[ProxyTarget]] to null.) Nuking a CCW doesn't and can't have the same effect, at least not if the effect is observable in any way, because a CCW is not a spec thing and is not visible at the spec level, so any observable munging of internal slots would be a spec violation.
  • WeakRef#deref does return undefined. Note that deref is not itself special; WeakRefs are special in that they are cleared out (see clearWeakRefTargets) when a CCW they're targeting gets nuked. That's why you can still create and deref a WeakRef for a target that has already been nuked. It's still only using the target's identity, not accessing the target. Arguably we're being inconsistent here and bug 2031152 is wrong.
  • WeakRef target identity (wr.deref() !== wr.deref() after its target has been nuked). This does seem legitimately weird. But see below.
  • new Proxy(deadobj, {}) succeeds. Another identity-only operation. It's also consistent with making a Proxy to a revoked proxy, fwiw.

But the nuked CCW case violates SpiderMonkey's security invariant that ALL operations on dead wrappers should fail.

Nuking is (somewhat surprisingly) not a security measure. It's purely a memory leak prevention measure. If you had found a way to reach the pre-nuked CCW target, that would be a problem because it would mean we'd be keeping it alive. See also bug 2031152 comment 4.

objectGlobal(nukedCCW) returns the calling compartment's global (not the target's), so this is not an info leak, but it's inconsistent with the dead-object model.

I just wanted to repeat that the "dead-object model" is not what you think it is. It's an implementation-only concern. It would be correct per the spec to make nuking a no-op and leak everything. But it's subtle; it sort of means that system (chrome) JS is not spec-compliant because nuking changes observable results, except that we're allowed to throw OOM (Out Of Memory) errors and this is similar.

The WeakRef target identity is a deceptive case. It's a result of nukeAllCCWs, which I finally learned what was while working through this bug. I will describe what's going on there at the end of this message.

But anyway, I ended up concluding that our current model is better because changing behavior seems like much more of a spec violation than throwing OOM-like errors. (I'm saying the same thing as jonco, just with a lot more words.) If we were to change this, then:

weakmap.set(obj, "hello");
globalThis.ensureReachable = [obj];
...other code here, perhaps doing an async yield...
weakmap.get(obj);

might not return "hello", and the spec says it should as long as obj is reachable (and it is) and it hasn't been removed from the weakmap. Admittedly, the WeakRef clearing violates this framing:

wr = new WeakRef(obj);
globalThis.ensureReachable = [obj];
...other code here, perhaps doing an async yield...
wr.deref() === undefined; // Could be true or false.

This currently may or may not evaluate to true. That seems like a smaller hole, and you could almost argue it away because WeakRef is one of the few things allowed to behave differently depending GC behavior. But not quite -- obj is reachable and so the GC argument doesn't work. And if you say it's ok because WeakRef is for observing the liveness of its target and nuking the target changes that liveness, then you'd have to make the argument that this is not also true of all the rest (WeakMap, equality testing, etc.)

Perhaps the better argument is that the purpose of WeakRef is also to avoid memory leaks, and so it should cooperate with the other mechanism (wrapper nuking) that is also for that purpose? Still, you could make the same argument with respect to WeakMap.

jonco, what do you think of that part? I can make a self-consistent argument for how all of this works, except for the WeakRef behavior. It's at least somewhat violating the WeakRef spec:

wr = new WeakRef(obj);
m = new Map([[obj, "still here!"]]);
...other stuff happens...
nukeAllCCWs();
wr.deref() === undefined; // True.
console.log(m.get(obj)); // "still here!"

The wr1.dererf() !== wr2.deref() case is legitimately weird. But if I change the test case to be selective, by calling nukeCCW(ref) in place of nukeAllCCWs(), then it behaves very differently. The nuking clears out the WeakRefs, and so .deref() on them returns undefined.

As I discovered, nukeAllCCWs is not equivalent to calling nukeCCW(ccw) for every ccw in the current compartment. In fact, they're partly opposite, and I'm very tempted to rename nukeAllCCWs. nukeCCW does what you expect: you have a CCW to some other compartment, you nuke it, now you have a DeadObjectProxy instead. If you retrieve the same object again, you'll get a fresh CCW (not a DeadObjectProxy, a working CCW). ref1 !== ref2. You can think of it as nuking an outgoing CCW, but imperatively so you can always create a new one.

nukeAllCCWs(), on the other hand, nukes all incoming CCWs. Not only that, but it prevents any new ones from being created that go either in or out. It's the "forcibly shut down this web page" operation, added for the purpose of preventing memory leaks. If there were any references into the page, they're gone now, and nothing can create any more.

So the code that executes in the shell after nukeAllCCWs() is called is a little weird. It's executing in a dead compartment. With your test, ref is not nuked, because it's an outgoing CCW, not an incoming one. WeakRefs to it are also not cleared out. In fact, they continue to have the same target stored internally that they always did, and it's in the other compartment. When you call deref(), it retrieves this target, but then in order to return it, it needs to wrap it in the caller's compartment -- which is the permanently nuked one that doesn't allow CCWs to be created in or out of it anymore. Instead, you get a DeadObjectProxy. So the deref() operation actually runs and completes successfully and returns the original real object, but while wrapping it up as the return value, the nuked compartment turns it into a fresh DeadObjectProxy. The compartment is not supposed to be able to do much of anything anymore, at least nothing that could create cross-compartment references.

Group: core-security
Group: core-security → javascript-core-security
Whiteboard: [deduped]

Automatic triage (under development): sec-rating
No validation or verification has been performed.

Security rating: nosec

Nuking cross-compartment wrappers is a memory-leak-prevention mechanism, not a security boundary. WeakMap/WeakSet/WeakRef lookups use only the dead wrapper's object identity (exactly as Map/Set and === do); any actual access to the dead object's properties still correctly throws, so no cross-origin or cross-compartment data is disclosed. In every PoC the "leaked" value was stored by the same code that reads it, using a key reference it already retained, so no origin boundary is crossed. The behavior is reachable from web content but grants an attacker no information or capability beyond what the current runtime already holds, and the platform owners concluded (comments 9-10) the current behavior is correct and spec-consistent. Not a security defect.

Whiteboard: [deduped] → [deduped][nosec]
Severity: S3 → --
Priority: P3 → --
Group: javascript-core-security

The severity field is not set for this bug.
:gtonietto, could you have a look please?

For more information, please visit BugBot documentation.

Flags: needinfo?(gtonietto)
Severity: -- → S3
Flags: needinfo?(gtonietto)
Priority: -- → P3

jonco, what do you think of that part? I can make a self-consistent argument for how all of this works, except for the WeakRef behavior. It's at least somewhat violating the WeakRef spec

I agree. Looking at this again I think deref() should really return the dead object wrapper after nuke.

I'll close this bug.

Status: UNCONFIRMED → RESOLVED
Closed: 1 month ago
Resolution: --- → INVALID
You need to log in before you can comment on or make changes to this bug.