d8
The runner reads a binary blob, wraps it in v8::ScriptCompiler::CachedData, and executes it as a V8 code cache. I burned most of day two on this and finally got it around midnight for second blood.
Code caches are meant to come from V8, so the interpreter treats their bytecode operands as trusted. Here we can edit the cache first. I ended up abusing an out-of-bounds operand in CreateArrayLiteral, using it to materialize a fake object, and then taking the fairly standard V8 route to code execution.
Generating a compatible code cache
Before modifying bytecode, we need a cache blob the challenge runner will accept. Older documentation mentions kProduceCodeCache and GetCodeCache, but neither exists in the supplied V8 version. The useful example is in test-api.cc: compile and run the script, then call v8::ScriptCompiler::CreateCodeCache().
The order matters. V8 compiles some functions lazily, so calling CreateCodeCache() before script->Run(context) leaves those functions out of the blob.
v8::Local<v8::Script> script =
v8::ScriptCompiler::Compile(context, &source).ToLocalChecked();
script->Run(context).ToLocalChecked();
std::unique_ptr<v8::ScriptCompiler::CachedData> cache(
v8::ScriptCompiler::CreateCodeCache(
script->GetUnboundScript()
)
);
I also enabled command-line flags through v8::V8::SetFlagsFromCommandLine(). That lets the source use native helpers such as %DebugPrint. The helper is compiled into the bytecode, so its output remains available when the cache later runs inside the challenge binary.
The runner pairs our cache with an empty source string. V8 checks that the source hash inside the cache matches the supplied source and rejects a mismatch. During debugging I found the stored four-byte hash at offset +8; the hash for an empty script is zero, so those bytes have to be patched to zero.
Debug and release caches are not interchangeable either. A cache produced by one build is rejected by the other. The debug build additionally enables FLAG_verify_snapshot_checksum, so for local debugging I disabled that check in SerializedCodeData::SanityCheckWithoutSource.
$ ./gen exp.js --allow-natives-syntax --print-bytecode
# writes the cache to blob.bin
# patch the source hash at blob.bin + 8 to 0
--print-bytecode is especially useful because the same instruction sequence appears inside the generated blob. At this point we can create a valid cache, find instructions in it and verify that the patched cache still executes in runner.cc.
Choosing a bytecode primitive
My first hope was that JIT machine code might be stored in the cache. If that were true, patching cached native code into shellcode would make the challenge very short. It did not work that way, so I moved down to interpreter bytecode.
Random byte changes crash V8 easily, but a crash is not yet a controllable primitive. The instruction that finally worked was CreateArrayLiteral. Consider a small function:
function foo() {
const value = [[], 1.1, 0x123];
return value[0];
}
foo();
readline();
V8 produces bytecode beginning with:
79 00 00 04 CreateArrayLiteral [0], [0], #4
c4 Star0
0c LdaZero
2f fa 01 GetKeyedProperty r0, [1]
a9 Return
The first operand is an index into a FixedArray associated with the function. Entry zero points to an ArrayBoilerplateDescription.
0x1b1f00253b31: [FixedArray]
- length: 1
0: 0x1b1f00253b25 <ArrayBoilerplateDescription ...>
The description contains a constant elements pointer to another fixed array holding the values used to initialize each new JavaScript array. Nested array literals are represented by another boilerplate description instead of an already-created JSArray, because every execution needs a fresh nested object.
0x1b1f00253b25: [ArrayBoilerplateDescription]
- elements kind: PACKED_ELEMENTS
- constant elements: 0x1b1f00253af9 <FixedArray[3]>
0x1b1f00253af9: [FixedArray]
0: <ArrayBoilerplateDescription for []>
1: <HeapNumber 1.1>
2: 291
I manually replaced one constant-element pointer with the address of an existing JavaScript object. The resulting array contained that object, confirming an important property: if we can make CreateArrayLiteral consume a fake boilerplate description, we can make it materialize a fake object.
Controlling memory after the victim array
The next problem is placing controlled bytes where an out-of-bounds index will read them. The operand indexes a FixedArray in OldSpace. I first tried allocating a JavaScript double array and forcing garbage collection so its elements would also move into OldSpace, but the elements ended up too far from the victim.
The constant-element storage belonging to array literals is placed much closer. For the victim function it appears before the operand array, which does not help with a positive out-of-bounds index. A second function declared after the victim solves the layout problem: its constant elements can be allocated after the victim’s FixedArray.
If the second literal contains only doubles, V8 stores it as a FixedDoubleArray. Those values are unboxed, so each JavaScript double gives direct control over eight bytes near the out-of-bounds read target.
function victim() {
const target = [[], 1.1, 0x123];
return target[0];
}
function spray() {
const controlled = [
1.0434666440167127e-310,
1.0434666440167127e-310,
// encoded fake fields
];
return controlled;
}
I filled the controlled doubles with A patterns, inspected the memory after the victim array in the debug gen process and calculated a candidate operand index. The exact layout is not identical in runner.cc, but it is close enough to tune experimentally. When the release runner crashes at an address shaped like 0x????41414141, the patched index has reached our data.
Building the fake object
With the out-of-bounds index established, the final sequence is:
- Allocate a large double array. Under V8 pointer compression, the low 32 bits of its element address are stable enough to use in compressed pointers.
- Place fake
ArrayBoilerplateDescription,FixedArrayandUint32Arraystructures inside that controlled backing store. Pointers to built-in maps also have predictable low 32 bits in the compressed heap cage. - Use a second literal’s
FixedDoubleArrayto spray the compressed pointer to the fake boilerplate immediately after the victim operand array. - Patch the victim’s
CreateArrayLiteralindex in the cache blob so it reads the sprayed pointer out of bounds. - Call the victim. V8 trusts the fake description and returns an object backed by our forged fields.
Once the fake typed array exists, the rest is familiar V8 work: forged backing-store fields give arbitrary reads and writes, then we find executable memory and redirect execution. The weird part was getting the first fake object out of bytecode V8 assumed could not be hostile.
The final workflow is to compile the JavaScript exploit into a cache, use a Python script to locate and patch the CreateArrayLiteral instruction, zero the source hash and send the modified blob to the runner.