← All issues

[2] IPInt PC desynchronization via variable-length LEB128 sub-opcodes

Severity: High | Component: JSC IPInt (In-Place Interpreter) | 0acf64e

Rated High because the observable effect is interpreter desynchronization causing the Wasm in-place interpreter to execute wrong instructions with wrong metadata from valid bytecode, and the attacker has significant control over the post-desynchronization instruction stream — the projection to type confusion primitives is at confidence 0.88, with the specific pre-patch advancePC constants inferred from the commit message and test case rather than directly visible in the diff.

Prefixed opcodes in IPInt (GC prefix 0xFB, SIMD prefix 0xFD) have sub-opcodes encoded as VarUInt32 LEB128. IPInt used hardcoded advancePC(constant) to skip past them, but LEB128 allows the same value to be encoded in variable numbers of bytes. The fix tracks dynamic instruction lengths for prefixed opcodes.

JSTests/wasm/stress/ipint-variable-length-gc-opcodes.js

+ // Helper function to create redundant LEB128 encoding of a value
+ function createRedundantLEB128(value, totalBytes) {
+ if (totalBytes === 1) {
+ return [value];
+ }
+ let result = [];
+ for (let i = 0; i < totalBytes - 1; i++) {
+ if (i === 0) {
+ result.push(value | 0x80); // First byte with continuation bit
+ } else {
+ result.push(0x80); // Middle bytes: just continuation bit
+ }
+ }
+ result.push(0x00); // Final byte: no continuation bit, value 0
+ return result;
+ }
+ // Test ref.i31 with 2-byte redundant encoding
+ {
+ let extendedOp = createRedundantLEB128(0x1C, 2); // ref.i31 redundant: [0x9C, 0x00]
+ let codeBody = [
+ 0x00, // 0 locals
+ 0x41, 0x2A, // i32.const 42
+ 0xFB, ...extendedOp, // ref.i31 with redundant encoding
+ 0x0B // end
+ ];
+ let module = new WebAssembly.Module(bytes);
+ let instance = new WebAssembly.Instance(module);
+ assert.eq(instance.exports.f(), 42);
+ }

The patch modifies the IPInt generator (WasmIPIntGenerator.cpp) to record dynamic PC advancement for all GC and SIMD prefixed opcode handlers — functions including addRefI31, addI31GetS, addI31GetU, addArrayLen, addArrayFill, addArrayCopy, addAnyConvertExtern, addExternConvertAny, and the SIMD variants. The calling convention in WasmFunctionParser.h::simd() is updated to pass instruction length information. Several generator methods are renamed across all JIT tiers (BBQ, OMG, ConstExpr) to match the new convention: addExtractLaneaddSIMDExtractLane, addReplaceLaneaddSIMDReplaceLane, addConstantaddSIMDConstant. The InPlaceInterpreter64.asm dispatch logic is also updated. A regression test constructs Wasm modules with redundant LEB128-encoded sub-opcodes and verifies correct execution.

Before:                                  After:
Wasm bytecode: [0xFB, 0x9C, 0x00, ...]  Wasm bytecode: [0xFB, 0x9C, 0x00, ...]

IPInt PC ──► advancePC(2)                IPInt PC ──► advancePC(dynamicLen)
             skips 2 bytes                            skips 3 bytes (actual encoding)
             PC lands on 0x00 ← WRONG                 PC lands on next opcode ← CORRECT
             next decode: garbage                      next decode: correct instruction

Fixed-length PC advancement for variable-length LEB128-encoded sub-opcodes in the Wasm in-place interpreter.

WebAssembly prefixed opcodes use a two-level dispatch: a prefix byte identifies the opcode group (0xFB for GC/reference types, 0xFD for SIMD), followed by a sub-opcode that identifies the specific instruction within that group. The sub-opcode is encoded as a VarUInt32 using LEB128 — a variable-length integer encoding where each byte uses 7 bits for data and 1 bit (the high bit) as a continuation flag. LEB128 permits redundant encodings: any value can be padded with extra continuation bytes carrying zero payload. For example, the value 28 (ref.i31) can be encoded as [0x1C] (1 byte), [0x9C, 0x00] (2 bytes), or [0x9C, 0x80, 0x00] (3 bytes). All encodings are semantically equivalent and valid per the Wasm spec.

IPInt (In-Place Interpreter) is JSC's lowest-tier Wasm execution engine — it interprets Wasm bytecode directly without compilation, used for cold code and initial execution before tiering up to BBQ or OMG JIT. IPInt maintains two cursors: PC (program counter, pointing into the Wasm bytecode) and MC (metadata counter, pointing into pre-generated metadata that contains decoded constants, branch targets, and instruction lengths). These two cursors must remain synchronized — each instruction advances both by the correct amount. If they desynchronize, subsequent instructions read wrong metadata, leading to incorrect execution.

The validation pass in WasmFunctionParser correctly handles variable-length LEB128 by parsing the actual byte count. It is only the IPInt execution tier that used hardcoded constants.

The root cause is a mismatch between IPInt's hardcoded PC advancement and the actual variable-length encoding of prefixed sub-opcodes. Before the fix, handlers for GC opcodes like ref.i31 used advancePC(2) — assuming a 1-byte prefix plus a 1-byte sub-opcode. When the sub-opcode was encoded with redundant LEB128 bytes (e.g., [0xFB, 0x9C, 0x00] instead of [0xFB, 0x1C]), the interpreter advanced PC by 2 but the actual instruction was 3 bytes. PC landed in the middle of the current instruction's trailing LEB128 bytes, and every subsequent instruction was decoded from the wrong offset.

This is an interpreter desynchronization bug. The PC and MC cursors diverge: MC advances correctly (if MC advancement is independent of PC length, as the IPInt architecture suggests), while PC is stuck one or more bytes behind. Each subsequent decode reads bytecode at an offset shifted by the desynchronization amount, interpreting arbitrary bytes as opcodes and operands while consuming metadata intended for entirely different instructions.

🔒

Explores the PC/MC desynchronization mechanism in depth and assesses what primitives an attacker could construct from misaligned interpreter state

Subscribe to read more

🔒

Multiple reusable audit patterns identified for interpreter cursor synchronization bugs, with concrete grep targets across Wasm execution tiers

Subscribe to read more