[4] [JSC] ExpressionInfo::Encoder::adjustInstPC should take an index instead of a pointer
The one loop that never got the memo that its own vector reallocates.
Rated Medium — the diff establishes a genuine read/write through a dangling heap pointer, reachable from a short script with no JIT warm-up. It stops short of High because the written value is a fixed encoding constant with no attacker control, and reclaiming the freed buffer inside this synchronous, allocation-poor window would take real allocator grooming.
C++ containers that grow by reallocating invalidate every raw pointer into their old buffer. That is a language-level fact, and code that walks a growable buffer while appending to it has to re-derive addresses rather than cache them. JSC stores source-position metadata — the instruction PC, divot, start/end offsets, line and column for each bytecode — in a delta-compressed stream of 32-bit words rather than fixed-size records, because the data only matters for error messages and stack traces. After a generator function's bytecode is rewritten to insert its resume machinery, every recorded instruction PC in that stream has to be remapped, and the remapper is allowed to grow the stream while doing so.
The angle: a page containing a suitably shaped generator function drives reads of freed heap words back into live bytecode metadata and fixed-constant writes into a just-freed buffer, during ordinary script compilation.
From the commit message:
adjustInstPC()previously took a rawEncodedInfo*pointer that aliasedm_expressionInfoEncodedInfo's backing buffer. This is fragile since the pointer can become invalid if the vector is resized.Change
adjustInstPC()to take an unsigned index instead. An integer index remains valid across reallocations since vector element access always recomputes the address from the current base pointer.
Source/JavaScriptCore/bytecode/ExpressionInfo.cpp
JSTests/stress/generator-expression-info-multiwide-remap.js
Patch Details
ExpressionInfo::Encoder::adjustInstPC() changes from taking a raw EncodedInfo* into the encoder's own Vector<EncodedInfo> m_expressionInfoEncodedInfo to taking an unsigned infoIndex. All four in-place write sites that previously went through the aliasing pointer (*firstInfo = encodeAbsInstPC(...), = encodeSingle(...), = encodeDuo(...), = { replacement }) now write through m_expressionInfoEncodedInfo[infoIndex].
The load-bearing change is in the MultiWide branch. The loop that relocates a MultiWide header plus its N value words into the extension island previously did m_expressionInfoEncodedInfo.append(firstInfo[i]); firstInfo[i] = encodeSingle(FieldID::InstPC, 0);, interleaved with append() calls that can grow and reallocate that same vector. It now copies each word into a local (auto fieldValue = m_expressionInfoEncodedInfo[infoIndex + i];) before appending, and re-indexes the vector for the no-op overwrite, so both the read and the write recompute their address from the current base pointer. The same treatment is applied to the trailing firstValue = ...[infoIndex + numberOfFields].value read and its no-op overwrite.
ExpressionInfo.h updates the declaration, and ExpressionInfoInlines.h's remap() converts decoder.currentInfo() into an index (decoder.currentInfo() - m_expressionInfoEncodedInfo.begin()) at the call site — immediately before the pre-existing decoder.recacheInfo() call whose comment already documents that adjustInstPC() may reallocate the vector. A regression test is added: a generator function with a yield followed by roughly 9,000,000 spaces before a final expression.
Retaining a raw interior pointer into a growable container across an operation that appends to that same container, so a reallocation leaves the pointer aliasing freed memory.
Background
Where this lives. ExpressionInfo is JSC's compressed store of per-bytecode source-position data (instPC, divot, start/end offsets, line, column), attached to unlinked code blocks. Because this data is only needed for error messages, stack traces, and debugging, it is stored as a delta-compressed stream of 32-bit EncodedInfo words rather than 24-byte records. ExpressionInfo.h documents startOffset/endOffset as offsets relative to divot, so those two fields track the expression's own width rather than its absolute position in the source.
Encoding widths. A Basic word packs all six field deltas into 32 bits. When a delta is too large, the encoder emits Wide words first: SingleWide (one field, 23 value bits), DuoWide (two fields, 10 bits each), or MultiWide (a header word naming up to six FieldIDs, followed by that many full 32-bit value words that the decoder requires to be contiguous). MultiWide is selected when three or more fields need wide values.
Generatorification. After initial bytecode generation, UnlinkedCodeBlockGenerator::applyModification() rewrites a generator function's bytecode to insert the resume switch and the save/restore sequences. That inserts and removes instructions, so every recorded instruction PC in the ExpressionInfo stream must be remapped.
Extension islands. Rather than shifting the whole EncodedInfo vector to make room for larger InstPC encodings during remap, the encoder replaces the entry's first word in place with an Extension word pointing at an appended "extension island" past the end of the normal stream, and copies the original word or words there. For a MultiWide entry the header and all its value words must be relocated together, because the decoder requires them contiguous; the vacated slots are overwritten with SingleWide(InstPC, 0) no-ops.
WTF::Vector growth. Appending to a Vector past its capacity allocates a larger backing buffer, copies the elements, and frees the old buffer. Indices remain meaningful across this because element access recomputes the address from the current base pointer. Note that WTF::Vector's slow-path append forwards the address of the incoming value to expandCapacity(), so a self-referential v.append(v[i]) argument is handled correctly by the container itself.
Encoder::remap(). It walks the stream with an ExpressionInfo::Decoder, and for each entry needing adjustment calls adjustInstPC(), then decoder.recacheInfo() to refresh the decoder's cached EncodedInfo* bounds against the vector's current buffer.
Analysis
This is a use-after-free: a stale interior pointer into a reallocated heap buffer, with both a read and a write through it.
MultiWide relocation loop (pre-fix)
───────────────────────────────────
firstInfo ──► [ ... entry@infoIndex ... ] buffer B0 (capacity full)
│
append({ firstValue }) ──────┴──► allocate B1, copy, FREE B0
firstInfo now dangles into B0
loop i = 1..N-1:
read firstInfo[i] ──► UAF read (from freed B0)
append(that value) ──► goes into live B1
write firstInfo[i] = no-op ──► UAF write (into freed B0)
after loop:
read firstInfo[N].value ──► UAF read
write firstInfo[N] = no-op ──► UAF write
adjustInstPC() received a raw EncodedInfo* aliasing the interior of m_expressionInfoEncodedInfo, a Vector<ExpressionInfo::EncodedInfo> owned by the same Encoder object (confirmed by the member declaration in ExpressionInfo.h). The function then calls append() on that very vector while continuing to dereference the aliasing pointer. As the diagram traces, the first append can free the old backing store, after which up to six words are read out of freed memory and up to six fixed-constant words are written into it, at an offset (infoIndex, the entry's word position in the encoded stream) determined by how much expression metadata precedes that entry. The words read out of the freed buffer are appended into the live vector as MultiWide field values, and the trailing firstValue = firstInfo[numberOfFields].value read is carried into the extension emitter.
What makes this a genuine bug rather than a theoretical one is that the rest of the function was already index-based: the removed line unsigned infoIndex = info - &m_expressionInfoEncodedInfo[0]; existed precisely so the downstream extension emitter could re-index safely. Only the MultiWide relocation loop kept using the raw pointer across the mutation. The caller was likewise already aware of the hazard, calling decoder.recacheInfo(m_expressionInfoEncodedInfo) after every adjustInstPC() with the comment "adjustInstPC() may have resized and reallocated m_expressionInfoEncodedInfo" — reallocation was documented, expected behaviour of this function.
Reachability is direct from web content: the added test does nothing but eval() a generator function, so a plain <script> containing a suitably shaped generator reaches the code. Walking the trigger:
function* gen(a)makes the function a generator, soapplyModification()runs generatorification andEncoder::remap()is invoked with a non-empty adjustment label point list — the precondition foradjustInstPC()running at all.- The five leading
a.b;statements and theyield 1;seed several ExpressionInfo entries with small deltas near the label points, so entries exist at and immediately after the remap boundaries. " ".repeat(9000000)places the finala.b;roughly 9e6 characters after the previous expression on the same line, so that entry's divot delta and its column delta each jump by about 9e6 — past the 23-bitsingleValueBitscapacity of 8388608 and far past the 10-bitduoValueBits. SincestartOffset/endOffsetare documented as relative to divot, those two stay small no matter how far the divot moves; the fields plausibly forced wide are the divot and column deltas plus the instPC being adjusted, and MultiWide requires three or more wide fields. The padding's purpose is to steeradjustInstPC()into the MultiWide relocation branch rather than the Single/Duo/Basic branches, all of which touch only one word and return before anyappend().- Inside that branch, the first
m_expressionInfoEncodedInfo.append({ firstValue })grows the vector; if this append crosses the capacity boundary the old buffer is freed andfirstInfobecomes stale. Whether this particular input crosses the boundary is not observable from the supplied context — the vector's capacity state at that point is not shown. - The loop then reads
firstInfo[i]from freed memory and writesencodeSingle(FieldID::InstPC, 0)back into it.
Under ASan the immediate observable effect is a heap-use-after-free read/write report; on a production build the most likely outcome is a silent write into a free-listed buffer. For escalation, the written value is a fixed encoding constant, not attacker data, so any corruption primitive would depend on where it lands. infoIndex is the word offset of the target entry within the encoded stream, a function of how much expression metadata precedes it, which the attacker authors freely, so the offset within the stale buffer might be steerable — though the supplied context does not establish the mapping from source text to word offset. If the freed buffer were reclaimed by a live object before the stale stores execute, this would become a fixed-constant write into that object's fields at that offset; the hard part is that no script runs concurrently on this thread during bytecode post-processing, so reclamation would require grooming the allocator so that some other same-size-class allocation lands in the freed slot during this synchronous window.
Separately, the words read via firstInfo[i] and firstInfo[numberOfFields].value are appended into the live encoded stream as MultiWide field values. If those stale words were later decoded into divot/line/column, residual freed-heap bytes could surface as script-visible source positions — for example via Error.prototype.stack — which would constitute a narrow info-leak channel. This depends on how the decoder masks and accumulates those field values, and the truncated portion of ExpressionInfo.cpp supplied here does not include the decode path needed to confirm how much of a stale word would survive into an observable number.
This is JSC bytecode generation, which runs in the renderer; the code path is not IPC-facing and does not run in the GPU or Networking process, so a separate escape would still be required for anything beyond the WebContent process.
The discovery route most consistent with the artifact is fuzzing or ASan-instrumented stress testing that produced a generator with a very large intra-function source gap — " ".repeat(9000000) has the look of a reduced fuzzer artifact, since 9e6 sits just past the 2^23 boundary of singleValueBits and is what is needed to push multiple fields into wide encoding. Pattern auditing is a plausible co-discovery route: the function already contained an index-conversion line and the caller already carried a "may have resized and reallocated" comment, so an audit for surviving raw aliases in reallocation-documented functions would land on the same loop. The commit was originally landed on a Safari release branch with two separate radars, consistent with a crash report or fuzzer finding triaged before upstreaming.
This vulnerability weakens memory safety inside the WebContent process at a point reachable by ordinary script compilation, with no JIT tiering or warm-up required. The invariant at stake is that bytecode metadata encoding operates entirely within its own live allocation; before the fix, a generator whose expression metadata required MultiWide encoding could drive reads and fixed-value writes into a just-freed heap buffer during bytecode post-processing. The practical ceiling is bounded by how little attacker-controlled allocation activity can be interleaved into this synchronous, single-threaded code path.
Insight
The surrounding code already knew the hazard existed. remap() carries the comment "adjustInstPC() may have resized and reallocated m_expressionInfoEncodedInfo" and calls decoder.recacheInfo() to fix up the decoder's cached pointers, and adjustInstPC() itself opened by converting its pointer argument back into an index for the extension emitter. Every consumer of the pointer had been hardened except the one loop that both reads and writes through it. This is the recurring shape of container-invalidation bugs: the fix is applied to the pointers that are obviously long-lived — the decoder's cursor — while a short-lived local pointer inside the mutating function itself is assumed safe because the mutation is only a few lines away. Note that the fix's auto fieldValue = ...; append(fieldValue); is not a workaround for a self-referential-append hazard in WTF::Vector; that container's slow-path append forwards the address of the incoming value to expandCapacity() precisely so v.append(v[i]) stays correct. The local copy matters for a different reason: the read of the source word has to be re-derived from the current base pointer on every iteration, because a previous iteration's append may already have moved the buffer.
Audit directions
-
Self-referential container mutation. A function holds a raw interior pointer or reference into a growable container and then appends to that same container, so the mutation invalidates the alias it is still using. The invariant is a container's own contents may not be addressed by raw pointer across any operation that can grow it. Narrow: grep
Source/JavaScriptCore/bytecode/andSource/JavaScriptCore/bytecompiler/for functions that take anEncodedInfo*,Instruction*, or other element pointer as a parameter while also naming a memberVector—ExpressionInfo::Decoder's rawEncodedInfo*cursor members are cached-pointer state that only stays valid because callers manually invokerecacheInfo(), so audit every mutation site that forgets that call. Wider: the same class appears through different mechanisms —auto& ref = vec[i]locals held across a laterappend/insert/shrink, range-for loops over a container whose body appends to it, and cached.begin()/.end()sentinels captured before a growth; search for growth calls that sit between an address computation and its use on the same container. Widest: this is the general iterator/reference-invalidation class present in any language with growable buffers — C++std::vectorandabsl::InlinedVector, Rust code that works around the borrow checker with raw pointers or index-then-unsafe, Go slices where a retained&s[i]survives a reallocatingappend. Match tell on every rung: the same container identifier appears both as the base of a pointer or reference used later and as the receiver of a growth operation in between. Carry-forward question: between where this address was computed and where it is used, can the container have grown? -
Partially applied hardening. A known invalidation hazard is documented and fixed for the long-lived or obvious aliases, while a short-lived local alias inside the mutating function is left untouched. The invariant is if a function is documented as reallocating a structure, every alias to that structure inside and outside the function must be re-derived, not just the ones the caller can see. Investigate by treating comments as an index of known hazards: grep
Source/JavaScriptCore/for comments containingreallocat,may resize,invalidat, andrecache, then for each hit enumerate all aliases to the named structure in the annotated function's own body rather than only at the call site. Wider: the same shape recurs wherever a defensive helper exists —recacheInfo()-style refresh functions,reserveCapacitycalls added as an ad-hoc safety net, and any "must be called after X" contract expressed only in prose; audit whether the refresh covers all state or only the state that was failing in the original bug report. Widest: this generalizes to any codebase where a bug was fixed by patching call sites rather than the abstraction — the reusable question is whether, when this hazard was previously discovered, the fix was applied to the abstraction or only to the reporter's reproducer path. Match tell: a comment describing a hazard that sits below code in the same function that still assumes the hazard doesn't apply. -
Metadata post-processing passes that mutate a compressed stream in place while walking it. The invariant is an in-place rewriter must not hold cursors derived from the pre-mutation layout. Trace the other post-generation bytecode rewriters that run alongside generatorification — start with
UnlinkedCodeBlockGenerator::applyModification()and everything it drives (instruction stream rewriting, jump target fixups,m_expressionInfoChaptersadjustments,RareDatatables) and check each for cursors, end-sentinels, or cached sizes captured before an append or erase. Wider: the same class covers any two-phase encode-then-patch design where phase two can change the encoding width of an already-emitted record — Wasm section rewriting, source-map and debug-info emitters, and relocation fixup passes all have this shape, and the tell is a loop that reads a record's width from the record itself while another code path may have replaced that record with a wider or no-op encoding. Widest: this holds for any variable-length record format patched in place after emission (DWARF line programs, protobuf wire-format patching, ELF relocation application); the carried invariant is that if the patch can change a record's length or the buffer's base, every offset must be re-derived from the current base after every patch. Match tell: a loop that both advances a cursor through a buffer and calls a function documented to append to that buffer.