コース目次 / 第1章

業務ロジックの穴 — クライアントを、信じない

価格をクライアントから受け取って信じる自作ショップに、改ざんした価格や負の数量を送り、原価割れの注文を通します。原因を理解し、サーバ側のカタログを正とする直しで再検証します。

第1章 / 全5章目安 約13分この章のゴール: 価格・数量の改ざんを起こし、サーバ側の値を正とする設計で直せるようになる

自作のショップを立てます。「架空の旅グッズ」を売る、小さな注文APIです。ここに、クライアントを信じすぎた業務ロジックの穴を、わざと2つ仕込みます。

法と倫理ゲート

手を動かす前に

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

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

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

標的 — 価格をクライアントから受け取るショップ

下を vuln-shop.js として保存します。

// vuln-shop.js — 学習用「わざとクライアントを信じた」ショップ。127.0.0.1:3920。
const http = require('node:http');

// サーバが持つ、本当の商品カタログ(正しい価格はここにある)。
const CATALOG = { tripod: { name: '三脚', price: 8000 }, film: { name: 'フィルム', price: 1200 } };

http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3920');
  res.setHeader('Content-Type', 'application/json; charset=utf-8');

  if (url.pathname === '/order' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      const { item, price, qty } = JSON.parse(body);
      const product = CATALOG[item];
      if (!product) { res.writeHead(404); res.end(JSON.stringify({ error: '商品がありません' })); return; }
      // 【穴1】価格をクライアントから受け取った値でそのまま計算している(カタログの price を見ていない)。
      // 【穴2】数量の範囲を検証していない(負の数も通る)。
      const total = price * qty;
      res.end(JSON.stringify({ item: product.name, price, qty, total: total + ' 円' }));
    });
    return;
  }
  res.end(JSON.stringify({ catalog: CATALOG }));
}).listen(3920, '127.0.0.1', () => console.log('やられショップ: http://127.0.0.1:3920 (Ctrl+C で停止)'));
node vuln-shop.js

正規の注文

三脚(本来 8,000円)を1つ、正しい価格で注文します。

curl -X POST --json '{"item":"tripod","price":8000,"qty":1}' http://127.0.0.1:3920/order

total: "8000 円" と返ります。ここまでは正しく見えます。でも、リクエストの中に price が含まれていることに注目してください。

攻撃1 — 価格を、書き換える

同じ商品を、価格だけ変えて送ってみます。

curl -X POST --json '{"item":"tripod","price":1,"qty":1}' http://127.0.0.1:3920/order

total: “1 円”。8,000円の三脚が、1円で注文できてしまいました。サーバは CATALOG という本当の価格表を持っているのに、それを見ずに、クライアントが送ってきた price をそのまま使っている。ブラウザの開発者ツールで送信データを書き換えるだけの、単純だが致命的な穴です。

攻撃2 — 数量を、マイナスにする

もう一つ。数量に負の数を送ってみます。

curl -X POST --json '{"item":"tripod","price":8000,"qty":-5}' http://127.0.0.1:3920/order

total: “-40000 円”。「マイナス5個買う」という、現実には意味をなさない注文が通り、合計金額がマイナスになりました。もしこのアプリが「合計金額をポイントとして加算する」「合計がマイナスなら返金する」ような処理をどこかで持っていたら——負の注文が、実質的な不正な入金・返金になりえます。qty の範囲を検証していないことが原因です。

なぜ、この2つが「注入」と違うのか

ここで一度、これまでの穴と比べておきます。

SQLi や XSS は「入力が、別の言語のコードとして解釈される」という機械的なパターンでした。でも、いまの2つの穴には、コードとして解釈された部分は一つもありません。price も qty も、ただの数値として、正しく処理されています。壊れているのは、値そのものではなく「その値をどこから取ってくるべきか」というビジネスロジックの設計です。だからスキャナには判定できません。

修正 — サーバのカタログを、正とする

直しは2つ。①価格はクライアントから受け取らず、カタログから引く。②数量は妥当な範囲に検証する。

// 直したあと: price はクライアントから受け取らず、カタログの値だけを信じる。
const { item, qty } = JSON.parse(body);
const product = CATALOG[item];
if (!product) { res.writeHead(404); res.end(JSON.stringify({ error: '商品がありません' })); return; }

// qty は、意味のある範囲(1個以上・上限あり)だけ許す。
const qtyNum = Number(qty);
if (!Number.isInteger(qtyNum) || qtyNum < 1 || qtyNum > 99) {
  res.writeHead(400); res.end(JSON.stringify({ error: '数量が不正です' })); return;
}

const total = product.price * qtyNum; // 価格は常にサーバのカタログから
res.end(JSON.stringify({ item: product.name, price: product.price, qty: qtyNum, total: total + ' 円' }));

クライアントが送ってよいのは「どの商品を、いくつ」という参照だけで、「それがいくらか」という事実はサーバが持ち続けます。sec-authz で「守りはサーバに置く」と学んだのと同じ形——事実(価格・在庫・権限)は、常にサーバ側の記録を正とする。

再起動して、両方の攻撃をもう一度試すと、価格の改ざんは無視され(常にカタログの8,000円で計算)、負の数量は 400 で拒否されます。

持ち帰る一言

クライアントは、事実を持ち込まない。 参照(どの商品か)は受け取ってよいが、事実(いくらか)はサーバが持つ。数値は意味のある範囲かを必ず検証する。次は、もう一つの領域——確認と実行の間の隙間を突く、競合状態です。

こうなっていればOK

卒業まであと3章です。

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