T28 · Smart Contract Security
コードは間違っていないのに、なぜ盗まれたのか?
- 練習する能力
- Builder
- 実践
- 再入可能性の脆弱性があるコントラクトを書き、自分で攻撃コントラクトを作って資金を取り出し、修正する。
- AI Lab
- AI にコントラクトを監査させ、各結論を実際の問題、誤検知、見落としに分類して比率を出す。
現実の問題
20行の金庫コントラクトです。預け入れ、引き出します。
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}一行ずつ読んでみます。
第一行、この人の残高を読みます。正しいです。 第二行、残高がなければ拒否します。正しいです。 第三行、その金額を送ります。正しいです。 第四行、送金が失敗したら全体を戻します。正しいです。 第五行、残高をゼロにします。正しいです。
五行とも、単独で見れば完全に正しいです。コンパイル警告はゼロ、単体テストは全て成功し、分岐網羅率も100%です。
公開の翌日、金庫が空になりました。一人分ではなく、全員の資金が盗まれました。
取引の呼び出し履歴を見ると異常があります。一つの取引で withdraw に数十回入り、毎回成功して同額を送っています。開始したアカウントが最初に預けたのは、ごく少額でした。
もはや「どの行を間違えたか」という問題ではありません。間違った行はありません。
問うべきは、五行の間に、逐行で読んだとき見えていなかった何が起きたのか? です。
思考実験
このコードを銀行窓口の業務に置き換えます。
引き出しに来たあなたに、行員は三つの作業をします。
1. 帳簿を開き、あなたの残高を見る
2. 引き出しから同額の現金を数え、渡す
3. 帳簿の残高を消す自然な順序に見え、ほとんどの場合は問題ありません。
条件を加えます。第二段階で渡す瞬間、あなたはすぐ次の要求を出せます。行員は第三段階に戻る前に、新しい要求を処理しなければなりません。
するとこうなります。
帳簿を見る:残高100 → 100を渡す
その瞬間に次の要求 → 帳簿を見る:まだ100(消していない!)
→ また100を渡す
さらに次の要求 → 帳簿はまだ100
→ また100を渡す
...
引き出しが空になり、ようやく帳簿の100を消す帳簿を消したのは一回、現金を渡したのは数十回です。
行員の計算は一度も間違っていません。 毎回帳簿を確認し、その通りの金額を渡しました。間違いは一つだけです。
「現金は出たが、記帳していない」瞬間に、次の要求の割り込みを許しました。
これが最初の五行の問題の全てです。第三行の msg.sender.call は送金だけでなく、実行権を相手に渡します。その瞬間に相手のコードが動きますが、帳簿には古い値が残っています。
もう一つの思考実験は、この章の後半に対応します。
オフィスの正面入口では警備員が身分証を確認し、一度も間違えず、入居者は安心しています。しかし設計時に「従業員しか知らない」と判断した側面入口には、警備員を置いていません。
確認は一度も間違えなくても、侵入されました。 安全性を決めるのは確認の厳しさではなく、確認を迂回する経路があるかです。
二つを合わせると、この章の全体になります。
脆弱性は一行のコードではなく、二行間の順序と、守られていない経路にあります。
あなたが決める
最初の金庫を修正します。一か所だけ先に変えるなら、どこにしますか?
結果を観察する
四つの修正を比べます。
| 根治か | 防げないもの | 期限があるか | 代償 | |
|---|---|---|---|---|
| ロック | 保険 | 関数をまたぐ経路、読み取り経路 | ない | 少量のGas |
| 順序の変更 | はい | コントラクト間の状態不整合 | ない | なし |
| Gas制限 | いいえ | 価格が変われば全て | ある | 正常なコントラクトも受取不可 |
| pull方式 | 他の問題も解決する根治 | なし(ただし操作形態は変わる) | ない | 二つの取引 |
第一の結論です。
順序は根治、ロックは保険。両方が必要です。
ロックを加えても盲点があるため、順序を無視できません。順序が正しくても、後の変更で誰かが戻してしまう可能性があり、そのときロックが救うため、ロックを省けません。
第二の結論が、この章の中心です。
外部アドレスへの呼び出しは必ず実行権を渡します。渡す前に、状態の整合を取っておく必要があります。
これはnative tokenの送金だけには限りません。実行権を渡す場所を全て列挙すると、想像以上にあります。T7、T9、T12 で予告してきた一覧が、ここで完成します。
第三の結論は二つ目の思考実験からです。
安全性は確認の厳しさではなく、確認を迂回する経路の有無で決まります。
コントラクトの監査では、逐行で読むだけでなく、各経路について、誰が入り口を守るかを問います。
モデルを作る
実行権を渡す場所
全て列挙します。印刷してモニターに貼っておきたい表です。
| 場所 | 理由 |
|---|---|
native tokenの送金(call) | 受取人の receive または fallback が動く |
外部アドレスへの call / delegatecall / staticcall | 呼び出しの定義そのもの |
| コントラクトへのNFT送付 | 標準が受取確認のcallbackを要求する |
| マルチtoken標準の単一・バッチ送金 | 同上。バッチcallbackは一回だけで見落としやすい |
| 送金hookのあるtoken | 標準が送金前後の双方への通知を認める |
| callback型の設計全般 | flash loan、flash swap、競売の入札通知 |
| 利用者が渡すアドレス引数 | 人ではなくコントラクトかもしれない |
最後の行が、この表の要点です。
全てのアドレス引数は、コントラクトかもしれません。
T9 のNFT標準で説明した受取確認のcallbackは、取り出せないアドレスへの送付を防ぐものですが、送付中に受取人へ実行権を渡すことも意味します。例で詳しく見ます。
// 一見、普通のmint関数
function mint(uint256 qty) external payable {
require(qty <= MAX_PER_TX, "too many");
require(minted[msg.sender] + qty <= MAX_PER_WALLET, "wallet limit");
require(msg.value == qty * PRICE, "wrong value");
for (uint256 i = 0; i < qty; i++) {
_safeMint(msg.sender, nextId++); // この行で受取人をcallbackする
}
minted[msg.sender] += qty; // callbackの後に数を更新する
}受取人はcallback内で mint に再入します。minted[msg.sender] は古い値のままで、wallet上限の確認は機能しません。各行は正しくても、順序が間違っています。
同じ構造の四つの形態
多くの人は第一しか知りませんが、実際の事故は第三でよく起きます。
| 形態 | 仕組み | ロックは効くか |
|---|---|---|
| 同一関数 | callbackから同じ関数へ再入 | 効く |
| 関数間 | 同じ状態を共有する別の関数へ再入 | 両方にロックがある場合だけ |
| 読み取り専用 | callback中に外部が view を読み、中間状態を得る | 全く効かない |
| 取引間 | callbackではないが、状態を使ってから更新する | 対象外 |
第三は近年多い事故なので詳しく扱います。被害を受けるのは、自分ではなく統合先であることが多いです。
// 総資産を総share数で割って価格を出す金庫
function pricePerShare() external view returns (uint256) {
return (address(this).balance * 1e18) / totalShares;
}
function redeem(uint256 shares) external nonReentrant {
uint256 amount = (address(this).balance * shares) / totalShares;
sharesOf[msg.sender] -= shares;
(bool ok, ) = msg.sender.call{value: amount}(""); // ここで実行権を渡す
require(ok, "transfer failed");
totalShares -= shares; // 分母はこの後に更新する
}redeem はロックされているので再入できません。しかし、その call の間はこうなります。
分子(コントラクト残高):既に減った
分母(totalShares):まだ減っていない
→ pricePerShareは真の値より低い数を返すこの間、外部コントラクトは金庫に触らず、pricePerShare をoracleに使う別の融資プロトコルを呼びます。そのプロトコルは低い価格を読み、T21 のoracle操作の経路が成立します。
教訓は三つです。
view関数にはロックがなく、ロックを持つこともできません。 読む状態の整合を常に保つことが唯一の防御です。- CEIの状態には、外部が読む全ての派生値が含まれます。その関数で明示的に変更する変数だけではありません。
- 統合先は、読み取る瞬間の値が中間状態ではないかを問う必要があります。 前章の「上流を信頼できなければ、自分の側に防御を置く」という規則の別の適用です。
CEI:三文字の順序
Checks 全ての確認と引数検証を先に行う
Effects コントラクトの状態を「既に実行された」状態にする
Interactions 最後に外部とやり取りする比較します。
| 問題のある順序 | 正しい順序 | |
|---|---|---|
| 引き出し | 送金 → ゼロ化 | ゼロ化 → 送金 |
| mint | callback型mint → 数の加算 | 数の加算 → callback型mint |
| 交換 | token送出 → reserve更新 | reserve更新 → token送出 |
| 清算 | 清算人に担保を渡す → 債務削減 | 債務削減 → 担保を渡す |
T19 の例外では、実際のAMMはflash swapのため先に送り、後で不変条件を検証します。CEI違反ではなく、Effectsを「やり取りの後の不変条件検証」に置き換えています。 成立するには、不変条件を明確に書き、やり取りが戻った後、一つずつ検証できなければなりません。
やり取り後に整合を確認する方式は、不変条件を書ける場合だけ認められます。書けなければCEIの順序に従います。
権限の境界
前節は順序、ここは境界の問題です。二つで脆弱性の大半を占めます。
| 落とし穴 | 具体的な形 |
|---|---|
| modifierの欠落 | 管理関数への権限確認を忘れる。単純で、最も多い |
| callerではなくoriginで認証 | tx.origin で判定すると、悪意あるコントラクトへ誘導された利用者が権限を与えてしまう |
| 初期化が無防備 | deploymentと初期化の間に、誰でも先に管理者になれる窓がある |
| 実装へ直接call可能 | proxyの実装自体もアドレスを持つ。T8 の事例 |
| 権限の伝達 | AはBだけ信頼するが、Bは誰のcallも受けるので、Aも全員を信頼してしまう |
| 既定で許可 | 新関数の確認を忘れると、全員に公開される |
第五は二つのコントラクトをまたぐため最も見つけにくく、片方だけ監査しても分かりません。
// 金庫:strategyコントラクトだけ信頼する
function pull(uint256 amount) external {
require(msg.sender == strategy, "only strategy");
token.transfer(strategy, amount);
}
// strategy:誰でも起動できるrebalancing関数
function rebalance(address to, uint256 amount) external { // 確認が一切ない
vault.pull(amount);
token.transfer(to, amount);
}金庫の確認は正しく、strategyは信頼されたアドレスです。しかしstrategyは、その信頼を無条件で全員に渡します。
権限の監査はmodifierを読むだけでなく、誰が何段階で関数へ到達するかの図を描きます。 一段でも「誰でも」があれば、経路全体が「誰でも」です。
最後の実践規則です。
既定で拒否します。 外部関数を追加するときは権限が必要と仮定し、公開できる理由を示します。逆にはしません。
精度と丸めの方向
この種の問題は、攻撃技術を必要とせず、あなたより丁寧に計算する人がいれば成立します。
整数除算は常に切り捨てます。これはbugではありません。誤差がどちら側に落ちるかが問題です。
そのまま使える規則です。
丸めの誤差は全て、利用者ではなくプロトコルに有利な側に落とします。
表にするとこうです。
| 計算対象 | 方向 | 理由 |
|---|---|---|
| 入金からshareへ | 切り捨て | 利用者へのshareを少なくする |
| shareから資産を償還 | 切り捨て | 利用者への資産を少なくする |
| 利用者の債務 | 切り上げ | 債務を少し多くする |
| 利用者の担保価値 | 切り捨て | 担保を少し少なく評価する |
| 手数料 | 切り上げ | プロトコルが少し多く受け取る |
| 清算で持ち出せる担保 | 切り捨て | 清算人へ余計に渡さない |
もう一つ、乗算を先、除算を後にします。 逆にすると精度を失ってから誤差を増幅する、よくある自滅になります。
二つが組み合わさる典型例、最初の預金者の問題を見ます。
初期状態:totalShares = 0、金庫は空
1. 最初の人が1 weiを預ける
初回は通常1:1 → 1 shareを得る、totalShares = 1
2. 同じ人が【depositを通さず】10000 tokenを直接送る
残高 = 10000e18 + 1、totalSharesは依然1
3. 次の人が10000 tokenを預ける
share = 10000e18 * totalShares / 残高
= 10000e18 * 1 / (10000e18 + 1)
= 0 ← 整数除算の切り捨て
4. この人は0 shareを得る。10000 tokenは金庫資産になるが、
全share(1)は最初の人が持っている三つの条件が同時に必要で、個別に見ると問題なさそうです。
- shareは「資産×総share数÷総資産」で計算し、切り捨てる
- 総資産は直接残高を読むので、直接送金で汚染できる
- 極小の初回入金を認め、totalSharesが1になれる対応する三つの防御は、通常組み合わせます。
- 内部記帳:総資産は残高ではなく、自ら管理する変数を使い、直接送られた資金は算入しません。
- 仮想shareと仮想資産:分子と分母へ固定offsetを加え、0 shareになる操作を経済的に割に合わなくします。
- dead share:初期化時に引き出せないアドレスへshareをmintし、
totalSharesが危険な小ささにならないようにします。
さらに、算出shareが0ならrevertします。 一行でこの問題の最後の段階を止めます。
upgradeのリスク
T8 のproxyの三つの原則を適用し、第四を加えます。
第一、storage layoutは末尾に追加するだけです。 挿入、型の変更、並べ替え、削除は禁止です。
reviewだけでは守れず、CIで守ります。各版のstorage layoutをファイルとしてrepositoryへ保存し、upgrade時にscriptで比較して追加だけを認めます。完全に自動化できる確認なので、省く理由はありません。
第二、proxy自身の変数は疑似ランダムな固定slotに置きます。slot 0から宣言すると実装と衝突します。
第三、実装自身も直接callできるアドレスです。 constructorで初期化済みとし、他人の再初期化を禁止します。自身を破壊できる経路も置きません。
第四、upgradeはコードだけでなく、操作自体も監査対象です。 次を答えます。
- 誰がupgradeできるか。一人、multisig、ガバナンス投票か?
- timelockはあるか、長さは?
- eventを出し、外部monitoringに見えるか?
- 新版初期化の手順はあり、守られているか?
- upgrade失敗後にrollbackできるか?第24章 の言葉を繰り返します。timelockのないupgrade可能なコントラクトは、upgrade鍵の持ち主へ全資金を渡すことと同じです。 どれほどコードがきれいでも、これを守らなければ前の努力は無駄になります。
不変条件:全体をまとめる
四種類の問題を見ました。形は違いますが、共通点があります。
全て、常に成立すべき命題が、ある瞬間に成立しない問題です。
| 問題 | 破られた命題 |
|---|---|
| 順序による重複引き出し | 保有資産は全利用者の残高合計以上 |
| 読み取り経路の中間状態 | 外部が読む派生値は常に整合している |
| mint超過 | 各アドレスのmint数は上限以下 |
| share inflation | 正の入金からは正のshareを得る |
| 権限伝達 | 許可されたアドレスだけ関数に到達する |
これを不変条件と呼びます。専門的に見える文章を書くことではなく、次に価値があります。
不変条件は、機械が繰り返し検証できる安全性の表現です。
「この関数は大丈夫だと思う」は検証できません。「残高は全利用者残高の合計以上」は、何百万ものランダムな操作列を生成し、各操作の後に検証できます。
次の段階でそれを行います。この章から、状態を持つコントラクトを書くたび、機能より先に不変条件を書く習慣を付けます。 書き方と自動検証はT30です。
何と呼ぶか
外部callで実行権を渡した後、状態更新より前に、本コントラクトへ制御が戻って入ることです。
本質は二回呼ばれることではなく、状態の使用より更新が遅いことです。四形態では読み取り専用が最も見落とされ、ロックも全く効きません。
全ての確認、コントラクト状態の変更、外部操作という関数の標準順序です。
再入の緩和ではなく根治です。例外は一つだけ。書ける不変条件を、やり取りの後に検証する方式です。
関数の実行中、再入を禁止する状態bitです。
保険であり、根治ではありません。盲点は二つ。同じロックを共有しない関数には相互に入れ、view は守れません。
誰がどの関数を呼べるかです。
modifierを読むだけでなく、到達可能性の図を描きます。誰が何段で来られるか。一段でも誰でも入れるなら、全経路の答えも誰でもです。既定で拒否します。
整数除算に誤差は必ずあり、問題はどちら側に落ちるかだけです。
規則は、常にプロトコルに有利な側へ落とします。 利用者へ渡す分は少なく、債務は多く計算し、乗算を先にします。
storage layoutのずれ、初期化の横取り、そしてupgradeできる人が全ロジックを一度に変更できることなど、upgrade可能性によるリスクです。
layout比較はCI、upgrade権限にはtimelockとeventが必要です。省くとコード監査の価値が大きく失われます。
どの瞬間、どの操作列の後でも真でなければならない命題です。
価値は機械による反復検証です。書けないコントラクトは人が見るしかありませんが、書ければ何百万回もプログラムで衝突させられます。
実践
この練習は自分のローカル環境だけで行います。 各行が正しくても全体が間違うことを目で確認し、修正を覚えるためです。public chainへ公開せず、実際に公開されたコントラクトに対して試さないでください。ローカルnode上は全て自分のもので、練習にもそれだけが必要です。
この段階で最も重要な30分です。自分の金庫がテスト内で一度破られる経験は、事故分析を十本読むより深く残ります。
三つの合格基準です。それぞれ成功と失敗が分かるテストにします。
基準1:少額を預けたアカウントが、預入額以上を回収することをテストで証明する
基準2:順序修正後、同じテストが成功から失敗に変わる
基準3:ロックと順序修正の防御対象と盲点を説明するこの脆弱な金庫を、一字も変更せずに書きます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
// この行でmsg.senderへ実行権を渡す
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// 戻ってきた時点では、上の処理に何度も再入した可能性がある
balances[msg.sender] = 0;
}
function totalAssets() external view returns (uint256) {
return address(this).balance;
}
}ローカルnetworkへ公開し、通常のアドレス二、三個から入金します。他の誠実な利用者を表す資金であり、練習で流出する部分です。totalAssets() の値を記録します。
問題を再現するprobeコントラクトを書きます。
他人への攻撃ではなく、送金を受けたとき withdraw を再度起動するtest fixtureです。順序の誤りが重複引き出しを起こす事実をassert可能にします。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
interface IVault {
function deposit() external payable;
function withdraw() external;
}
// test fixture:ローカルだけで脆弱性の存在を証明する
contract ReentrancyProbe {
IVault public immutable vault;
uint256 public unit;
constructor(address v) {
vault = IVault(v);
}
function start() external payable {
unit = msg.value;
vault.deposit{value: msg.value}();
vault.withdraw();
}
// 金庫からの送金でここが起動し、金庫の帳簿はまだゼロではない
receive() external payable {
if (address(vault).balance >= unit) {
vault.withdraw();
}
}
}公開前に紙で推演します。 最初の三回の balances[probe] と金庫残高を書き、正しく予測してから実行します。出来事の理解が変わる、練習の核心なので省かないでください。
テストでassertします。
誠実なアドレスから入金し、小額でprobeの start() を呼び、最後に二点をassertします。
- 金庫のtotalAssets()が0またはほぼ0に下がる
- probeの回収額が最初の小額より明らかに多いこのテストは今、成功するはずです。資金流出を証明しています。脆弱性の実在を確信できる証拠で、口頭説明より確実です。T8 以来、動くテストは説明より強いという同じ基準です。
順序を直し、前のテストを失敗させます。
withdraw をCEIに変えます。
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // 記帳を先にする
(bool ok, ) = msg.sender.call{value: amount}(""); // 外部操作を後にする
require(ok, "transfer failed");
}同じテストが今度は失敗しなければなりません。二回目のprobeの進入では残高が0で、require が止めます。
コンパイル成功だけでは修正完了になりません。緑から赤への変化が、この練習の核心の合格基準です。
ロックだけを加え、順序修正との役割の違いを理解します。
脆弱版に戻し、順序は変えずロックだけ加え、同じテストが赤になることを観察します。
次を答えます。同じ balances を共有して送金する二関数の、一方だけにロックを加えるとどうなるか? 関数間の経路をテストで検証します。これが第一の盲点です。
読み取り経路の問題を再現し、第二の盲点を理解します。
残高をshare数で割る例で、redeem にロックを付け、totalShares の更新は送金後に置きます。受取callback内で金庫の pricePerShare() を読むコントラクトを作ります。
redeem にロックがあっても価格が間違っています。 ロックは view を守れないためです。
totalShares -= shares を送金前へ移します。再び、実行権を渡す前に、外部が読む全ての量の整合を取ります。
まとめます。 練習で使った修正を表にします。
| 修正 | 防ぐもの | 盲点 |
|---|---|---|
| CEI | 同一関数、関数間、読み取りの全て | コントラクト間の不整合は別途検討 |
| ロック | ロック付き関数への再入 | 関数間では双方必要、viewは対象外 |
| pull方式 | 状態を持つロジックから送金を除く | 操作形態が変わる |
この表を埋めると、順序は根治、ロックは保険、両方が必要です。 という結論を理解できます。
AI Lab
成果物はbugの発見数ではなく、AI監査能力への信頼の境界を示すreportです。三段階で進めます。
第一段階:
私のコントラクトです(実践の金庫、読み取り経路を持つshareコントラクト、
自作の権限伝達問題を一つ含む二コントラクト構造を渡す)。
全ての安全性問題を見つけ、それぞれ次を示してください。
- 具体的な関数と行
- 起動の前提条件とcall順序
- 結果
- 修正方法
監査を推奨する、再入に注意など、具体性のない発言は不要です。
第二段階:
各報告を【現在のコードで失敗する】テストにしてください。
書けない場合は確信できていないので、その旨を記してください。
第三段階:
意図して少なくとも二か所の問題を残し、伝えていません。
読み取り経路と、コントラクト間の権限伝達です。
この二種類を特に探し、もう一度監査してください。第一段階で典型的な動作が見えます。名前のある教科書的な問題(同一関数の再入、modifier欠落)は得意ですが、関数間・コントラクト間の推論では的中率が下がります。 文脈では成立しない理論上の可能性もnoiseとして列挙します。それが分類する理由です。
第二段階は、T8 のdelegatecall Labと同じ厳しいふるいです。悪いコードで失敗するテストが、問題の実在を証明します。 全結論のテストを求めると、捏造は書けないか、悪いコードで成功してしまいます。失敗テストを付けられない結論は全て誤検知に下げます。
第三段階は見逃しを測ります。誤検知は時間を浪費し、見逃しは安全だと思わせます。 実践で再現した読み取り経路は良い素材です。第一で報告せず、第三でも見つからなければ、このtoolは読み取り経路を代わりに守れないという具体的な境界が分かります。
最後は自分で、三比率を数え、一文で結論を書きます。 「名前のある問題の大半は掃除できるが、読み取り再入と権限伝達は自分で監査する」などです。今後の工程のどこに置くかの根拠になります。
最低限の原則:AIは監査の手掛かりを生成できますが、結論を出すあなたの代わりにはできません。T32 の、モデル出力をモデルで検証しない規則と同じです。確認は、赤と緑が分かれる確定的なテストで行います。
AI の回答後、自分で必ず検証すること
- 真の問題とする全項目を、失敗するテストで証明する。証明できなければ誤検知に下げる
- 具体的な関数、行、起動経路があるか。場所を示さず再入の可能性とだけ述べる項目は発見ではなくnoise
- 実践で意図して残した読み取り経路の問題をコードに隠し、検知するか確認する。できなければ見逃し
- ロックを付ければ安全と結論し、順序が根治したかを説明できていないか
- 丸め方向を、誤差はプロトコル側という規則で自分で再確認する
- 引用したlibrary、modifier名、標準番号が実在するかを全て自分で確認する
- 真の問題、誤検知、見逃しの三比率を自分で数え、toolへの信頼の境界を書く
実例
巨額を集めたコントラクトの引き出しは、最初の例と同じ送金後にゼロ化する形でした。この順序を使い、一取引で何度も引き出して自分のshare以上の資金を持ち出しました。
影響はchainのforkにまで至りました。根因は信じ難いほど単純な、二行の順序です。
業界が記帳を先にする規則を覚えた起点であり、間違いは各行ではなく二行の間にあります。 という冒頭の説明でもあります。
share単価を返す view を持つ金庫には再入ロックがあり、完璧に見えました。しかしshare総数を送金後に更新するため、callbackの間は価格がずれました。
別の融資protocolが価格源に使い、callback中の低い価格を読んで、本来出すべきでない融資をしました。
両protocolは個別に監査を通過していましたが、組み合わせで問題が起きました。教訓は、view も攻撃面です。さらに T21 の通り、他protocolの即時状態をoracleにすることは典型的な攻撃入口です。
T27 の例を契約側から見ます。特定操作だけのはずの権限が関数を制限しないため、承認関数を呼んで管理下のアドレスへ枠を渡しました。その後の移転は元の確認を通りません。
金額上限は一度も起動しませんでした。承認操作自体の金額はゼロだからです。
規則は、送金だけでなく資金の支配権を変える全操作を覆います。 承認、委任、管理者変更、upgradeを一つも省けません。
T8 の詳しい例が、初期化経路が無防備という権限境界と、実装自身へ直接call可能というupgradeリスクの両方を示します。
library自身の初期化を直接呼んでownerになり、自壊させた結果、依存する全walletが同時に空の殻になりました。
upgrade可能なコントラクトでは、実装とproxyを別々に監査し、誰でも実装を直接呼ぶと仮定します。
変数を一つ変える
各関数で少量のGasを増やし、同一関数と関数間の再入を防ぎます。一見安全です。
しかし、読み取り経路(view を守れない)と、コントラクト間の状態不整合(送金中の中間状態を統合先が読む)は残ります。
長期的には順序への警戒が緩みます。次の改版で新関数へのロックを忘れ、または節約のため外すと、残る防御は順序だけなのに、その順序を維持していません。 だから両方が必要です。
再入はほぼ消え、バッチGas上限と、一受取人の失敗による全体停止も解決します。
代償として大半の操作が二取引になり、体験と製品の複雑さが増します。未受取という新状態も不変条件に含め、未受取合計と支払済み合計は、支払うべき合計と一致します。
万能ではなく、体験を代償に順序リスク一群を取り除く選択で、即時性を求めない場面に向きます。
最初の預金者による0 shareや、多くの精度の自滅問題はほぼ消えます。
しかし丸め方向の規則は全て必要です。誤差はゼロではなく、一度方向を逆にすれば、十分に繰り返すことで小さな誤差が累積増幅します。多くの搾取型攻撃がこの形です。
総資産を直接残高から読む根因も解決しません。share inflationの三条件で丸めは一つだけです。高精度でも内部記帳と仮想shareは必要です。
誰もロジックを変えられず、storageのずれ、初期化横取り、一鍵で全変更するリスクは消えます。
反対に、脆弱性を見つけても直せません。 公開済みの脆弱性があると、早く撤資するよう利用者を案内し、徐々に抜かれるのを見るしかない場合があります。
安全か否かではなく、恐れる制御喪失の種類を選びます。upgrade可能なら鍵の悪用、不可なら脆弱性を修正できないことです。 成熟した折衷は、timelock付きガバナンスへupgrade権限を渡し、変更を遅く透明にし、利用者が事前に見て退出できるようにします。
持ち帰る問い
誰がリスクを負うか?答えは、公開した人と信頼する利用者です。 server bugのようにhot fixできず、公開後は誰でも見え、不可逆で、常時監視されます。修正の基準は、正しく見えるかではなく、正しいと証明するテストがあるかです。
Tokenがゼロでも動くか?別の問いでは、依存する外部コントラクトが異常なら、自分はどうなるか? 外部callは想定外の値を返し、callbackで想定外の処理をし得ます。全依存を潜在的な異常源と考えて初めて、耐障害性が生まれます。
AIの誤りは誰の損失か?監査で危険なのは誤検知より見逃しで、安全と思わせます。読み取り経路とコントラクト間の権限伝達は特に漏れます。責任は署名して公開する人にあります。AIは手掛かりを生成できても結論の代わりにはできず、結論は常に成功と失敗を示すテストです。
章末チェック
脆弱性は一行でなく、二行の順序と守られていない経路にあるためです。
金庫の各行は正しいですが、送金後、記帳前に実行権を渡しました。構文ではなく状態順序の誤りです。
順序、権限、精度、upgradeの四種類は、一行の書き間違いではなく構造の隙間です。
確認、状態変更、外部操作のCEIでは、実行権を渡す前に整合が取れ、相手がcallbackで何をしても最終状態しか読めません。同一関数、関数間、読み取りの全形態を覆います。
ロックは対象関数への再入を止めますが、同じロックを共有しない関数間と、ロックの効かない view という二つの盲点があります。
順序は根治でも後の変更で戻る可能性があり、そのときロックが救うので両方必要です。
被害者は統合先であることが多く、ロックが全く効かないためです。
view がshare価格など派生値を返し、その分子と分母が外部操作の前後で同期せず更新されると、操作中に別のコントラクトが view から中間状態を読みます。ロックは view を守れず、この経路を止めません。
唯一の防御は、整合を取る状態を、現在の関数で明示的に変える変数から、外部が読む全ての派生値へ広げることです。
誤差は常にプロトコルに有利な側へ。
利用者へ渡すshare、償還資産、担保価値は切り捨て、債務と手数料は切り上げます。さらに、精度喪失の増幅を防ぐため、乗算を先、除算を後にします。
一回の小誤差でなく、同方向の誤差の反復増幅を防ぎます。逆の丸めを何十万回も繰り返す搾取型攻撃があります。
標準解答はありません。次を確認します。
- 資産保存の条件はあるか。残高が全利用者残高の合計以上など、基本的で順序問題を防ぐ条件です。
- 外部が読む派生値の条件はあるか。share価格が常に妥当な範囲にあるなど、読み取り経路を守ります。
- 権限の到達可能性の条件はあるか。許可アドレス以外は、どの経路でも重要状態を変えられないなどです。
- 各条件を関数にし、何百万のランダム操作列で毎回確認できるか。できなければ感覚にとどまります。
- 意図して違反を作り、確認が失敗すると示せるか。違反を検知できない条件は、ないのと同じです。
感覚を、機械が繰り返し検証する命題に変えることが、T30への橋です。
要点
脆弱性の多くは構文エラーではなく、状態の順序と権限の境界から生じる。