Type confusion via bytecode stack misalignment in [@ DecoratorEmitter::emitCreateDecoratorContextObject] when decorator key is a computed property containing a function expression
Categories
(Core :: JavaScript Engine, defect, P2)
Tracking
()
People
(Reporter: bugmon, Assigned: arai)
References
(Blocks 2 open bugs)
Details
(6 keywords)
Attachments
(4 files, 3 obsolete files)
Summary
Vulnerability type: Type confusion (JS::Value bits used as NativeObject* pointer)
Affected file: js/src/frontend/DecoratorEmitter.cpp, line 1320 (root cause); js/src/frontend/FunctionEmitter.cpp, lines 104–108 (broken invariant)
Root cause: DecoratorEmitter::emitCreateDecoratorContextObject re-emits a computed property key ParseNode subtree that was already emitted once by BytecodeEmitter::emitPropertyList. When this subtree contains a FunctionNode, the second emission hits the wasEmittedByEnclosingScript() fast-path in BytecodeEmitter::emitFunction, which calls FunctionEmitter::emitAgain(). For non-Annex-B, non-hoisted function expressions, emitAgain() returns true without emitting any bytecode — no value is pushed onto the expression stack. The subsequent JSOp::ToPropertyKey (emitted by emitComputedPropertyName) consumes the wrong stack slot, and the one-slot deficit propagates through the rest of the bytecode. At the outer array literal, JSOp::InitElemArray reads a misaligned slot and treats its raw bits as an object pointer.
Precondition: Requires the build to be configured with --enable-decorators (default: off; js/moz.configure:122). This code is not compiled into production Firefox.
Affected Code
File: js/src/frontend/DecoratorEmitter.cpp, lines 1315–1323
if (key->is<NameNode>()) {
if (!bce_->emitStringOp(JSOp::String, key->as<NameNode>().atom())) {
return false;
}
} else {
if (!emitPropertyKey(key)) { // Re-emits the ParseNode tree that was
return false; // already emitted at BytecodeEmitter.cpp:9792
}
}
emitPropertyKey → emitComputedPropertyName:
bool BytecodeEmitter::emitComputedPropertyName(UnaryNode* computedPropName) {
MOZ_ASSERT(computedPropName->isKind(ParseNodeKind::ComputedName));
return emitTree(computedPropName->kid()) && emit1(JSOp::ToPropertyKey);
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ re-emits the FunctionNode
}
File: js/src/frontend/BytecodeEmitter.cpp, lines 6190–6195
if (funbox->wasEmittedByEnclosingScript()) {
if (!fe.emitAgain()) {
// [stack]
return false;
}
MOZ_ASSERT(funNode->functionIsHoisted()); // debug-only! not enforced in release
}
File: js/src/frontend/FunctionEmitter.cpp, lines 96–109
bool FunctionEmitter::emitAgain() {
MOZ_ASSERT(state_ == State::Start);
MOZ_ASSERT(funbox_->wasEmittedByEnclosingScript());
// [stack]
// Annex B block-scoped functions are hoisted like any other assignment
// that assigns the function to the outer 'var' binding.
if (!funbox_->isAnnexB) {
#ifdef DEBUG
state_ = State::End;
#endif
return true; // Returns WITHOUT emitting anything — no JSOp::Lambda, nothing pushed
}
Why this is vulnerable: The decorators proposal requires the computed property key to be evaluated once and reused for both the method installation and the decorator context's name property. The current implementation re-evaluates the key expression by re-emitting the same ParseNode tree. This is fundamentally unsound: ParseNode trees are designed for single emission, and nodes like FunctionNode carry state (wasEmittedByEnclosingScript) that makes re-emission a no-op. In debug builds, the MOZ_ASSERT(funNode->functionIsHoisted()) at BytecodeEmitter.cpp:6195 would catch this; in release builds, the bytecode is silently emitted with a stack deficit.
Correct Pattern
The computed key should be evaluated once and stored (e.g., via JSOp::Dup or a stack pick/unpick, or by storing to a temporary slot) and the stored value should be reused when building the decorator context object:
// At the original computed key emission site (BytecodeEmitter::emitPropertyList),
// when decorators are present, dup the key after ToPropertyKey and stash it:
// [stack] ... KEY
// Dup
// [stack] ... KEY KEY
// <save one copy for decorator context>
// In emitCreateDecoratorContextObject, instead of re-emitting the expression:
if (key->is<NameNode>()) {
if (!bce_->emitStringOp(JSOp::String, key->as<NameNode>().atom())) {
return false;
}
} else if (key->isKind(ParseNodeKind::NumberExpr)) {
// Number literals are safe to re-emit (no side effects, no state)
if (!bce_->emitNumberOp(key->as<NumericLiteral>().value())) {
return false;
}
} else {
// For ComputedName: retrieve the previously-computed key from the stack
// (requires the caller to have stashed it with pickN/dupAt)
if (!bce_->emitDupAt(/* depth of stashed key */)) {
return false;
}
}
Alternatively, the bytecode emitter should assert (via MOZ_RELEASE_ASSERT) that a FunctionNode in expression position is never reached with wasEmittedByEnclosingScript() == true.
Exploit Chain
- Attacker supplies JS:
[class { @dec [function(){}]() {} }] - Parser builds a
ClassMemberListwith aClassMethodwhose key isComputedName(FunctionNode) BytecodeEmitter::emitPropertyListcallsemitTree(key->kid())— theFunctionNodeis emitted,JSOp::Lambdais pushed, andfunbox->wasEmittedByEnclosingScriptis set totrue- After installing the method, decorator application begins.
DecoratorEmitter::emitCallDecoratorForElementcallsemitCreateDecoratorContextObject(kind, key, ...) - At DecoratorEmitter.cpp:1320,
emitPropertyKey(key)is called for the non-NameNode key - This reaches
emitComputedPropertyName→emitTree(funNode)→emitFunction - Since
wasEmittedByEnclosingScript()istrue,emitAgain()is called. The function is not Annex-B, so it returnstruewithout emitting any bytecode — nothing is pushed - Back in
emitComputedPropertyName,emit1(JSOp::ToPropertyKey)is emitted. At runtime this pops the wrong stack slot (the ObjectEmitter's partially-built context object) and converts it to a property key - The emitted bytecode now has one fewer push than the bytecode-emitter's stack model expects. Every subsequent operation that references stack slots is off by one
- Class emission completes and the outer array literal emits
JSOp::InitElemArray. At runtime,REGS.sp[-2]now reads into a slot containing raw bits0x0000000000000000(which isDoubleValue(+0.0)) Value::toObject()computesasBits_ ^ JSVAL_SHIFTED_TAG_OBJECT=0 ^ 0xfffe000000000000=(JSObject*)0xfffe000000000000(unchecked in release;MOZ_ASSERT(isObject())is debug-only)- This pointer is passed via
obj.as<ArrayObject>()(unchecked cast) toInitElemArrayOperation setDenseInitializedLength→getElementsHeader()readsthis->elements_at offset0x10→ load from0xfffe000000000010→ SEGV (non-canonical address)
Security Impact
Severity: sec-high (within --enable-decorators builds only)
Attacker capability: The bytecode stack misalignment is deterministic and depends on the emitted bytecode, which in turn depends on the attacker-controlled JS source. In this minimal testcase, the misaligned sp[-2] happens to read a zero-bits slot, yielding a non-canonical pointer and a safe crash. However, the attacker fully controls the surrounding JS structure (the class body, additional statements before/after, the outer expression context). By crafting the surrounding code, the attacker may be able to place an arbitrary ObjectValue in the slot that InitElemArray reads — in which case toObject() would correctly extract the payload pointer, and the subsequent unchecked as<ArrayObject>() cast would allow setDenseInitializedLength(index+1) and initDenseElement(index, val) to operate on a non-Array object. If the target object's elements_ field overlaps with attacker-influenced memory, this becomes a controlled write. Additionally, other opcodes (InitElemInc, property initializers) reached during the misaligned window may offer alternative exploitation paths.
Preconditions:
- Build configured with
--enable-decorators(default off; not shipped in production Firefox) - No runtime pref required — if compiled in, the syntax is parsed unconditionally
- No JIT required — reproduces in the bytecode interpreter on first execution
Mitigating factors: The feature is compile-time gated and not present in release Firefox. A debug build would catch this at MOZ_ASSERT(funNode->functionIsHoisted()) (BytecodeEmitter.cpp:6195).
ASAN Report
AddressSanitizer:DEADLYSIGNAL
=================================================================
==412717==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x56c082ece2b5 bp 0x7fff2dd7a630 sp 0x7fff2dd79780 T0)
==412717==The signal is caused by a READ memory access.
==412717==Hint: this fault was caused by a dereference of a high value address (see register values below). Disassemble the provided pc to learn which register was used.
#0 0x56c082ece2b5 in js::NativeObject::getElementsHeader() const /firefox/js/src/vm/NativeObject.h:1470:41
#1 0x56c082ece2b5 in js::NativeObject::setDenseInitializedLengthInternal(unsigned int) /firefox/js/src/vm/NativeObject.h:1532:37
#2 0x56c082ece2b5 in js::NativeObject::setDenseInitializedLength(unsigned int) /firefox/js/src/vm/NativeObject.h:1539:5
#3 0x56c082ece2b5 in js::InitElemArrayOperation(JSContext*, unsigned char*, JS::Handle<js::ArrayObject*>, JS::Handle<JS::Value>) /firefox/js/src/vm/Interpreter-inl.h:916:8
#4 0x56c082ece2b5 in js::Interpret(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:3980:7
#5 0x56c082eb82a6 in MaybeEnterInterpreterTrampoline(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:384:10
#6 0x56c082eb82a6 in js::RunScript(JSContext*, js::RunState&) /firefox/js/src/vm/Interpreter.cpp:460:13
#7 0x56c082ebcee7 in js::ExecuteKernel(JSContext*, JS::Handle<JSScript*>, JS::Handle<JSObject*>, js::AbstractFramePtr, JS::MutableHandle<JS::Value>) /firefox/js/src/vm/Interpreter.cpp:850:10
#8 0x56c0832400f2 in JS_ExecuteScript(JSContext*, JS::Handle<JSScript*>) /firefox/js/src/vm/CompilationAndEvaluation.cpp:572:10
#9 0x56c0813720b7 in RunFile(JSContext*, char const*, _IO_FILE*, CompileUtf8, bool, bool) /firefox/js/src/shell/js.cpp:1388:10
#10 0x56c081371780 in Process(JSContext*, char const*, bool, FileKind) /firefox/js/src/shell/js.cpp
#11 0x56c08131d385 in ProcessArgs(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12095:10
#12 0x56c08131d385 in Shell(JSContext*, js::cli::OptionParser*) /firefox/js/src/shell/js.cpp:12348:12
#13 0x56c08130ac78 in main /firefox/js/src/shell/js.cpp:12754:12
#14 0x7aeba5bd31c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
#15 0x7aeba5bd328a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 8e9fd827446c24067541ac5390e6f527fb5947bb)
#16 0x56c081213e18 in _start (/firefox/obj-x86_64-pc-linux-gnu/dist/bin/js+0x2db3e18) (BuildId: 3aba26634ffaecbc331026713f24abcf)
==412717==Register values:
rax = 0x0000000000000000 rbx = 0x000076e39bffe890 rcx = 0x0000000000000010 rdx = 0x0000780ba50e4f3b
rdi = 0x000056c084de0978 rsi = 0x0000780ba50e4f3e rbp = 0x00007fff2dd7a630 rsp = 0x00007fff2dd79780
r8 = 0x00000000fffe26d6 r9 = 0x000076e39bffe89f r10 = 0x00000edc737ffd13 r11 = 0x00000edcf37f7d10
r12 = 0x00007fff2dd7a3b0 r13 = 0x1fffc00000000002 r14 = 0xfffe000000000010 r15 = 0xfffe000000000000
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV /firefox/js/src/vm/NativeObject.h:1470:41 in js::NativeObject::getElementsHeader() const
==412717==ABORTING
Register decode: r15 = 0xfffe000000000000 = JSVAL_SHIFTED_TAG_OBJECT = (JSObject*)(0 ^ obj_tag) — the this pointer returned by toObject() on a zero-bits slot. r14 = 0xfffe000000000010 = r15 + offsetof(NativeObject, elements_) — the faulting load address.
Suggested Fix
Short-term defensive fix — upgrade the debug assertion to a release assertion so the bytecode emitter fails instead of producing corrupt bytecode:
// js/src/frontend/BytecodeEmitter.cpp:6190
if (funbox->wasEmittedByEnclosingScript()) {
// emitAgain only handles hoisted functions (Annex B var assignment).
// A function expression reaching here means the ParseNode tree is being
// emitted twice, which is unsupported.
if (!funNode->functionIsHoisted()) {
MOZ_CRASH("FunctionNode in expression position emitted twice");
}
if (!fe.emitAgain()) {
return false;
}
}
Correct fix — the decorator emitter must not re-emit computed key expressions. At the first emission point in emitPropertyList, when the class member has decorators and a computed key, dup the key after ToPropertyKey and keep it on the stack (or store it in a reserved slot). In emitCreateDecoratorContextObject, retrieve the stashed key instead of calling emitPropertyKey(key):
// js/src/frontend/DecoratorEmitter.cpp:1315
if (key->is<NameNode>()) {
if (!bce_->emitStringOp(JSOp::String, key->as<NameNode>().atom())) {
return false;
}
} else if (key->isKind(ParseNodeKind::NumberExpr)) {
if (!bce_->emitNumberOp(key->as<NumericLiteral>().value())) {
return false;
}
} else {
MOZ_ASSERT(key->isKind(ParseNodeKind::ComputedName));
// Retrieve the pre-computed key stashed at emitPropertyList time.
// Requires plumbing the stashed-key stack depth through the decorator
// emitter call chain.
if (!bce_->emitDupAt(computedKeyDepth)) {
return false;
}
}
This also fixes a spec-correctness bug: the current implementation evaluates the computed key expression twice (observable if the expression has side effects), which violates the decorators proposal semantics.
| Reporter | ||
Comment 1•6 months ago
|
||
| Reporter | ||
Comment 2•6 months ago
|
||
Updated•6 months ago
|
Comment 4•6 months ago
|
||
This requires building with --enable-decorators.
| Assignee | ||
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
| Assignee | ||
Comment 5•6 months ago
|
||
The current property key handling seems to be completely wrong,
and it's difficult to modify the current implementation to work correctly, given there are multiple loops that iterates over property keys (for example for class fields), and the decorator's loop can appear before other loop, and keeping the property key on the stack across them requires almost full rewrite.
it will require either:
- merge all decorator-related and non-decorator-related handlings into single place and single loop
- put property keys in a temporary array, beforehand, and use the array for all remaining operations
- put all property keys on the stack, and dup it for each loop
| Assignee | ||
Comment 6•6 months ago
|
||
tentative solution would be to just throw not-yet-implemented Error whenever computed property appears there.
At least the execution order and the number of exectuion are incorrect, and all other side-effectful computed properties also behaves incorrectly.
| Assignee | ||
Comment 7•6 months ago
•
|
||
The following part also doesn't handle computed properties. the string concatenation must be performed at runtime.
And just running the public-auto-accessor.js testcase hits an assertion failure, due to the propAtom being null.
// Step 3. Let privateStateDesc be the string-concatenation of name
// and " accessor storage".
StringBuilder privateStateDesc(fc_);
if (!privateStateDesc.append(this->parserAtoms(), propAtom)) {
And given that this part is executed regardless of the existence of decorators, throwing an error for computed properties isn't a reasonable option. (edit: actually, this is accessor-specific, which is a part of the decorator. so we can still throw error)
So probably we should drop the current implementation, and re-implement with the correct execution order and the semantics whenever necessary.
| Assignee | ||
Comment 8•6 months ago
|
||
so far prepared a patch stack to remove the implementation, in https://phabricator.services.mozilla.com/D289058 as WIP.
but apparently it's not linked, possibly because of WIP + sec bug combination?
Anyway, I'll wait for others' opinion for the removal.
Comment 9•6 months ago
|
||
The severity field for this bug is set to S3. However, the bug is flagged with the sec-high keyword.
:arai, could you consider increasing the severity of this security bug?
For more information, please visit BugBot documentation.
| Assignee | ||
Comment 10•6 months ago
|
||
This is disabled at build-time, and thus S3 or S4 is appropriate.
| Assignee | ||
Comment 11•6 months ago
|
||
| Assignee | ||
Comment 12•6 months ago
|
||
| Assignee | ||
Comment 13•6 months ago
|
||
Comment 14•6 months ago
|
||
In my opinion, if the implementation is that flawed, we should just remove it. If we come back to decorators, we can always reapply the parts that are still useful.
| Assignee | ||
Comment 15•6 months ago
|
||
Some context from discussion.
I'll file two separate bugs for the followings:
- computed properties gets evaluated multiple times (the underlying issue of the comment #0, where property key node is emitted multiple times)
- accessor name concatenation fails on computed properties (comment #7's case)
Given that the decorator code is disabled at compile time and has no effect on the distributed Firefox binaries,
they can be public bugs.
(so, please don't dupe them against this bug)
For this bug, we could do one of the following:
- disable the build option
- throw "not-yet-implemented" error when computed property appears for decorator/accessor
- throw an error on --fuzzing-safe whenever decorator/accessor related keyword appears
| Assignee | ||
Comment 17•6 months ago
|
||
| Assignee | ||
Comment 18•6 months ago
|
||
Updated•6 months ago
|
Updated•6 months ago
|
Updated•6 months ago
|
| Assignee | ||
Comment 19•6 months ago
|
||
https://wiki.mozilla.org/Security_Severity_Ratings/Client
according to the severity rating, we can reduce the severity for unusual configuration.
this is build-time configuration and won't affect many users (only people who's building the browser by their own with custom configuration).
And thus I'm reducing the rating from sec-high to sec-moderate.
and given it's sec-moderate and disabled by build-time configuration, I'll skip the security approval process.
Comment 20•6 months ago
|
||
Comment 21•6 months ago
|
||
https://hg-edge.mozilla.org/mozilla-central/rev/a8cf453219de
https://hg.mozilla.org/mozilla-central/rev/cba979f5bb3d
Updated•5 months ago
|
Updated•5 months ago
|
Updated•27 days ago
|
Description
•