コース目次 / 第3章

競合状態の修正 — 確認と実行を、1つにする

確認と減算の間にawaitを挟まない(atomicな)設計に直し、同じ同時購入攻撃で在庫が正しく1件だけ成功することを再検証します。実務でのロックやDBの原子的更新、冪等キーにも触れます。

第3章 / 全5章目安 約11分この章のゴール: 確認と実行を隙間なく行う設計でTOCTOUを塞ぎ、再検証できるようになる

前章の穴の原因は、「確認」と「実行(在庫を減らす)」の間に、待ち時間(隙間)があったことでした。直しは、この隙間そのものを無くすことです。

法と倫理ゲート

手を動かす前に

このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。

  • (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
  • (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
  • (c) CTF 競技の中(規約の範囲で)

実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。

直し — 確認と減算を、間を空けずに行う

決済処理のような時間のかかる作業を、在庫を確保したあとに回します。「確認してから、時間のかかることをして、それから減らす」ではなく、「確認したら、その場ですぐ減らし、時間のかかることはそのあと」にする。

// race-shop-fixed.js — 確認と減算を、間にawaitを挟まず行う。127.0.0.1:3930。
const http = require('node:http');

let stock = 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') {
    // 直したあと: 確認(stock > 0)と減算(stock -= 1)の間に、await を一切挟まない。
    // JavaScript は1スレッドなので、この2行の間には他のリクエストが割り込めない。
    if (stock > 0) {
      stock -= 1; // ここで在庫を"確保"してしまう。もう他の誰にも渡さない。
      await new Promise((r) => setTimeout(r, 50)); // 決済処理は、確保したあとに行う
      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 で停止)'));

変わったのは、たった1行の位置です。stock -= 1 を、await の前に移しました。JavaScript の実行モデルでは、await に到達するまでのコードは、途中で他の処理に割り込まれません。だから if (stock > 0) { stock -= 1; … という2行は、他のリクエストから見て、ひとかたまり(atomic)に実行されます。1人目がこの2行を実行し終えるまで、2人目はこの2行に入ってこられない。

再検証 — もう一度、5人で同時に

在庫を1個に戻して(サーバを再起動)、前章とまったく同じ攻撃(race-attack.js)を実行します。

node race-shop-fixed.js &
node race-attack.js

今度は、成功件数がちょうど1件になります(何度実行しても)。残りの4件は「売り切れです」。/stock を見ても、在庫はマイナスにはなりません。確認と実行の間の隙間が無くなったので、割り込む余地も無くなりました。

この発想の一般化 — 「確認してから」ではなく「できたかで」

この直しを、もう一段抽象化しておきます。実務のデータベースでは、こんな形でも同じ原理が使われます。

「在庫があるか確認してから、減らす」のではなく、「減らせるなら減らす。減らせたかどうかで、成功を判定する」という考え方に転換します。たとえば SQL なら:

UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0;

この1文は、「確認」と「更新」をデータベースが1つの操作として保証します(原子的な更新)。更新された行数が0なら「売り切れ」、1なら「成功」——判定は更新できたかどうかの結果で行い、事前の確認には頼りません。データベースのトランザクションやロックも、この「隙間を作らない」ための仕組みです。

もう一つの層 — 冪等キー

Web の実務では、利用者側のネットワーク再送でも似た問題(二重注文)が起きます。これに対しては別の仕組み、冪等キー(idempotency key)を使います。

注文のたびに、クライアントが一意なキーを発行してリクエストに添える。サーバは「このキーで、もう処理済みか」を先に確認し、済んでいれば同じ結果を返すだけで、二重に処理しない。ネットワークの再送やボタンの連打による重複を防ぐ、TOCTOU とはまた別の対策です。決済 API の多くが、この仕組みを提供しています。

持ち帰る一言

確認と実行を、1つの操作にする。 待ち時間を挟まず(atomicに)処理するか、データベースの原子的な更新に任せるか。「確認してからやる」のではなく「やってみて、できたかで判定する」という設計への転換が、TOCTOU を根から塞ぎます。次はまとめて、業務ロジックと競合状態を締めます。

こうなっていればOK

卒業まであと1章です。

この章はまだ完了していません。