Ricerca CTF 2023 Writeup

2023/04/27に公開

チーム"m1z0r3"として参加して、20位でした。
国内学生チームとしては5位[1]で、3位との差は65ptでした。
つまり、あと1問解けば[2]入賞できそうでした。とても悔しい。

https://twitter.com/shio_sa1t/status/1649762559271239680?s=20

競技時間中に解いた問題

[Web] Cat Café

Ricerca Cat Caféのスタッフである猫たちがギャラリーで表示されるサイトです。

app.py
import flask
import os

app = flask.Flask(__name__)

@app.route('/')
def index():
    return flask.render_template('index.html')

@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)

if __name__ == '__main__':
    app.run()

12行目に注目します。

L12
filename = flask.request.args.get("f", "").replace("../", "")

書き換えは再帰的に行われないので、../....//で作ることができます。
/img?f=....//flag.txt
ディレクトリ・トラバーサル攻撃を行い、フラグを獲得できます。
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}

[Web] tinyDB

UsernamePasswordを入力して送信すると、authIDauthPWgradeを返すサイトです。
別にadminのログインページがあるので、そこでログインするとフラグを獲得できます。
ソースコードを一部抜粋します。

index.ts
server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
  const { username, password } = request.body;
  const session = request.session.sessionId;
  const userDB = getUserDB(session);

  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }

  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);
  }

  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!

usernamepasswordを入力して送信すると、authIdauthPWgradeを出力します。
usernamepasswordが登録されていない場合、新たにguest権限で登録します。
次に、userDB.size > 10になるとデータベースが削除されて、adminが登録されます。
しかし、PW"*".repeat(auth.password.length);です。
(別のコードから、auth.password.lengthは分かるが、後で不要なことが分かる。)
また、パスワードがランダムな文字列に更新されるのは2-5秒後です。
つまり、このタイミングでPW***...***でログインできます。

フラグを獲得する方法はかなりシンプルです。
まず、最初のページでusernamePWを空白にして複数回送信します。
送信しているとuserDB.size > 10になり、データベースが削除されます。
その次の送信で、authIdadminauthPW********************************gradeadminであると表示されるはずです。
そして、2-5秒以内にadminのログインページで得られた情報を使ってログインすると、フラグを獲得できます。
RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

競技時間後に解いた問題

[Web] funnylfi

クエリにURLを投げると、ウェブサイトを表示してくれるサイトです。

import subprocess
from flask import Flask, request, Response


app = Flask(__name__)


# 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


# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False


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


@app.route("/")
def funnylfi():
    url = request.args.get("url")
    if not url:
        return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>"
    if scheme_detector(url):
        return "Hi, Scheme man !!!!"
    try:
        proc = subprocess.run(
            f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}",
            capture_output=True,
            shell=True,
            text=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "[error]: timeout"
    if proc.returncode != 0:
        return "[error]: curl"
    return proc.stdout


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=314152

schemeを制限し、記号なども制限し、最終的にcurlを実行しています。
また、途中でIDNAエンコードによりPunycodeに変換されています。
schemeⓕileでバイパスすることができます。(f-ileでも可能)
?url=ⓕile:///var/www/flagでファイル自体にアクセスできますが、WAFに怒られます。
SECCON CTF 2022 Finalsのeasylfi2のように回避することも無理そうです。

ここが最大の壁です。競技時間の半分、6時間以上考えていましたが、越えられなかった...
ずっと、IDNAエンコードによるPunycodeへの変換が怪しいと思って取り組んでいました。
最初はmbc_sanitizerが変換せずにチェックしてると思い、Unicode宝探しをしていた。

宝探しの成果
hyphen = "-"
pipe = "|"
q = """
space = " "
percnet = "﹪"

しかし、宝探しは無駄ではありませんでした。
a¨aを通すと、xn-- a -deccとなり、空白を含むことができます。
Unicodeのデータベースを読むと、¨00A8;DIAERESIS;Sk;0;ON;<compat> 0020 0308;;;;N;SPACING DIAERESIS;;;;であり、<compat> 0020と空白を含んで構成されています。
mbc_sanitizerでは1文字ずつ検証しているが、¨xn-- -ccbとなるため、空白のみではないため、通過することができます。

競技時間中に分かったのはここまででした。
あとはxn-- file:///var/www/flag -r 1-***を作れば出来そうだと思い、ハイフンを含んで構成された<compat> 002Dを見つければ良いのですが、存在しませんでした。
次に、空白を入れたときに最後が-***となることに注目します。
curlのrオプションは、通常-r 1-のように使いますが、-r1***でも同様の動作をするようです。
Unicodeのデータベースより、<compat> 0020が記述されているUnicodeをリスト化します。
それをⓕile:///var/www/flagの両端に追加し、総当たりで検証します。

unicode_bruteforce
for a in l:
    for b in l:
        url = a + "ⓕile:///var/www/flag" + b
        print(url)
        print(f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}")

実行すると、゜ⓕile:///var/www/flag˛xn-- file:///var/www/flag -r1m6885sに変換されます。
/?url=゜f-ile:///var/www/flag˛にアクセスすると、先頭一文字が欠けたフラグを獲得できます。
RicSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y}

作問者はよく知っている人[3]だったので、競技時間中に解きたかった...

[Misc] gatekeeper

Twitterで解法を見てしまったので、自力では解いていませんが、使えそうな仕様だと思ったので、メモ程度に書きます。

import subprocess

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())
  else:
    print('Wrong')

base64デコードした文字列がopen sesame!であるとフラグを獲得できます。
しかし、普通にopen sesame!をbase64エンコードしたb3BlbiBzZXNhbWUhから始まる文字列は拒否されます。
競技時間中はopen sesami!の空白に注目していましたが、何も成果は得られませんでした。
先に答えを言うと、途中に=が入ってても大丈夫です。
たしかに、ソースコードを見るとそのような仕様があります。知らなかった。
open sesami!のbase64デコードした文字列を連結させたbw==cGVuIHNlc2FtZSE=を送信するとフラグを獲得できます。
RicSec{b4s364_c4n_c0nt41n_p4ddin6}

とてもシンプルな問題で好きです。そして、普通に使えそうな仕様。
funnylfiが解けそうなので優先してしまったが、こっちを見るべきでした。反省。

感想

最初にチームメイトの方、ありがとうございました。
はじめて、チームm1z0r3として参加させて頂きました。
入賞まで本当にあと少しというところだったので、とても悔しいです。
そして運営の皆さん、開催していただきありがとうございました。
もし来年もあるならば、ぜひリベンジしたいです。

脚注
  1. 4位かもしれない ↩︎

  2. 解けていない問題の中で一番点数の低い[Misc]gatekeeperは競技終了時点で200ptだった ↩︎

  3. 競技終了後に解けなかった報告をしたら、なぜか別の問題の作問者に煽られた。僕はカスです。 ↩︎

Discussion