博多電光

blog.hkt.sh

Ricerca CTF 2023 Writeup (Cat Café, tinyDB, funnylfi, gatekeeper)

Ricerca CTF 2023 にチーム TSG-graduates で参加し、4位にランクインしました。

2023.ctf.ricsec.co.jp

賞金獲得要件の関係でTSGの非学生でチームを組んで参加したんですが、学生チームのほうは学生1位に入賞し賞金を獲得したそうです。すごい。

全体的に問題のクオリティが高く素晴らしいCTFでした。12時間CTFはほぼ参加したことがなかったんですが、日本のローカルCTFということもあり日中のあいだ参加できて、かなりストレスなく (いつものCTFと同じ感じで) 参加できました。タイムゾーンの問題さえなければすべてのCTFがこうであってほしい⋯⋯。

Cat Café (web warmup, 95pts)

ディレクトリトラバーサル防止のために怪しいreplaceをしているので、../ を消したあとに攻撃が通りそうなものを投げつけるといいです。

@app.route('/img')
def serve_image():
    filename = flask.request.args.get("f", "").replace("../", "")
    path = f'images/{filename}'
    if not os.path.isfile(path):
        return flask.abort(404)
    return flask.send_file(path)

payload: http://cat-cafe.2023.ricercactf.com:8000/img?f=..././flag.txt

RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}

いいwarmupでした。

tinyDB (web, 135pts)

非同期処理めんどくさい!そもそも処理が複雑で何をやっているか理解するのが難しいんですが、ちゃんとよむと、10人以上のユーザーが作成されたときに怪しい処理を実行していることがわかります。

  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

JavaScriptのMapはObjectをキーとして使用した場合に参照として機能するので、userDB.setのあとのauth.passwordの更新はuserDBに影響を与えます。

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!

この不具合は数秒後にrollbackされて無かったことにされるので、この数秒のうちに admin:******************************** という認証情報を用いてフラグを獲得します。

RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

ちょっと問題設定に無理がありましたが、面白い問題でした。

funnylfi (web, 341pts)

与えた文字列がpunycode (正確にはPythonidnaエンコーディング) に変換された上でcurlの引数として与えられ実行されます。フラグはサーバーのローカルファイルにあるのでSSRFして読む必要があります。

ただし、sanitizerによって、ASCII printable な文字のうち英数字と :./ 以外の文字は取り除かれます。

# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url

記号が取り除かれるため、ローカルファイルの読み込み自体は ?url=fi-le:///etc/passwd のようにすることで比較的簡単にできます。しかし、フラグを読み取ろうとすると以下のwafによって弾かれるため、なんからの対策が必要となります。

# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response

文字列が国際化ドメイン名になる際に xn--[ascii文字]-[非ascii文字をエンコーディングしたもの] という形式に変換されるので、ある程度変な文字列を作ることができます。それに加えて、以下の2点がポイントになりました。

  • Pythonのidnaエンコーディング文字列のNFKC正規化を行います (親切なことに、これは問題文の例で示されています)
    • これを利用し、「|」や「\」などの全角文字を使って変な記号を挿入しようとしてもsanitizerによって弾かれるためうまくいかないのですが、Unicodeには正規化することで複数文字に変換される文字が存在します。
      • U+FDFAあたりが特に有名?
    • 今回は空白を含む文字に正規化されるU+00A8を利用することで、idnaエンコード後の文字列に空白を挿入しました。
  • curlのオプションに -r というオプションが存在し、file://スキームの場合出力されるバイトを範囲で切り取ってくれます。 (こおしいずが教えてくれました)
    • 実験したところ、このオプションは -r1hogehoge のようにゴミ文字列がくっついていても正しく認識されます。
      • ちなみに、このパーサーの挙動はHTTP越しの通信だと確認できませんでした。Rangeヘッダにそのまま渡されちゃうからでしょうか?
    • この挙動を利用し、punycode変換をかけた後に -r1 で始まる文字列に変換される文字をプログラムで探索しました。
      • 今回の場合、U+0261が該当しました。

最終的なpayloadは ?url=¨fi-le:///var/www/flag¨a.hkt.sh/.ɡ¨ となりました。この文字列はエンコードすると xn-- file:///var/www/flag a-nymv.hkt.sh/.xn-- -r1a61c に変換され、ローカルファイルを読みながら1バイト目を切り取って表示してくれます。

RicSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y}

めちゃくちゃ難しくておもしろかったです。問題コードも短くて最終的には気持ちよく解けました。Unicodeの知識が少し生きたかな?

gatekeeper (misc, 200pts)

コードはほぼこれだけです。

def base64_decode(s: str) -> bytes:
  proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True)
  if proc.returncode != 0:
    return ''
  return proc.stdout

if __name__ == '__main__':
  password = input('password: ')
  if password.startswith('b3BlbiBzZXNhbWUh'):
    exit(':(')
  if base64_decode(password) == b'open sesame!':
    print(open('/flag.txt', 'r').read())

base64コマンドのbase64パーサは他のパーサと比べてかなり挙動が厳しく、改行文字以外の余分な文字は一切入れられないようです。

ソースコードを注意深く読んでいくと、4バイトごとのチャンクの中に = が入っていても正しくパースされるであろうことが読み取れます。これを利用して b3A=ZW4=IHM=ZXNhbWUh のようなbase64を作ると条件を満たします。

RicSec{b4s364_c4n_c0nt41n_p4ddin6}

シンプルかつストレートなmisc問題で好きです。

dice-vs-kymn (crypto, 500pts)

解けなかった。位数が6となる楕円曲線パラメータを気合で求めるであろうところまでは思い至ったんですが、筋力とsagemath力が足りなかった⋯⋯。

writeupを読んで精進します。

ps converter (web, 393pts)

解く気力が残っていませんでした (は?)。webじゃなくてmiscでは?