Closed Bug 2022034 Opened 3 months ago Closed 3 months ago

NaN-boxing type confusion in JSIPCValue deserialization

Categories

(Core :: DOM: Content Processes, defect)

defect

Tracking

()

RESOLVED FIXED
150 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox148 --- wontfix
firefox149 + fixed
firefox150 + fixed

People

(Reporter: decoder, Assigned: mccr8)

References

(Regression)

Details

(5 keywords, Whiteboard: [adv-main149+r])

Attachments

(6 files)

Attached file exploit.patch

Summary

NaN-boxing type confusion in JSIPCValue deserialization at /firefox/dom/ipc/jsactor/JSIPCValueUtils.cpp:653. A compromised content process can send a crafted IEEE-754 NaN whose 64-bit representation has the top 17 bits equal to JSVAL_TAG_OBJECT (0x1FFFC). When the parent process calls setDouble() on this value, it becomes a JS::Value tagged as Object with a fully attacker-controlled 47-bit JSObject* payload — a textbook fake-object primitive across the IPC trust boundary.

Affected Code

File: /firefox/dom/ipc/jsactor/JSIPCValueUtils.cpp, lines 652–654

case JSIPCValue::Tdouble:
  aOut.setDouble(aIn.get_double());   // ← NO NaN CANONICALIZATION
  return;

Why this is vulnerable:

On JS_PUNBOX64 platforms (x86_64, ARM64), JS::Value::setDouble() is implemented as a raw bitwise cast (/firefox/js/public/Value.h:580–583):

void setDouble(double d) {
  asBits_ = bitsFromDouble(d);
  MOZ_ASSERT(isDouble());        // debug-only!
}

bitsFromDouble() (/firefox/js/public/Value.h:513–518) only canonicalizes under JS_NONCANONICAL_HARDWARE_NAN, which is defined only on SPARC (/firefox/js/public/Value.h:409–414). On x86_64, it is a bare mozilla::BitwiseCast<uint64_t>(d).

The IPC layer does no canonicalization: Pickle::WriteDouble (/firefox/ipc/chromium/src/base/pickle.cc:426) and Pickle::ReadDouble (line 240) are raw byte copies. The crafted NaN bits survive serialization intact.

Bit-level proof (verified): with poison 0xFFFE414141414141:

  • Valid IEEE-754 NaN (sign=1, exp=0x7FF, mantissa≠0) — std::isnan() returns true
  • isDouble(): 0xFFFE414141414141 <= JSVAL_SHIFTED_TAG_MAX_DOUBLE (0xFFF80000FFFFFFFF)false
  • isObject(): 0xFFFE414141414141 >= JSVAL_SHIFTED_TAG_OBJECT (0xFFFE000000000000)true
  • toObject(): 0xFFFE414141414141 ^ 0xFFFE000000000000 = JSObject* at 0x414141414141

Correct Pattern

SpiderMonkey already provides the safe helper at /firefox/js/public/Value.h:454–459 and 1096–1098:

case JSIPCValue::Tdouble:
  aOut.set(JS::CanonicalizedDoubleValue(aIn.get_double()));
  return;

CanonicalizeNaN() replaces any NaN with GenericNaN() (bits 0x7FF8000000000000), which safely satisfies isDouble().

Exploit Chain

  1. Content process calls WindowGlobalChild::SendRawMessage() with a JSIPCArray containing one element: a JSIPCValue(double) where the double's bits are 0xFFFE414141414141.
  2. IPC serialization writes raw bytes via Pickle::WriteDoubleWriteBytes(&value, sizeof(value)).
  3. Parent process PWindowGlobalParent::OnMessageReceived deserializes via Pickle::ReadDoubleReadScalar (raw byte copy).
  4. WindowGlobalParent::RecvRawMessageJSActorManager::ReceiveRawMessageJSIPCValueUtils::ToJSVal.
  5. ToJSVal dispatches on TJSIPCArrayToJSArray() (line 716).
  6. ToJSArray iterates elements → recursive ToJSVal → dispatches on TdoubleaOut.setDouble(poison) (line 653).
  7. JS::Value::asBits_ is now 0xFFFE414141414141, tagged as Object.
  8. ToJSArray calls JS_DefineElement(cx, array, 0, value, JSPROP_ENUMERATE) (line 524).
  9. Nightly/fuzzing path (JS_CRASH_DIAGNOSTICS): DefineDataElementcx->check(obj, value)ContextChecks::check(Value) sees v.isObject() → calls v.toObject().compartment()shape()headerPtr() → dereferences 0x414141414141.
  10. Release path (no diagnostics): cx->check() is a no-op; value flows to DefineDataPropertyById → GC write barrier ValuePostWriteBarrier (/firefox/js/src/gc/Barrier.h:386) → next.isGCThing() is true → next.toGCThing()->storeBuffer() dereferences 0x414141414141.

Both paths dereference the attacker-controlled pointer in the parent process.

IPC Path

  • IPDL protocol: PWindowGlobal (/firefox/dom/ipc/PWindowGlobal.ipdl:105)
  • Message: async RawMessage(JSActorMessageMeta aMetadata, JSIPCValue aData, nullable StructuredCloneData aStack) — direction both:
  • Child sender: WindowGlobalChild::SendRawMessage (auto-generated in PWindowGlobalChild.cpp)
  • Parent receiver: WindowGlobalParent::RecvRawMessage (/firefox/dom/ipc/WindowGlobalParent.cpp:565)
  • Manager chain: PWindowGlobalPBrowserPContent (frame #14: PContentParent::OnMessageReceived)
  • Serialization: ParamTraitsFundamental<double> (/firefox/ipc/chromium/src/chrome/common/ipc_message_utils.h:788) → raw byte copy with no NaN normalization

Security Impact

Severity: Critical — sandbox escape from content process to parent process.

Attacker capability: Full fake-object primitive in the parent-process SpiderMonkey engine. The attacker controls all 47 bits of the JSObject* payload. With parent-process heap spraying (achievable via IPC-exposed arenas, large strings, etc.), the attacker places a fake JSObject header at a predictable address → fake Shape* → fake JSClass* → arbitrary read/write → code execution at parent-process privilege (unsandboxed).

Preconditions: Compromised content process (achievable via a separate renderer bug; this is the second stage of a sandbox escape chain). No user interaction required beyond loading any page.

Affected platforms: All JS_PUNBOX64 platforms — x86_64 and ARM64 Linux/Windows/macOS. SPARC is unaffected due to JS_NONCANONICAL_HARDWARE_NAN.

ASAN Report

=================================================================
==203580==ERROR: AddressSanitizer: SEGV on unknown address 0x414141414141 (pc 0x767d44d9e679 bp 0x7fff42dc1410 sp 0x7fff42dc1400 T0)
==203580==The signal is caused by a READ memory access.
    #0 0x767d44d9e679 in js::gc::HeaderWord::get() const /firefox/js/src/gc/Cell.h:111:23
    #1 0x767d44d9e679 in js::gc::CellWithTenuredGCPointer<js::gc::Cell, js::Shape>::headerPtr() const /firefox/js/src/gc/Cell.h:844:60
    #2 0x767d44d9e679 in JSObject::shape() const /firefox/js/src/vm/JSObject.h:94:37
    #3 0x767d44d9e679 in JSObject::compartment() const /firefox/js/src/vm/JSObject.h:146:49
    #4 0x767d44d9e679 in js::ContextChecks::check(JSObject*, int) /firefox/js/src/vm/JSContext-inl.h:84:18
    #5 0x767d44d9e679 in js::ContextChecks::check(JS::Value const&, int) /firefox/js/src/vm/JSContext-inl.h:134:7
    #6 0x767d467cc760 in void JSContext::checkImpl<JS::Handle<JSObject*>, JS::Handle<JS::Value>>(JS::Handle<JSObject*> const&, JS::Handle<JS::Value> const&) /firefox/js/src/vm/JSContext-inl.h:216:33
    #7 0x767d467cc760 in void JSContext::check<JS::Handle<JSObject*>, JS::Handle<JS::Value>>(JS::Handle<JSObject*> const&, JS::Handle<JS::Value> const&) /firefox/js/src/vm/JSContext-inl.h:223:5
    #8 0x767d467cc760 in DefineDataElement(JSContext*, JS::Handle<JSObject*>, unsigned int, JS::Handle<JS::Value>, unsigned int) /firefox/js/src/vm/PropertyAndElement.cpp:461:7
    #9 0x767d418375f0 in mozilla::dom::ToJSArray(JSContext*, nsTArray<mozilla::dom::JSIPCValue>&&, JS::MutableHandle<JS::Value>, mozilla::ErrorResult&) /firefox/dom/ipc/jsactor/JSIPCValueUtils.cpp:524:10
    #10 0x767d418375f0 in mozilla::dom::JSIPCValueUtils::ToJSVal(JSContext*, mozilla::dom::JSIPCValue&&, JS::MutableHandle<JS::Value>, mozilla::ErrorResult&) /firefox/dom/ipc/jsactor/JSIPCValueUtils.cpp:716:14
    #11 0x767d41836db0 in mozilla::dom::JSActorManager::ReceiveRawMessage(mozilla::dom::JSActorMessageMeta const&, mozilla::dom::JSIPCValue&&, mozilla::dom::ipc::StructuredCloneData*) /firefox/dom/ipc/jsactor/JSActorManager.cpp:200:3
    #12 0x767d41412572 in mozilla::dom::WindowGlobalParent::RecvRawMessage(mozilla::dom::JSActorMessageMeta const&, mozilla::dom::JSIPCValue&&, mozilla::dom::ipc::StructuredCloneData*) /firefox/dom/ipc/WindowGlobalParent.cpp:568:3
    #13 0x767d41808a5e in mozilla::dom::PWindowGlobalParent::OnMessageReceived(IPC::Message const&) /firefox/obj-x86_64-pc-linux-gnu/ipc/ipdl/PWindowGlobalParent.cpp:954:86
    #14 0x767d4172081a in mozilla::dom::PContentParent::OnMessageReceived(IPC::Message const&) /firefox/obj-x86_64-pc-linux-gnu/ipc/ipdl/PContentParent.cpp:6684:32
    ...
    #37 0x767d44b0f1e3 in XRE_main(int, char**, mozilla::BootstrapConfig const&) /firefox/toolkit/xre/nsAppRunner.cpp:6259:21

==203580==Register values:
rax = 0x0000414141414141  rbx = 0x0000000000000000  rcx = 0x0000000000000001  rdx = 0x0000082828282828
SUMMARY: AddressSanitizer: SEGV (/firefox/obj-x86_64-pc-linux-gnu/dist/bin/libxul.so+0x2219e679)
==203580==ABORTING

Parent process confirmed: PID 203580 matches [Parent 203580, Main Thread] in stderr; frame #37 is XRE_main (parent entry point, not XRE_InitChildProcess). Register rax = 0x0000414141414141 is the XOR-unboxed fake JSObject*.

Suggested Fix

// /firefox/dom/ipc/jsactor/JSIPCValueUtils.cpp:652
case JSIPCValue::Tdouble:
  // NaN bits from a potentially-compromised content process must not
  // alias JS::Value type tags. CanonicalizeNaN replaces any NaN with
  // the canonical bit pattern (0x7FF8000000000000) that satisfies
  // isDouble() under PUNBOX64.
  aOut.setDouble(JS::CanonicalizeNaN(aIn.get_double()));
  return;

Defense-in-depth: Additionally, ParamTraitsFundamental<double>::Read (/firefox/ipc/chromium/src/chrome/common/ipc_message_utils.h:794) should canonicalize NaNs for all IPC doubles, since other IPC receivers may have the same pattern. Long-term, consider making JS::Value::setDouble() always canonicalize (removing the JS_NONCANONICAL_HARDWARE_NAN conditional), accepting the minor branch cost for safety at trust boundaries.

Attached file test.html
Attached file crash_stack.txt
Keywords: regression
Regressed by: 1999397

Oops, that's bad. StructuredClone does appear to do the right thing, as you'd expect.

Assignee: nobody → continuation
Group: core-security → dom-core-security
Component: IPC → DOM: Content Processes
Severity: -- → S2

JSIPCDOMRect also sends doubles over IPC, but on deserialization it sticks them into a C++ DOMRect object and then wraps that to get a value. The DOMRect bindings does the canonicalization.

Attached file (secure)
Attached file (secure)

(In reply to Christian Holler (:decoder) from comment #0)

The IPC layer does no canonicalization: Pickle::WriteDouble (/firefox/ipc/chromium/src/base/pickle.cc:426) and Pickle::ReadDouble (line 240) are raw byte copies. The crafted NaN bits survive serialization intact.

Maybe we should do something about that. I don't think there's anything that passes doubles over IPC that needs NaN payloads to be preserved (but that should be tested).

Defense-in-depth: Additionally, ParamTraitsFundamental<double>::Read (/firefox/ipc/chromium/src/chrome/common/ipc_message_utils.h:794) should canonicalize NaNs for all IPC doubles, since other IPC receivers may have the same pattern.

And I see I wasn't the only one with that idea.

Comment on attachment 9551278 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: The basic issue is obvious. Turning this into an exploit would require arbitrary code execution in the content process, plus either some kind of heap grooming and/or leaking an address to the child process, then figuring out how to get parent process chrome JS to poke at the controlled wild pointer in the right way.
  • 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?: 148+
  • If not all supported branches, which bug introduced the flaw?: Bug 1999397
  • Do you have backports for the affected branches?: No
  • If not, how different, hard to create, and risky will they be?: This code hasn't changed since I landed it so there should be no issue.
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely. This only changes the behavior in a weird exploit case.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9551278 - Flags: sec-approval?

Comment on attachment 9551278 [details]
(secure)

sec-approval+ to land now and request beta uplift. This is the last week for sec-bug uplifts so don't wait too long.

Please hold off on landing the patch until 2026-05-05 or later

Attachment #9551278 - Flags: sec-approval? → sec-approval+
Flags: in-testsuite?
Whiteboard: [reminder-test 2026-05-05]

firefox-beta Uplift Approval Request

  • User impact if declined: sec-high
  • Code covered by automated testing: yes
  • Fix verified in Nightly: no
  • Needs manual QE test: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: Under normal circumstances, it shouldn't change the behavior at all.
  • String changes made/needed: none
  • Is Android affected?: yes
Attachment #9551552 - Flags: approval-mozilla-beta?
Attached file (secure)
Group: dom-core-security → core-security-release
Status: NEW → RESOLVED
Closed: 3 months ago
Resolution: --- → FIXED
Target Milestone: --- → 150 Branch
Attachment #9551552 - Flags: approval-mozilla-beta? → approval-mozilla-beta+

In a review comment, jandem said "This is a great find." and I agree.

QA Whiteboard: [sec] [uplift] [qa-triage-done-c150/b149]
Whiteboard: [reminder-test 2026-05-05] → [reminder-test 2026-05-05][adv-main149+r]

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

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

Flags: needinfo?(continuation)
Whiteboard: [reminder-test 2026-05-05][adv-main149+r] → [adv-main149+r]
Flags: needinfo?(continuation)
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: