NaN-boxing type confusion in JSIPCValue deserialization
Categories
(Core :: DOM: Content Processes, defect)
Tracking
()
| 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)
|
2.68 KB,
text/plain
|
Details | |
|
49 bytes,
text/html
|
Details | |
|
6.52 KB,
text/plain
|
Details | |
|
48 bytes,
text/x-phabricator-request
|
dveditz
:
sec-approval+
|
Details | Review |
|
48 bytes,
text/x-phabricator-request
|
Details | Review | |
|
48 bytes,
text/x-phabricator-request
|
phab-bot
:
approval-mozilla-beta+
|
Details | Review |
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()returnstrue isDouble():0xFFFE414141414141 <= JSVAL_SHIFTED_TAG_MAX_DOUBLE (0xFFF80000FFFFFFFF)→ falseisObject():0xFFFE414141414141 >= JSVAL_SHIFTED_TAG_OBJECT (0xFFFE000000000000)→ truetoObject():0xFFFE414141414141 ^ 0xFFFE000000000000=JSObject*at0x414141414141
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
- Content process calls
WindowGlobalChild::SendRawMessage()with aJSIPCArraycontaining one element: aJSIPCValue(double)where the double's bits are0xFFFE414141414141. - IPC serialization writes raw bytes via
Pickle::WriteDouble→WriteBytes(&value, sizeof(value)). - Parent process
PWindowGlobalParent::OnMessageReceiveddeserializes viaPickle::ReadDouble→ReadScalar(raw byte copy). WindowGlobalParent::RecvRawMessage→JSActorManager::ReceiveRawMessage→JSIPCValueUtils::ToJSVal.ToJSValdispatches onTJSIPCArray→ToJSArray()(line 716).ToJSArrayiterates elements → recursiveToJSVal→ dispatches onTdouble→aOut.setDouble(poison)(line 653).JS::Value::asBits_is now0xFFFE414141414141, tagged asObject.ToJSArraycallsJS_DefineElement(cx, array, 0, value, JSPROP_ENUMERATE)(line 524).- Nightly/fuzzing path (
JS_CRASH_DIAGNOSTICS):DefineDataElement→cx->check(obj, value)→ContextChecks::check(Value)seesv.isObject()→ callsv.toObject().compartment()→shape()→headerPtr()→ dereferences0x414141414141. - Release path (no diagnostics):
cx->check()is a no-op; value flows toDefineDataPropertyById→ GC write barrierValuePostWriteBarrier(/firefox/js/src/gc/Barrier.h:386) →next.isGCThing()is true →next.toGCThing()->storeBuffer()dereferences0x414141414141.
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)— directionboth: - Child sender:
WindowGlobalChild::SendRawMessage(auto-generated inPWindowGlobalChild.cpp) - Parent receiver:
WindowGlobalParent::RecvRawMessage(/firefox/dom/ipc/WindowGlobalParent.cpp:565) - Manager chain:
PWindowGlobal→PBrowser→PContent(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.
| Reporter | ||
Comment 1•3 months ago
|
||
| Reporter | ||
Comment 2•3 months ago
|
||
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Comment 3•3 months ago
|
||
Oops, that's bad. StructuredClone does appear to do the right thing, as you'd expect.
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Updated•3 months ago
|
| Assignee | ||
Comment 4•3 months ago
|
||
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.
| Assignee | ||
Comment 5•3 months ago
|
||
| Assignee | ||
Comment 6•3 months ago
|
||
Comment 7•3 months ago
|
||
(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) andPickle::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.
| Assignee | ||
Comment 8•3 months ago
|
||
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
Updated•3 months ago
|
Comment 9•3 months ago
|
||
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
Updated•3 months ago
|
Comment 10•3 months ago
|
||
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
| Assignee | ||
Comment 11•3 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D286819
Comment 12•3 months ago
|
||
Comment 13•3 months ago
|
||
Updated•3 months ago
|
Updated•3 months ago
|
Comment 14•3 months ago
|
||
| uplift | ||
| Assignee | ||
Comment 15•3 months ago
|
||
In a review comment, jandem said "This is a great find." and I agree.
Updated•3 months ago
|
Updated•3 months ago
|
Comment 16•1 month ago
|
||
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.
Comment 17•1 month ago
|
||
| Assignee | ||
Updated•1 month ago
|
Comment 18•1 month ago
|
||
Updated•1 month ago
|
Description
•