← All reports

[4] [JSC] ExpressionInfo::Encoder::adjustInstPC should take an index instead of a pointer

MediumJSC bytecodeUAF

The one loop that never got the memo that its own vector reallocates.

799e388

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 raw EncodedInfo* pointer that aliased m_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

-void ExpressionInfo::Encoder::adjustInstPC(EncodedInfo* info, unsigned instPCDelta)
+void ExpressionInfo::Encoder::adjustInstPC(unsigned infoIndex, unsigned instPCDelta)
{
- unsigned infoIndex = info - &m_expressionInfoEncodedInfo[0];
- auto* firstInfo = info;
- unsigned firstValue = firstInfo->value;
+ unsigned firstValue = m_expressionInfoEncodedInfo[infoIndex].value;
...
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.
goto emitExtension;

JSTests/stress/generator-expression-info-multiwide-remap.js

+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);
+}

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.

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.

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:

  1. function* gen(a) makes the function a generator, so applyModification() runs generatorification and Encoder::remap() is invoked with a non-empty adjustment label point list — the precondition for adjustInstPC() running at all.
  2. The five leading a.b; statements and the yield 1; seed several ExpressionInfo entries with small deltas near the label points, so entries exist at and immediately after the remap boundaries.
  3. " ".repeat(9000000) places the final a.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-bit singleValueBits capacity of 8388608 and far past the 10-bit duoValueBits. Since startOffset/endOffset are 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 steer adjustInstPC() into the MultiWide relocation branch rather than the Single/Duo/Basic branches, all of which touch only one word and return before any append().
  4. Inside that branch, the first m_expressionInfoEncodedInfo.append({ firstValue }) grows the vector; if this append crosses the capacity boundary the old buffer is freed and firstInfo becomes 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.
  5. The loop then reads firstInfo[i] from freed memory and writes encodeSingle(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.

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.