おい、ちょっと手を止めてこっちを向いてくれ。
今日話すのは、最近ウチのチームが監査した某DAOプロジェクトで危うく大惨事を引き起こしかけた、ガバナンス機構の致命的な盲点についてだ。
「トークンを売却したのに投票権が残っている」「委任(Delegation)されたパワーが移動する瞬間に二重投票(Double Voting)が成立する」。
Web3のスマートコントラクト開発において、この手のロジックの隙は、攻撃者にとって格好の餌食だ。教科書通りの綺麗なコードを書くだけでは、現実のMEV(Maximal Extractable Value)ボットや悪意あるクジラの巧妙なトランザクション順序操作の前には、秒でハックされてしまう。
今回は、DAOの委任機能における二重投票リスクの正体と、それを根絶するためのスナップショットベースの実装手法を、現場の泥臭い知見を交えて徹底的に解説する。
—
1. なぜ「委任と売却」の隙間で二重投票が起きるのか?
DAOの投票において、ガバナンストークン(ERC-20等)の保有量=投票権とするのは素人考えだ。なぜなら、投票期間中にトークンが自由売却・移動できてしまうと、次のような悪夢のようなシナリオが成立するからだ。
1. 攻撃者の初期状態: 攻撃者は大量のガバナンストークンを保有(または委任されている)。
2. 投票の実行: 提案Aに対して、自分の持つ莫大な投票権で「賛成」に一票を投じる。
3. 即座のトークン売却(または別アドレスへの移転): 投票がブロックチェーンに記録された直後(あるいは同じブロック内の先行トランザクションとして)、DEXでトークンをすべて売り払う。
4. 二重の権利行使: トークンを受け取った別のアドレス、あるいは委任先の変更を受けたアドレスが、同じガバナンストークンを使って再度投票を行う。
「いやいや、タイムスタンプやブロック高で制御しているよ」と思ったそこの君、甘い。ブロックチェーンの世界では、同一ブロック内(Atomic Transaction)の順序操作や、フラッシュローン(Flash Loan)を用いた瞬間的な権限の移動によって、リアルタイムの残高参照ロジックは簡単にハックされる。
だからこそ、「いつの時点の残高・委任状態を正とするか」を切り取るスナップショット(Snapshot)の概念が不可欠なのだ。
—
2. 脆弱な実装と攻撃者の手口
まずは、よくある「やっちゃいけない」アンチパターンを見てみよう。以下のSolidityコードは、現在のリアルタイム残高や現在の委任先をそのまま参照している典型的な欠陥コントラクトの断片だ。
// 【危険なアンチパターン】リアルタイムの委任・残高を参照している例
function vote(uint256 proposalId, bool support) external {
// 現在の委任先から投票権を計算しているため、ブロック内でトークンを動かされると破綻する
uint256 votingPower = token.getVotes(msg.sender);
require(votingPower > 0, "No voting power");
require(!hasVoted[proposalId][msg.sender], "Already voted");
hasVoted[proposalId][msg.sender] = true;
proposals[proposalId].yep += votingPower;
}
この実装の何が問題か? token.getVotes(msg.sender) が「今この瞬間」の投票権を返してしまう点だ。攻撃者は、フラッシュローンで一時的にトークンをかき集めて委任を受け、投票を済ませた直後にトークンを返却するという一連の動きを、たった1つのトランザクション内で完結させることができる。
—
3. 対策:スナップショットベースの投票集計実装
この脆弱性を完全に塞ぐための唯一の確実なアプローチは、「提案が作成された正確なブロック高(Block Number)」、あるいは「あらかじめ定められたタイムスタンプ」における過去の状態(Checkpoints)を厳密に参照することだ。
OpenZeppelin等の実績あるライブラリでは、ERC20Votesなどの拡張機能でこのチェックポイント管理が標準実装されている。これを用いたセキュアなコントラクトの設計アプローチを、JavaScript(Hardhat/Ethers.js環境)でのテストコードとあわせて確認しよう。
セキュアなスマートコントラクトの肝となるロジック
コントラクト側では、投票関数に proposalId を渡し、その提案がブロック X でスナップショットされたものであるならば、必ず getPastVotes(account, proposalId.snapshotBlock) を呼び出すように強制する。
// 【セキュアな実装例】過去のスナップショットブロックを指定して投票権を取得
function voteBySnapshot(uint256 proposalId, bool support) external {
Proposal memory prop = proposals[proposalId];
// 提案作成時のブロック高が経過しているか、投票期間内かを確認
require(block.number >= prop.startBlock && block.number <= prop.endBlock, "Invalid voting time");
require(!hasVoted[proposalId][msg.sender], "Already voted");
// ★重要: 現在の残高ではなく、提案スナップショット時点の過去投票権を取得する
uint256 votingPower = token.getPastVotes(msg.sender, prop.snapshotBlock);
require(votingPower > 0, "No voting power at snapshot");
hasVoted[proposalId][msg.sender] = true;
prop.yesVotes += votingPower;
// イベントの発行(オフチェーンのインデクサー監視用)
emit Voted(msg.sender, proposalId, votingPower, support);
}
—
4. 実務で使える検証スクリプト(JavaScript / Ethers.js)
インフラやバックエンドを構築するエンジニアとしても、このスナップショットの整合性が正しく保たれているかをテストコードで担保しておく必要がある。以下は、委任状態を変更した後に過去のスナップショットブロックでの投票権が正しく維持されている(=二重投票や不正なパワー移動が防がれている)ことを検証するテストの断片だ。
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("DAO Delegation & Snapshot Double Voting Prevention", function () {
it("should prevent voting with tokens acquired after the snapshot block", async function () {
const [owner, delegator, attacker] = await ethers.getSigners();
// 1. ガバナンストークンとDAOコントラクトのデプロイ(省略)
// ...
// 2. 委任の設定
await token.connect(delegator).delegate(delegator.address);
// 3. 提案の作成(現在のブロック高をスナップショットとして記録)
const tx = await dao.createProposal("Upgrade Core System");
const receipt = await tx.wait();
const proposalId = 1;
// スナップショットとなったブロック高を取得
const snapshotBlock = await ethers.provider.getBlockNumber();
// 4. 攻撃者がスナップショットブロック *以降* にトークンを取得し、委任を受ける
// (実際の攻撃シナリオをシミュレート:市場からの買い集めと自己委任)
await token.transfer(attacker.address, ethers.utils.parseEther("100000"));
await token.connect(attacker).delegate(attacker.address);
// 5. 攻撃者が投票を試みる
// スナップショット時点では攻撃者はトークンを持っていなかったため、投票権は「0」になるはず
await expect(
dao.connect(attacker).voteBySnapshot(proposalId, true)
).to.be.revertedWith("No voting power at snapshot");
console.log("SUCCESS: Attack successfully blocked by snapshot validation.");
});
});
—
5. セキュリティチーフからの実務アドバイス
現場のエンジニアに強く伝えたいのは、「スマートコントラクト単体のロジックだけでなく、フロントエンドやIndexer(Graph等)の同期ズレにも気を配れ」ということだ。
- ブロック高のズレに注意: フロントエンド側で「現在のユーザーの投票権」を表示する際、最新ブロックの残高を表示してしまうと、ユーザーは「自分には1万票ある」と誤認して投票トランザクションを送り、結果として
Revert食らうという最悪のUXを生む。UI側でも必ず「該当プロポーザルのスナップショットブロック時点の残高」を表示するように設計を統一すること。 - 監査ログの監視: 委任(
DelegateChanged)イベントと投票(Voted)イベントの相関をリアルタイムで監視するアラートをGrafanaやDatadog等のモニタリング基盤に組み込んでおくこと。急激な委任の集中(クジラによる買収の兆候)を検知できるようにしておくのが、プロフェッショナルなインフラ・セキュリティエンジニアの仕事だ。
セキュリティは「知らなかった」では済まされない世界だ。コードを書くときは常に「この変数は、悪意あるスマートコントラクトから同一ブロック内で操作されたらどうなるか?」という疑いの目を忘れないでほしい。頼んだぞ。
コメント