← WebKit Silent-Fix Report — 2026-W22

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

severity medium class UAF confidence 0.60 JSC bytecode ExpressionInfo exploitable-grade
Yijia Huang Wed May 27 11:58:22 2026 -0700 full: 799e388d2eb5ad657027a45b14091070d06925e8 bug report ↗ view on GitHub ↗
Primitive: dangling pointer into vector after append/realloc
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

  1. Design — adjustInstPC takes a raw EncodedInfo* into the stream and rewrites entries in place.
  2. Latent hazard — The MultiWide branch calls m_expressionInfoEncodedInfo.append(), which can reallocate the buffer while the raw pointer is still in use.
  3. Trigger — A generator with ~9M chars of padding produces an instPC delta large enough to force widening and Vector growth during remap.
  4. Corruption — After append reallocates, firstInfo[i] reads and firstInfo[i] = … writes hit the freed old buffer — UAF read/write.
  5. 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

  1. 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.
  2. Force reallocation — The widening append() grows m_expressionInfoEncodedInfo past capacity, freeing the old backing buffer while adjustInstPC still holds a pointer into it.
  3. 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

Before / after

Loading diff…