Index
frontend は受け取ったURLをそのまま curl --silent --show-error <url> に渡している。shellは介さないのでコマンド注入ではないが、curl が HTTP 以外の protocol も扱える点が攻撃面になる。
今回の secret は SSH ではなく、socat が 1337/tcp で Python を起動するだけのサービスになっている。接続すると1行入力を待ち、特定の文言が入るとflagを返す。
curl の gopher:// はURLのselector部分を任意のTCP payloadとして送れるため、内部DNS名 secret:1337 に対して、URLエンコードした1行入力を流す。これで frontend 経由のSSRFから secret のsocatに直接話しかけられ、応答としてflagを読める。
index.js は / へのリクエストで User-Agent header を読み、文字列 Switch が含まれている場合だけflag入りのHTMLを返す。認証や追加の状態はなく、判定対象はこのheaderだけになっている。
RSAで d を256-bit primeとして選び、e = d^{-1} mod phi(n) を公開している。n は1024-bit程度なので、秘密指数 d は n^{1/4} 付近にあり、Wiener攻撃の対象になる。
index.js はPOSTされた choice の length が5以下であることだけを確認し、choices/${choice} を sendFile に渡している。通常の文字列では ../flag.txt のようなパストラバーサルは長さ制限で弾かれる。
ただし application/x-www-form-urlencoded で同名の choice を複数送ると、Expressのbody parserは choice を配列として扱う。choice.length は配列の要素数になるため、各要素の文字列長が長くてもチェックを通せる。
配列はテンプレート文字列内でカンマ区切りの文字列に変換される。ここで ....//../../flag.txt のような要素を重ねると、カンマを含む余分なディレクトリ名は .. で打ち消され、path.join の正規化後に /flag.txt を指せる。結果として、choices/ 配下に限定しているつもりの sendFile でコンテナ直下のflagを読める。
chall.py は引数を整数に変換したあと、assert n > 0 と assert n < 0 を連続で実行する。通常実行では同時に満たせる整数がないため、最後の print(os.getenv("FLAG", ...)) まで到達できない。
ただし起動元の server.py はユーザが指定した環境変数を1つ追加して、sys.executable chall.py <arg> を実行する。ここで PYTHONOPTIMIZE=1 を渡すとPythonが最適化モードになり、assert 文が実行されなくなる。arg には適当な整数を入れれば、そのままFLAG表示まで進む。
現在側の /setup はTOTP設定URLを返しており、secretにはflagそのものではなく sha1(flag) がBase32で入っている。TOTP検証に必要なのはこのsecretだけなので、まず現在側からsecretを回収できる。
未来側では /setup は閉じられているが、同じflagから同じsecretを作り、/auth で現在時刻のTOTPを確認している。future側のコンテナは faketime で Date.now() を約4000年進めているため、未来側の時刻に合わせてTOTPを計算すればよい。