🌊

seccon2022 easylfi

2022/11/21に公開約8,300字

SECCON2022 Easylfiの問題について取り組んだので、以下記録します。
CTF初心者なので内容に間違いがあるかもしれません。ご注意ください。

A. 問題の説明
B. 取り組んだ過程(結果解けず)
C. 答え(自力で解けなかったのでWriteup見た内容)
D. 深堀 追加の検討

A.問題の説明

seccon 2022 easylfiの問題について記載します。
大会終了後の画面キャプチャ。
こう見てみると解けている人が多く簡単な問題だったようですね。

主催者側から提供されているdocker-composeを起動してアクセスした初期画面。
名前を入力してSubmitするだけの画面の様子。

Submitするとページ遷移して「Hello!**入力した文字**」が表示されるだけのシンプルなWEBアプリ

B. 取り組んだ過程

どう考えて、どこまでFlagゲットに近づけたのか!? 記載します

B.1. 全体を把握

ざっとソースを見たり、いろいろ入力してみたりして全体概要を把握しました

  • 言語はpython、フレームワークはFlask
  • XSS対策としてURLに「..」「%」が含まれているとサニタイズするコードが含まれている
app.pyの抜粋
@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

  • 提供されたtar.gzを展開した直下にFlag.txtがあり、このなかにFlagがありそう

B.2. とりあえず思いついたものを試す

問題は「..」「%」をブロックされているポイントだけに見えるので、この攻略を検討。

  • とりあえず「http://~~~~/../flag.txt」 など試すが、当然エラーになる
  • 「..」の間に何か文字を入れても、処理されると「..」と解釈される・・・そんなことができれば行けそう。 ということで、「パストラバーサル」&「WAF回避」などキーワードに調べて試す。
  • URLエンコードは「%」ではじかれるのでNG
     *「.」はASCIIで「2E」なので、URLエンコードで「%2E」
     よって「%2E%2E/flag.txt」は「../flag.txt」の意味になるが、「%」を含むので、当然WAF部分ではじかれる
  • 「\」とかでエスケープできないか試すもNG
     *「../flag.txt」とか「\../flag.txt」とか試してみるがダメ

B.3. curlのURL指定部分でシェル実行

やっぱり簡単にはいかない。。ということでひとひねり。

curl のURL指定する部分では「$()」の中にシェルを記載・実行することができるという記事を見つけた。 この仕様を活用して以下のようなものを思いつけば、Flagゲットできるかも?という戦法。 とりあえずbase64エンコードを試す。

# curl file:///~~~/public/$(「../flag.txt」を返すシェルコード)

  1. 「../flag.txt」 をBASE64エンコードすると「Li4vZmxhZy50eHQ=」になる
実験1エンコード
# echo -n "../flag.txt" | base64 
Li4vZmxhZy50eHQ=
  1. 「Li4vZmxhZy50eHQ=」をデコードすれば当然「../flag.txt」になる
実験2デコード
# echo -n "Li4vZmxhZy50eHQ=" | base64 -d
../flag.txt
  1. これを使ってローカルで試したところ、ダミーFlagが帰ってくる。
      Flagゲットできるかも!?
実験3curlコマンドを試す
# curl file:///~~~/public/$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)
SECCON{dummydummy}
  1. 行けそう! ということで、試してみるが、だめー

B.4 理由を確認

サーバのターミナル内では実行できるのに何で!? 「something wrong...」ってなんだ?を確認するために、Docker内のapp.pyを再度確認。

app.py抜粋
@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

「Something wrong...」が表示されているということは、「"proc.returncode"が0でない」ということで、気になるのは以下

  1. subprocess.runの引数にうまく「$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)」がわたっていない?
  2. 「$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)」が実行されていない?
  3. その他エラー?

ということで、curlコマンド実行直前でfilneameの値出力とエラー出力を試す。
ブラウザで入力するのも面倒なのでミニマムのコードで試す。

最小限のテストコード
import subprocess
import os

#filename="../flag.txt"
filename="$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)"

try:
    proc = subprocess.run(
        ["curl", f"file://{os.getcwd()}/public/{filename}"],
        capture_output=True,
        timeout=1    )
except subprocess.TimeoutExpired:
    print ("Timeout")

if proc.returncode != 0:
    print (f"Something wrong...{filename}")

print(f"    stdout= {proc.stdout.decode()}")
print(f"    stdout= {proc.stderr.decode()}")
print(f"    arg   = {proc.args}")
print( "-------stdout-------")


テストコードを実行して、その結果とコマンドを実際に実行した結果(2つの赤枠)を比較すると、URL部分をダブルクオーテーションで囲ったときはFlag表示されるが、シングルクォートで囲ったときは表示されない。 このテストコードはシングルクォートで囲ったときと同じ挙動をしているので、 subprocess通すとシェルが無効化されてしまうのかな?と予想

python の subprocess関数について調べてみると、「subprocessでは、"Shell=True"をつけないとシェルは実行されず、オプションは1つ1つエスケープされる」みたいな説明を発見。

shell が True なら、指定されたコマンドはシェルによって実行されます。あなたが Python を主として (ほとんどのシステムシェル以上の) 強化された制御フローのために使用していて、さらにシェルパイプ、ファイル名ワイルドカード、環境変数展開、~ のユーザーホームディレクトリへの展開のような他のシェル機能への簡単なアクセスを望むなら、これは有用かもしれません。

「Shell=True」つければ、$()が実行されるのか!? と思い試してみる

「Shell=True」を追加する
    proc = subprocess.run(
        ["curl", f"file://{os.getcwd()}/public/{filename}"],
	shell=True,
        capture_output=True,
        timeout=1    )

が、これもダメ。やっぱりシェルは実行されていない。 手ごわいなーとあきらめ気味。

そもそも、「$()」ってcurlではなくてBashの機能では??
ということで、subprocessではそのあたりセキュリティ的に禁止しているのかもなーと推測して、この筋での深追いは中止。

B.5 curlのmanを確認

八方ふさがり気味なので、基本に返りcurlのmanを確認

って1ページ目見て、次のネタ発見! 「{a,b,c}」 なんてできるのか!?
これなら簡単にいけそうだなーということで、テストコードで試してみる。

curlの{}を使って攻略
import subprocess
import os

#filename="../flag.txt"
#filename="$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)"
filename="{.}{.}/flag.txt"

try:
    proc = subprocess.run(
        ["curl", f"file://{os.getcwd()}/public/{filename}"],
        capture_output=True,
        timeout=1    )
except subprocess.TimeoutExpired:
    print ("Timeout")

if proc.returncode != 0:
    print (f"Something wrong...{filename}")

print(f"    stdout= {proc.stdout.decode()}")
print(f"    stdout= {proc.stderr.decode()}")
print(f"    arg   = {proc.args}")
print( "-------stdout-------")

が、、やっぱり駄目。
さっきと同じエラーメッセージ。 これもシェルの機能であり、subprocess使うと軒並みこの手の機能は制限されているのかなー? と予想してここで断念しました。

C. 答え

後日、writeupを人づてに聞いたところ、「 {.}{.}/flag.txt 」で行けると知りびっくり・・・
実際試したところ、ローカル環境でダミーのFlagはゲットできました。
何が悪かったんだろう・・・

D. 深堀

答え分かったすっきりー!
だけでは寂しいので、もう少し考えてみる

D.1. なぜうまくいかなかったのか?

「{.}{.}/flag.txt」までわかっていてなぜうまくいかなかったのか・・?
よくよくテストコード見直してみると、本来のapp.pyにはない「shell=True」をつけたまま実行しているのが原因でした。

確認したときのコードと実行結果

テストコードのShell=Trueをコメントアウトして、本来のapp.pyと同じにしたときの実行結果

D.2. 同じ失敗をしないために 案1:strace

「shell=True」と「shell=False」の使用を正しく理解していなかったことが原因とは言え、このあたり、実際どういうコマンドが実行されているのか? 動作を追える手段を身につけていれば、ちゃんと気づけたかも? 今後汎用的に同じ失敗をしないで済むかも? ということで少し調べてみました。

といっても、ベターにstraceぐらいしか見つからなかったのでとりあえずこれで、
「shell=True」と「shell=False」の場合の挙動差を見てみる。

  1. 少しでもstraceのログ追いやすくするために、対話モードでpython実行

  2. 対話モードのpythonプロセスID調べてstarce実行、かつコマンド実行だけ抜き取ればいいので「execve」でgrepしてログを確認。 → 「shell=True」の場合、 /bin/sh -c でcrulコマンド含めた引数はわたっているが、次のcurlコマンド実行時に、肝心のURL部分である「file:///~~~/public/{.}./flag.txt」の引数がわたっていないことがわかる(緑枠内)。 ここまで見ていれば気づけたはず。

  3. 対して、「shell=False」の場合は全ての引数がわたっていることがわかる

ということで、「shell=True」にすると第二引数以降は使われないから、「curl file:///~~~/public/{.}./flag.txt」 と実行しているつもりが、引数ナシの「curl」だけで実行していたので curl: try 'curl --help' or 'curl --manual' for more information ってエラーメッセージが出ていたようです。 「引数が仕様にのっとっていないよ!」っていうエラーと勘違いしてしまったのが敗因でした。

D.3. 同じ失敗をしないために 案2:subprocessのログ出力

subprocessがコマンドを実行する直前ででバックログを出力して、どんなコマンドを実行しようとしているか確認してみました。

  1. subprocess内でコマンド実行直前にprint分を挿入
  2. 「shell=True」の時、実行されるコマンド(=緑線部分) と、エラーメッセージ(=黄色線部分)が、curl単体で実行した時と全く同じなので、運が良ければ気づけたかも。

こちらは、あまり汎用的ではないので意味ないかもしれませんが。。

Discussion

ログインするとコメントできます