seccon2022 easylfi
SECCON2022 Easylfiの問題について取り組んだので、以下記録します。
CTF初心者なので内容に間違いがあるかもしれません。ご注意ください。
A. 問題の説明
B. 取り組んだ過程(結果解けず)
C. 答え(自力で解けなかったのでWriteup見た内容)
D. 深堀 追加の検討
A.問題の説明
seccon 2022 easylfiの問題について記載します。
大会終了後の画面キャプチャ。
こう見てみると解けている人が多く簡単な問題だったようです
主催者側から提供されているdocker-composeを起動してアクセスした初期画面。
名前を入力してSubmitするだけの様子。
Submitしてページ遷移すると
「Hello!**入力した文字**」が表示されるだけのシンプルな画面が表示されます
B. 取り組んだ過程
どう考えて、どこまでFlagゲットに近づけたのか!? 記載します
B.1. 全体を把握
ざっとソースを見たり、いろいろ入力してみたりして全体概要を把握しました
- 言語はpython、フレームワークはFlask
- パストラバーサル対策として入力内容に「..」「%」が含まれているとサニタイズするロジックが含まれている
@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エンコードは?
*「.」はASCIIで「2E」なので、URLエンコードで「%2E」
よって「%2E%2E/flag.txt」は「../flag.txt」の意味になるが、「%」を含むので、当然WAF部分ではじかれるのでNG - エスケープは?
*「\../flag.txt」とか「\\../flag.txt」とか試してみるがNG
B.3. curlのURL指定部分でシェル実行
やっぱり簡単にはいかない。。ということでひとひねり。
curl のURL指定する部分では「$()」の中にシェルを記載・実行することができるという記事を見つけたので、この仕様を活用して以下のようなものを思いつけば、Flagゲットできるかも?という戦法。 とりあえずbase64エンコードを試す。
# curl file:///~~~/public/$(「../flag.txt」を返すシェルコード)
- 「../flag.txt」 をBASE64エンコードすると「Li4vZmxhZy50eHQ=」になる
# echo -n "../flag.txt" | base64
Li4vZmxhZy50eHQ=
- 「Li4vZmxhZy50eHQ=」をデコードすれば当然「../flag.txt」になる
# echo -n "Li4vZmxhZy50eHQ=" | base64 -d
../flag.txt
- これを使ってローカルで試したところ、ダミーFlagが帰ってくる。
Flagゲットできるかも!?
# curl file:///~~~/public/$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)
SECCON{dummydummy}
- 行けそう! ということで、試してみるが、だめー
B.4 理由を確認
サーバのターミナル内では実行できるのに何で!? 「something wrong...」ってなんだ?を確認するために、Docker内の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でない」ということで、気になるのは以下
- subprocess.runの引数にうまく「$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)」がわたっているのか?
- 「$(echo -n "Li4vZmxhZy50eHQ=" | base64 -d)」が実行されていないのか?
- その他エラー?
ということで、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」つければ、$()が実行されるのか!? と思い試してみる
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}」 なんてできるのか!?
これなら簡単にいけそうだなーということで、テストコードで試してみる。
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}"],
shell=True,
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ゲットまで以下のポイントがありました。
- 「..」「%」入力制限をcurlのオプション機能{}/[]を使って回避する
- 同じくCurlの{}を使い、「index.html」「flag.txt」を連結したレスポンスを取得する
- 自作Template機能のBug(?)を使いWAF機能回避
結局1つもわからなかったのですが、、、
せっかくなので理解した範囲で内容を記載していきます。
C.1. 「../flag.txt」の指定方法
curlのURL指定では以下のように{}が使えるので、これをうまく使い、 「.{.}」や「{.}{.}」と指定すると、実質「..」と同じ意味になるので、「..」があるとエラーになる入力チェックを回避しつつ、「../flag.txt」を指定することができるようになります。
ただ、このままではFlag.txt内にあるSECCONという文字列に反応してWAF内の条件が成立してしまい「Try Harder」という文字だけが帰ることになってしまい肝心のFlagがゲットできません。
C.2. hello.htmlとFlag.txtを連結して取得
ここからは完全に他のWriteup見た内容の焼きまわしになります。
Flag.txtだけだとどうにもならないのでhello.htmlと連結したうえで取得します。
なぜ連結するのかは次のC.3で解説
C.3. WAF回避
この問題ではテンプレート機能があり、 テンプレート.html内にある {KEY}
で指定された文字列を、リクエストパラメタで指定された値で置き換える事が可能。
しかもKeyのチェックに一部考慮漏れがあり {***}
以外に {
の一文字だけでも置換することができてしまいます。
※Keyのチェックロジックについて
以下確認用テストコードと、実行結果からわかるように、KeyのValidateは以下の3つだけなので、 {
の1文字だけだと、末尾が}
であることのチェックをすり抜けることが可能。
- 1文字目が
{
であること ※下の画像赤枠部分 - 末尾が
}
であること ※下の画像青枠部分 - これ以外では
{
と}
が含まれないこと
ということで、この2つの特性をつかってcurlコマンドのレスポンスからSECCONを削除させWAFを回避=Flagゲットできるようにします。手順は以下3Step
- STEP1:
{name}
を{
に置換
http://localhost:3000/.{.}/{public/hello.html,flag.txt}?{name}={ - STEP2:
{
を}{
に置換 - STEP3:
{!</h1>\n</body>\n</html>\n--_curl_--file:///app/public/../flag.txt\nSECCON}}
をchange
に置換
ということで、無事WAFを回避してFlagを取得することができました。
注意: 途中経過で{dummydummy}が表示されているのは手持ちのプログラムを変えているためです。こうしないと「Try hard」が帰ってくるだけでHTMLソースが確認できないので。
D. 深堀
答え分かったすっきりー!
だけでは寂しいので、もう少し考えてみる
D.1. なぜうまくいかなかったのか?
「{.}{.}/flag.txt」までわかっていてなぜうまくいかなかったのか・・?
よくよく検証コード見直してみると、本来のapp.pyにはない「shell=True」をつけたまま実行しているのが原因でした。
確認したときのコードと実行結果(Shell=Trueあり)
テストコードのShell=Trueをコメントアウトして、本来のapp.pyと同じにしたときの実行結果
D.2. 同じ失敗をしないために 案1:strace
「shell=True」と「shell=False」の使用を正しく理解していなかったことが原因とは言え、このあたり、実際どういうコマンドが実行されているのか? 動作を追える手段を身につけていれば、ちゃんと気づけたかも? 今後汎用的に同じ失敗をしないで済むかも? ということで少し調べてみました。
といっても、ベターにstraceぐらいしか見つからなかったのでとりあえずこれで、
「shell=True」と「shell=False」の場合の挙動差を見てみる。
-
少しでもstraceログの見る範囲を狭めるために、対話モードで1行ずつpythonコードを実行
-
対話モードのpythonプロセスID調べてstarce実行、かつコマンド実行だけ抜き取ればいいので「execve」でgrepしてログを確認。 → 「shell=True」の場合、 /bin/sh -c でcrulコマンド含めた引数はわたっているが、次のcurlコマンド実行時に、肝心のURL部分である「file:///~~~/public/{.}./flag.txt」の引数がわたっていないことがわかる(緑枠内)。
ここまで見ていれば、勘に頼らず気づけたかも。
-
比較用に「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がコマンドを実行する直前ででバックログを出力して、どんなコマンドを実行しようとしているか確認してみました。
- subprocess内でコマンド実行直前にprint分を挿入
- 「shell=True」の時、実行されるコマンド(=緑線部分) と、エラーメッセージ(=黄色線部分)が、curl単体で実行した時と全く同じなので、運が良ければ気づけたかも。
こちらは、あまり汎用的ではないので意味ないかもしれませんが。。
Discussion