799e388d2e [JSC] ExpressionInfo::Encoder::adjustInstPC should take an index instead of a pointer
Triage note: The old raw pointer into m_expressionInfoEncodedInfo dangles when the vector reallocates on append (huge-source test), a use-after-realloc.
Contents
The bug at a glance
Medium (memory-safety hardening in JSC bytecode metadata handling). OBSERVED: ExpressionInfo::Encoder::adjustInstPC previously held a raw EncodedInfo* pointing into the m_expressionInfoEncodedInfo Vector’s backing buffer, and in its MultiWide path it calls m_expressionInfoEncodedInfo.append(…), which can reallocate that buffer, after which the function keeps reading and writing through the now-dangling pointer (firstInfo[i], *firstInfo). That is a genuine use-after-realloc write primitive inside the compiler. INFERRED: reachability requires generating bytecode whose expression-info stream is large enough to hit the reallocation during remapping (the added test uses a generator with ~9,000,000 spaces of padding to force a multi-wide instPC delta and vector growth). It is compiler-internal state not directly attacker-shaped, so rated medium rather than high, but the corruption is real.
adjustInstPC cached a pointer into a Vector and then called append() on that same Vector; the append reallocated the backing store, and every subsequent dereference of the cached pointer read/wrote freed memory.
Root cause
ExpressionInfo maps bytecode instruction program counters to source expression ranges, stored as a compact variable-width stream of EncodedInfo words in the Vector m_expressionInfoEncodedInfo. During remap() (used when bytecode is rewritten, e.g. when instruction PCs shift), the encoder walks the stream via a decoder and, for each entry whose instPC must change, calls adjustInstPC() to rewrite that entry in place.
Before the fix, adjustInstPC(EncodedInfo* info, unsigned instPCDelta) received a raw pointer info that aliased an element inside m_expressionInfoEncodedInfo’s backing buffer, and derived unsigned infoIndex = info - &m_expressionInfoEncodedInfo[0] plus auto* firstInfo = info. Most branches simply overwrite *firstInfo with a re-encoded word, which is fine. The dangerous branch is the MultiWide / extension path: when the updated instPC no longer fits in the compact single/duo encoding, the code must widen the entry, which grows the stream via m_expressionInfoEncodedInfo.append({ firstValue }) followed by a loop of further append() calls.
WTF::Vector::append() may exceed capacity and reallocate the entire backing buffer to a new address, freeing the old one. But firstInfo (and firstInfo[i], firstInfo[numberOfFields]) still point into the old, freed buffer. The loop then executes m_expressionInfoEncodedInfo.append(firstInfo[i]) — reading freed memory — and firstInfo[i] = encodeSingle(FieldID::InstPC, 0) — writing freed memory. It also does firstValue = firstInfo[numberOfFields].value and firstInfo[numberOfFields] = … through the stale pointer. This is a use-after-free read/write into the reallocated buffer’s former location.
The fix changes adjustInstPC to take an unsigned infoIndex instead of a pointer. All accesses become m_expressionInfoEncodedInfo[infoIndex], m_expressionInfoEncodedInfo[infoIndex + i], m_expressionInfoEncodedInfo[infoIndex + numberOfFields] — indexed accesses that recompute the address from the Vector’s current base pointer on every use, so they remain valid across the append-induced reallocation. The single caller in ExpressionInfoInlines.h is updated to compute the index from decoder.currentInfo() - m_expressionInfoEncodedInfo.begin() before the call, and it already re-derives endInfo after adjustInstPC because ‘adjustInstPC() may have resized and reallocated m_expressionInfoEncodedInfo.’
Key code
MultiWide path rewritten to index the Vector instead of the freed alias
m_expressionInfoEncodedInfo.append({ firstValue }); // MultiWide header.
for (unsigned i = 1; i < numberOfFields; ++i) {
- m_expressionInfoEncodedInfo.append(firstInfo[i]);
- firstInfo[i] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
+ auto fieldValue = m_expressionInfoEncodedInfo[infoIndex + i];
+ m_expressionInfoEncodedInfo.append(fieldValue);
+ m_expressionInfoEncodedInfo[infoIndex + i] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
}
// Save the last field in firstValue, and let the extension emitter below append it.
- firstValue = firstInfo[numberOfFields].value;
- firstInfo[numberOfFields] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
+ firstValue = m_expressionInfoEncodedInfo[infoIndex + numberOfFields].value;
+ m_expressionInfoEncodedInfo[infoIndex + numberOfFields] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
Patch walkthrough
JSTests/stress/generator-expression-info-multiwide-remap.js— Added regression test. A generator function with several a.b property accesses and a huge ‘ ’.repeat(9000000) whitespace pad forces the expression-info stream to encode a large instPC that must widen into a MultiWide entry during remap, triggering the append/reallocation that exposed the dangling pointer. It runs the generator (it.next() twice) inside eval to build and execute the affected bytecode.Source/JavaScriptCore/bytecode/ExpressionInfo.cpp— adjustInstPC’s signature changes from (EncodedInfo* info, unsigned) to (unsigned infoIndex, unsigned). The internal infoIndex computation and firstInfo alias are removed; every read/write is rewritten to index m_expressionInfoEncodedInfo directly — including the MultiWide loop’s firstInfo[i] and firstInfo[numberOfFields] accesses, which are the ones that dangled after append().Source/JavaScriptCore/bytecode/ExpressionInfo.h— Declaration updated to adjustInstPC(unsigned infoIndex, unsigned instPCDelta), matching the pointer-to-index change.Source/JavaScriptCore/bytecode/ExpressionInfoInlines.h— The caller in remap() computes unsigned infoIndex = static_cast<unsigned>(decoder.currentInfo() - m_expressionInfoEncodedInfo.begin()) and passes the index. The existing comment/recompute of endInfo after the call is retained, confirming reallocation is expected.
Background
ExpressionInfo — JSC bytecode metadata mapping instruction PCs to source expression positions, used for error stacks and debugging; stored as a compact variable-width EncodedInfo stream.
m_expressionInfoEncodedInfo — WTF::Vector<EncodedInfo> backing buffer for the encoded stream; append() can reallocate it, invalidating any raw pointers into the old buffer.
adjustInstPC / MultiWide encoding — Rewrites an entry’s instruction PC during remap; when the new value no longer fits the compact form it widens the entry, appending new words — the operation that grows and may reallocate the Vector.
remap() — Rewrites the expression-info stream when bytecode PCs shift; its loop already anticipates reallocation by recomputing endInfo after adjustInstPC returns.
Use-after-realloc — A pointer cached into a Vector’s storage becomes dangling once append() reallocates; subsequent dereference reads/writes freed memory.
Vulnerability window
- Design — adjustInstPC takes a raw EncodedInfo* into the stream and rewrites entries in place.
- Latent hazard — The MultiWide branch calls m_expressionInfoEncodedInfo.append(), which can reallocate the buffer while the raw pointer is still in use.
- Trigger — A generator with ~9M chars of padding produces an instPC delta large enough to force widening and Vector growth during remap.
- Corruption — After append reallocates, firstInfo[i] reads and firstInfo[i] = … writes hit the freed old buffer — UAF read/write.
- Fix (313990@main) — Signature changed to an unsigned index; all accesses recompute from the current base, surviving reallocation.
Proof of concept
Verbatim added test JSTests/stress/generator-expression-info-multiwide-remap.js. The 9,000,000-space pad between the yield and the final a.b forces a very large instruction-PC delta so that, during remap, the corresponding expression-info entry must be widened into a MultiWide encoding, invoking m_expressionInfoEncodedInfo.append() and reallocating the backing Vector — exercising exactly the path where the old code dereferenced the now-dangling firstInfo pointer.
let code = `
function* gen(a) {
a.b;
a.b;
a.b;
a.b;
a.b;
yield 1;
` + " ".repeat(9000000) + `a.b;
}
let it = gen({});
it.next();
it.next();
`;
try {
eval(code);
print("Done");
} catch(e) {
print("Error: " + e);
}
Exploitation
- Shape the metadata — Craft source (e.g. a generator with massive interior padding) so the expression-info stream contains an instPC that overflows the compact encoding during remap.
- Force reallocation — The widening append() grows m_expressionInfoEncodedInfo past capacity, freeing the old backing buffer while adjustInstPC still holds a pointer into it.
- UAF read/write — Pre-fix, the MultiWide loop reads freed words (append(firstInfo[i])) and writes freed memory (firstInfo[i] = …), corrupting the reallocated heap region — a compiler-internal, but real, memory-safety violation.
Detection & hunting
For defenders and SOC / detection engineers:
- ASan on ExpressionInfo::Encoder::adjustInstPC —
- Vector reallocation while raw element pointers are live —
Audit directions
- Raw pointers into WTF::Vector storage across mutation —
- Other in-place encoders —
- remap reallocation assumptions —