Freal World Manipulation
An IEEE754 overflow blows the index limit open, and the mincore() bounds check becomes the oracle that defeats 1 GB of brk randomisation.
pwnsec{2123d5422f630c0e} A little flashback to “Computer Organizations and Architecture” course. But, what could go wrong? Well, it’s AGI today so — @k4tou
0. First impressions
The handout is one freal binary, flag.txt and a Dockerfile. Every mitigation is on.
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
Stripped: No
Symbols survive, which is a relief. The import list alone sketches the shape of the challenge:
fesetround fegetround feclearexcept fetestexcept strtod
mincore sysconf malloc_usable_size calloc free
fesetround and mincore in the same binary is a first. It is a floating-point-rounding challenge and something checks whether memory is mapped. The Dockerfile’s last line nails it down further:
h=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n'); mv flag.txt flag-$h.txt
The flag filename is randomised, so there is no open("flag.txt") shortcut: we need a shell, or at least a directory listing.
1. Program structure
Six menu entries:
1. new decimal -> add()
2. load mantissa -> load()
3. view mantissa -> view()
4. release decimal -> destroy()
5. add decimal -> addition()
6. multiply decimal -> multiply()
Parallel arrays hang off the decimal global (image-relative 0x5060):
| Address | Contents |
|---|---|
decimal + 0x0000 | void *ptr[256] |
decimal + 0x0800 | size_t usable[256] (malloc_usable_size) |
decimal + 0x1000 | size_t len[256] |
decimal + 0x2000 | double value[256] |
decimal + 0x2800 | uint64_t bits[256] |
| … | fraction / sign / exponent / class |
0x9460 | count |
0x9468 | index limit (initially 0x100) |
0x9470 | cumulative mapped bytes |
add(limbs) does calloc(limbs, 1), records usable, then adds 0x9470 += align(usable). limbs ranges from 1 to 0x800000 (8 MB).
Index validation happens in two stages. First checked_slot:
if (idx < 0) { if (-idx <= decimal/8) return decimal + idx*8; } // reaches down to address 0
else {
if (idx <= 0xff) return decimal + idx*8;
if (idx <= (~decimal)>>3) { // guards against multiply overflow
p = decimal + idx*8;
if (p >= &_end) return p; // fine as long as it is outside our bss
}
}
return NULL; // "nan"
then checked_mantissa:
if (write_mode && idx < 0) fail;
slot = checked_slot(idx);
if ((unsigned)idx > 0xff) {
if (!mapped_range(slot, 8)) fail; // mincore
if ((signed)idx > 0xff && total_mapped <= 0x1fffffff) fail;
}
if (!slot || !(p = *slot)) fail;
if (idx < 0 && !coherent_pointer(p)) fail; // image-internal pointers only
if (idx >= limit) fail;
return p;
Two things stand out. Raise limit (0x9468) and idx can be arbitrarily large, which makes slot an arbitrary address. *slot is then used as the mantissa pointer, and:
view(idx)— prints0x100bytes frompload(idx, n)— writesn(≤0x1000) bytes top
Both only check “is it mapped” via mapped_range (mincore). That is a complete arbitrary read/write — provided we can raise limit.
2. The real bug — an overflowing multiply blows the limit open
multiply is the culprit. Cleaned up:
if (!calibrated()) fail; // total_mapped > 0x1FFFFFFF (512 MB)
r = fegetround(); feclearexcept(FE_ALL);
fesetround(parse_rounding(word));
a = value[l]; b = value[r];
fetestexcept(FE_OVERFLOW|...);
fesetround(r); // <- rounding is restored FIRST
if (isnan(a) || fabs(a) > DBL_MAX || fabs(a) < DBL_MIN) fail;
if (isnan(b) || fabs(b) > DBL_MAX || fabs(b) < DBL_MIN) fail;
if (isnan(a*b) || fabs(a*b) <= DBL_MAX) fail; // <- the product must be inf
limit = (usable[l] >> 4) * (usable[r] >> 4) * 0x80;
So the limit is only updated when both operands are normal finite values but their product overflows to inf. That is the title — “Freal World Manipulation”, manipulating reals — and it makes sense of the “Computer Organizations and Architecture flashback”: IEEE754 overflow is the exploit condition.
There were two traps.
First, the rounding mode is a decoy. mulsd executes after fesetround(original). Passing zero (FE_TOWARDZERO) makes 1e300 * 1e300 saturate to DBL_MAX rather than inf, which then fails fabs(a*b) <= DBL_MAX. The right move is to send a word that matches nothing so the mode stays FE_TONEAREST — I sent near.
Second, 512 MB has to be mapped first. calibrated() requires 0x9470 > 0x1FFFFFFF. An 8 MB calloc goes to mmap and adds align(usable) = 0x801000 each time, so 66 of them clears it comfortably. RSS does not actually grow — add writes a single '0' at the head of each chunk, touching one page apiece. We reserve 528 MB of address space purely to unlock the limit.
The limit arithmetic is neat, too. Two 8 MB chunks give
(0x800ff0>>4)^2 * 0x80 ≈ 2^45
so idx*8 reaches an offset of 2^48 = 256 TB. The distance from the binary (0x55…) to the stack (0x7ff…) is 0x2AAAA… ≈ 2^45.4 — exactly as much room as is needed. The author tuned that number deliberately.
3. But we do not know a single address
We have arbitrary read/write, and the catch is that it is relative.
- To read address
A, we need a memory cell whose contents areAto use as theslot. - To write, likewise.
We fully control our own heap chunk contents, so knowing the heap base lets us plant any pointer there and get a complete arbitrary R/W. The heap base is therefore the first thing to get.
Reading into the binary (the .got, say) with a negative index is another avenue, but coherent_pointer demands that both *slot and **slot be inside the image, which rejects every libc pointer. Dead end.
mincore is an oracle
Here the safety check betrays itself. Look at load:
print "index: " -> checked_mantissa fails -> print "nan\n" and return (no bytes prompt)
print "index: " -> checked_mantissa succeeds -> print "bytes: " and read a number
So whether "bytes: " appears tells us the slot’s page is mapped and its contents are non-zero. Better still, both paths consume exactly two integers (on failure the menu’s scanf eats the second one and emits nan), which makes pipelining possible: push thousands of "2\n<idx>\n0\n" at once and parse the response stream in order. The round-trip latency disappears entirely.
8192 probes should have done it
The initial reasoning: the brk heap is PAGE_ALIGN(_end) plus randomisation, and x86_64’s arch_randomize_brk uses 32 MB, so there are 8192 candidate pages. Probe heap_start + 8 (the 0x291 size field of the tcache chunk) at each candidate and exactly one hits. 8192 probes, done.
All 8192 ran and nothing hit.
The temptation was to pile another guess on top instead of checking the assumption. Measuring was better: rerun the container as root, freeze the process, read /proc/<pid>/maps.
5c9dd3d80000-5c9dd3d8a000 ... /home/ctf/freal
5c9de5394000-5c9de53b5000 rw-p [heap]
0x5c9de5394000 - 0x5c9dd3d8a000 = 0x1160A000 — 291 MB, not 32. Back to the kernel source, where 64-bit uses a different value:
unsigned long arch_randomize_brk(struct mm_struct *mm)
{
if (mmap_is_ia32())
return randomize_page(mm->brk, SZ_32M);
return randomize_page(mm->brk, SZ_1G); // <- 1 GB
}
32 MB is the 32-bit case. There are 262144 candidates, not 8192 — scanning one page at a time is out.
Painting the heap to lengthen the stride
The oracle’s unit of judgement is “are these 8 bytes non-zero”, so making a contiguous run of the heap non-zero lets the stride grow to the length of that run.
Sixteen chunks of 0x1f000 — just under the 128 KB mmap threshold — each filled with 0x41 across its whole usable (0x1f008) size. The chunks are adjacent, so including the header size fields, a 0x1f0100-byte span is non-zero at every 8-byte granularity. That is a 2 MB upload and takes seconds.
Then:
- sweep 1 GB with a
0x1f0100stride → 530 probes (128 pipelined at a time, so 5 round trips) - binary-search between the hit and the previous point → 19 probes
- page-align → heap base
The region below the painted span (the tcache struct) mixes zero and non-zero, so it is not strictly monotonic, but the search converges inside [heap_start, heap_start+0x298] and page alignment lands on the same answer regardless. As a check, probing heap_start + 8 once more confirms 0x291.
One silly bug here: all internal arithmetic was in offsets relative to decimal, and page alignment was applied with & ~0xfff to the relative value. decimal is base+0x5060, which is not page-aligned, so the result was off by 0xfa0. It has to be done in absolute terms:
HEAP_REL = ((hi + DECIMAL) & ~0xfff) - DECIMAL
4. The leak chain
With the heap base, the rest rolls quickly.
libc. Free an 0x810 chunk beforehand so it lands in the unsorted bin (tcache tops out at 0x410, so it passes straight through; a guard chunk behind it prevents consolidation with top). That chunk’s fd/bk is main_arena + 0x60. Using that address as the slot and calling view prints 0x100 bytes of main_arena in one go:
+0x00 top -> absolute heap address
+0x10 bins[0] -> the chunk we freed (heap)
+0x20 bins[2] -> main_arena + 0x70 <- absolute libc address
Both the libc and heap absolute addresses in a single read. There is no symbol for main_arena, so it was found by disassembling malloc_trim, whose first instruction is lea rax, [rip + …] loading &main_arena: offset 0x21ac80.
Full arbitrary R/W. Use a small heap chunk as a “pointer cell”:
IDX_PTR = (heap + 0x2a0 + BASEOFF - decimal) // 8
def aread(addr): # 0x100 bytes from addr
load(I_PTR, p64(addr)); return view(IDX_PTR)
def awrite(addr, data): # up to 0x1000 bytes to addr
load(I_PTR, p64(addr)); load(IDX_PTR, data)
Stack. aread(libc + environ) gives the envp array address. Walking down in 0x100 steps, look for libc + 0x29d90. That value is the address of the instruction right after the call rax inside __libc_start_call_main — in other words main’s saved return address:
29d89 mov rax, qword [rsp + 8]
29d8e call rax ; main(argc, argv, envp)
29d90 mov edi, eax ; <- this address is on the stack
The stack slot holding it is the slot to overwrite.
5. Finishing — main returns cleanly
main is not an infinite loop:
while (1) {
menu();
if (scanf("%ld", &op) != 1) break; // <- a parse failure just exits
...
}
return 0; // returns normally after the canary check
Send something non-numeric and main returns. Only 32 bytes from the return address slot need writing, and the canary lives below it (at a lower address), so it is untouched. The current call stack is entirely below main’s frame at that point, so writing there is safe.
awrite(ret_slot, flat(libc+POP_RDI, libc+BINSH, libc+RET, libc+SYSTEM))
io.sendline(b'#')
Choosing # as the trigger is a small detail worth keeping. scanf("%ld") does not consume the input it fails on, so it stays in the buffer. Sending quit gave a shell that immediately printed:
/bin/sh: 1: uit: not found
— the shell had eaten the leftovers as its first command. With #, scanf fails just the same and the shell sees a comment.
[+] libc = 0x7f4630b86000
[+] heap = 0x562e6da88000
[+] elf = 0x562e40d7c000
[+] environ -> 0x7ffe5b52d2a8
[+] main ret slot = 0x7ffe5b52d188
[+] rop planted; leaving main
pwnsec{2123d5422f630c0e}
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
The flag filename is randomised, so cat /home/ctf/flag*.txt grabs it. That is why the Dockerfile bothered to randomise the name: a single file-read primitive is not enough, the author wanted a shell.
What went wrong along the way
Worth recording honestly.
Misremembering brk randomisation as 32 MB. The biggest time sink. “x86_64 brk randomisation = 32 MB” came from memory and was the 32-bit figure. When the 8192 probes came back empty, the right move was to read /proc/pid/maps immediately; instead a lot of time went into suspecting a parser bug. When an assumption fails, measure rather than guess again — rerunning the container as root took two minutes.
Deadlock from throwing away tube buffers. The first pipelining implementation read a large chunk with io.recv(), parsed what it needed, and discarded the rest. That discarded chunk contained the "> " prompt for the next batch, so the following recvuntil('> ') waited forever: the program waiting on input, me waiting on output. Fixed by accumulating everything read into a global buffer and moving only a parse cursor.
The trailing space in "mantissa: ". take(4) + take(5) skipped 9 bytes; the real string including the space is 10. Every leak came out shifted by one byte, producing 7-byte addresses like libc = 0x7fdf828dab4310. If an address is not 6 bytes, suspect a parser shift first. One .rodata hexdump settled it.
A tcache decoy idea (dropped). view succeeds only when *slot is a valid mapped address, so to use view — which costs one integer per probe — the plan was to plant a raw heap pointer in tcache entries[0xf]. A nice trick, halving round trips and output, but painting the heap with 0x41 makes *slot become 0x4141… and kills it. Back to the load-based oracle: lengthening the stride was worth far more than saving a constant factor.
In one sentence
Blow the index limit open with a floating-point overflow, turn OOB indexing into arbitrary R/W, then turn the mincore() safety check back into an ASLR oracle and defeat 1 GB of brk randomisation in about 550 probes.
The heart of this challenge is defensive code becoming a side channel. mapped_range() exists to avoid dereferencing a bad pointer, but “it did not crash” is itself an answer that leaks the address-space layout one bit at a time. A check that prevents a crash is almost always an oracle.
Files
exp.py— the full exploit (verified against local Docker and the remote)dec.txt— full decompilation dump (r2ghidra)
python3 exp.py <host> <port> # port 443 switches to TLS automatically