お疲れ。最近、NFTのセールやDeFiのガバナンストークン、あるいはOT機器の自動買い付けオークションなんかで、「終了直前の数秒にボットが割り込んできて高値で持って行かれる」っていう相談がやたらと増えてきている。
フロントランニング、いわゆる先回り攻撃だ。
パブリックブロックチェーンの世界では、トランザクションがマイナー(バリデーター)のプールにブロードキャストされた瞬間から、その中身は丸見えになる。悪意あるボットやマイナー自身が、お前の入札価格を盗み見して、より高いガス代(手数料)を積んで自分のトランザクションを先祖返りさせる。Web2のAPIサーバーだって同じことだ。エンドポイントのレートリミットが甘かったり、DBの競合状態(レースコンディション)を正しくハンドリングしていなかったりすると、リバースエンジニアリングで暴かれたAPIに秒速で割込入札を叩き込まれる。
今回は、このえげつないフロントランニングを根絶するための決定打、「コミット・リビールスキーム(Commit-Reveal Scheme)」を用いた堅牢なオークション設計について、実務でそのまま使えるコードベースで解説していく。気合入れてついてこい。
—
1. なぜ「普通のオークション」は破綻するのか?
多くの開発者がやりがちな失敗は、シンプルに入札額をそのまま POST リクエストで受け付けるAPIを作ることだ。
[クライアント] --- (入札: 100ETH) ---> [APIサーバー / mempool]
[ボット] --- (入札: 105ETH) ---> [APIサーバー / mempool] ※先回り!
これでは、入札データがネットワーク上を流れる段階、あるいはブロックチェーンのmempool(未承認トランザクションプール)に溜まった段階で、中身が完全に露出する。ボットは「あ、今100ETHで入札した奴がいるな、じゃあ101ETHで上書きしてやるか」と簡単に計算できてしまうわけだ。
これを防ぐためには、「自分がいくらで入札したかを、オークション終了時まで絶対に他人に知られないようにする」必要がある。それを暗号学的に担保するのが、コミット・リビールスキームだ。
—
2. コミット・リビールスキームの仕組み
このスキームは、オークションを以下の2つのフェーズに完全に分離する。
1. コミットフェーズ(入札の秘匿)
入札者は、自分の「入札額」と、他人に推測されないための「ランダムな文字列(ソルト)」を組み合わせたハッシュ値(コミットメント)だけを送信する。この時点では、入札額の数字自体はどこにも存在しない。
2. リビールフェーズ(結果の開示)
コミット期間が終了した後、入札者は自分が使った「入札額」と「ソルト」を実際に公開する。システム側は、公開されたデータからハッシュを再計算し、最初のコミットと一致するかを検証して正式な入札として受理する。
途中で嘘をついて「やっぱり入札額を下げます」とか「別の金額でした」と言い張っても、最初のハッシュと一致しないため、不正は一発で弾かれる仕組みだ。
—
3. 【実装】セキュアなコミット・リビールオークション(Node.js / JavaScript)
理屈はそれくらいにして、実際に手を動かそう。今回はスマートコントラクト、あるいは高負荷なNode.jsバックエンドのどちらでも応用できる、実務直結のJavaScript(TypeScriptライク)による実装サンプルだ。
まずは、クライアント側が送信するハッシュの生成と、サーバー側での検証ロジックを見てほしい。
const crypto = require('crypto');
/**
* 【クライアント側】入札額とソルトからコミットメント(ハッシュ)を生成する
* @param {number} bidAmount 入札額 (例: 150)
* @param {string} secretSalt 推測不可能なランダム文字列 (例: "my_super_secret_salt_999")
* @returns {string} SHA-256ハッシュ値
*/
function createCommitment(bidAmount, secretSalt) {
const data = `${bidAmount}:${secretSalt}`;
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* 【サーバー側】リビールされた値が、過去にコミットされたものと一致するか検証する
* @param {string} committedHash ユーザーが事前に提出していたハッシュ
* @param {number} revealAmount ユーザーが今回公開した入札額
* @param {string} revealSalt ユーザーが今回公開したソルト
* @returns {boolean} 検証結果(true: 正常, false: 改ざん・不一致)
*/
function verifyCommitment(committedHash, revealAmount, revealSalt) {
// 提出された値から再度ハッシュを生成
const calculatedHash = createCommitment(revealAmount, revealSalt);
// タイミング攻撃(定数時間比較)を防ぐため、crypto.timingSafeEqualを使用する
const hashBuffer = Buffer.from(committedHash, 'hex');
const calculatedBuffer = Buffer.from(calculatedHash, 'hex');
if (hashBuffer.length !== calculatedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(hashBuffer, calculatedBuffer);
}
// --- 動作テスト(実証) ---
const myBid = 500;
const mySalt = "random_bytes_xyz_777";
// 1. コミットメント作成(これをオークション前半戦でサーバーに送る)
const commitmentHash = createCommitment(myBid, mySalt);
console.log("送信するコミットハッシュ:", commitmentHash);
// 2. リビール検証(オークション後半戦で、額とソルトを公開して検証)
const isValid = verifyCommitment(commitmentHash, myBid, mySalt);
console.log("リビール検証結果:", isValid ? "合格(正常な入札)" : "拒否(不正検知)");
// 悪意あるユーザーが金額をこっそり書き換えてリビールしようとした場合
const isMaliciousValid = verifyCommitment(commitmentHash, 501, mySalt);
console.log("不正なリビール検証結果:", isMaliciousValid ? "合格" : "拒否(不正検知)");
セキュリティエンジニアからのワンポイントアドバイス
上記のコードで crypto.timingSafeEqual を使っている点に注目してほしい。文字列の比較を通常の === でやってしまうと、比較処理の経過時間(何文字目で一致しなくなったか)を攻撃者に観測される「タイミング攻撃」の餌食になる。セキュリティを語るなら、こうしたサイドチャネル攻撃への配慮すらもコードに落とし込めて一人前だ。
—
4. Webアプリケーション全体のステート管理と設計ポリシー
単にハッシュを計算するだけではシステムは守れない。APIサーバー側で、フェーズの切り替え(State Machine)を厳密に制御する必要がある。
以下は、NginxやAPIゲートウェイ、そしてバックエンドのDB設計において絶対に守るべきインフラ・アーキテクチャの要件だ。
フェーズ管理のタイムライン
1. フェーズ1: コミット期間(例: 00:00 〜 23:00)
- 受け付けるのは
POST /api/auction/commitのみ。 - ペイロードには
commitment_hashとユーザーのウォレットアドレス/IDを含める。 - レートリミット(DDoS対策)を厳しく設定し、同一IPや同一アカウントからのスパムを排除。
2. フェーズ2: インターバル(例: 23:00 〜 23:05)
- 新規のコミットもリビールも受け付けないクールダウン期間。
3. フェーズ3: リビール期間(例: 23:05 〜 23:30)
- 受け付けるのは
POST /api/auction/revealのみ。 - ユーザーは
bid_amountとsaltを送信する。 - サーバー側は事前に保存しておいたハッシュと突き合わせ、有効な入札のみを有効な入札リストに登録。
4. フェーズ4: 終了・落札決定
- 有効な入札リストの中で最高額を叩き出したプレイヤーを勝者として確定。
Nginx側のリミット設定例(参考)
ボットによるブルートフォースや、リビールフェーズでの連打攻撃を防ぐため、リバースプロキシ層でしっかりとスロットリングをかけろ。
http {
# 1秒間に最大5リクエストまでを許可するレートリミットゾーン
limit_req_zone $binary_remote_addr zone=auction_limit:10m rate=5r/s;
server {
listen 443 ssl;
server_name auction.internal-system.local;
location /api/auction/ {
# レートリミットの適用(バーストを2に絞る)
limit_req zone=auction_limit burst=2 nodelay;
# 内部のNode.jsコンテナへ転送
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
—
5. 現場の落とし穴:リビールし忘れ問題への対策
コミット・リビールスキームには、一つだけ致命的な運用上の弱点がある。
それは、「最高額を入札した人間が、オークションに負けそうになったり、あるいは単に面倒くさくなったりして、リビール期間内にデータを公開しなかった場合、どうなるか」という問題だ。
もし勝者がリビールをボイコットしたら、その入札は無効になり、二番目に高かった奴が不当に安く落札してしまう。あるいはオークション自体が成立しなくなる。
これを防ぐための実務的なハックとして、以下のペナルティ設計を必ず組み込んでおけ。
- デポジット(証拠金)の没収メカニズム
コミットフェーズで入札を行う際、入札額とは別に「保証金(例: 一律0.1ETH または 一定のAPIクレジット)」をスマートコントラクト、あるいはエスクロー口座にデポジットさせる。
ちゃんとリビールを完了させたユーザーには保証金を全額返還するが、期限内にリビールしなかった場合は保証金を没収する。この経済的インセンティブの縛りを入れることで、悪意ある幽霊入札(ゴーストビッド)を完全に抑え込むことができる。
—
最後に
セキュリティは「魔法の杖」を一本買えばすべて解決するような甘い世界じゃない。フロントランニング対策も、今回紹介したコミット・リビールスキーム、タイムミング攻撃を防ぐセキュアコーディング、そしてインフラ層でのレートリミットが噛み合って初めて鉄壁の防御となる。
お前たちが組むコードや設計が、悪意あるハッカーや強欲なボットどもの高い壁になる。泥臭く、しかし理論武装した美しいシステムを作り上げてくれ。期待している。
コメント