Crypto OS
Technical Crypto OS第5段階 · インフラとセキュリティ

T28 · Smart Contract Security

コードは間違っていないのに、なぜ盗まれたのか?

この章を 3D で体験する →
練習する能力
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操作の経路が成立します。

教訓は三つです。

  1. view 関数にはロックがなく、ロックを持つこともできません。 読む状態の整合を常に保つことが唯一の防御です。
  2. CEIの状態には、外部が読む全ての派生値が含まれます。その関数で明示的に変更する変数だけではありません。
  3. 統合先は、読み取る瞬間の値が中間状態ではないかを問う必要があります。 前章の「上流を信頼できなければ、自分の側に防御を置く」という規則の別の適用です。

CEI:三文字の順序

Checks        全ての確認と引数検証を先に行う
Effects       コントラクトの状態を「既に実行された」状態にする
Interactions  最後に外部とやり取りする

比較します。

問題のある順序正しい順序
引き出し送金 → ゼロ化ゼロ化 → 送金
mintcallback型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になれる

対応する三つの防御は、通常組み合わせます。

  1. 内部記帳:総資産は残高ではなく、自ら管理する変数を使い、直接送られた資金は算入しません。
  2. 仮想shareと仮想資産:分子と分母へ固定offsetを加え、0 shareになる操作を経済的に割に合わなくします。
  3. 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です。

何と呼ぶか

再入Reentrancy

外部callで実行権を渡した後、状態更新より前に、本コントラクトへ制御が戻って入ることです。

本質は二回呼ばれることではなく、状態の使用より更新が遅いことです。四形態では読み取り専用が最も見落とされ、ロックも全く効きません。

確認・状態変更・外部操作Checks-Effects-Interactions

全ての確認、コントラクト状態の変更、外部操作という関数の標準順序です。

再入の緩和ではなく根治です。例外は一つだけ。書ける不変条件を、やり取りの後に検証する方式です。

再入防止ロックReentrancy Guard

関数の実行中、再入を禁止する状態bitです。

保険であり、根治ではありません。盲点は二つ。同じロックを共有しない関数には相互に入れ、view は守れません。

アクセス制御Access Control

誰がどの関数を呼べるかです。

modifierを読むだけでなく、到達可能性の図を描きます。誰が何段で来られるか。一段でも誰でも入れるなら、全経路の答えも誰でもです。既定で拒否します。

丸めの方向Rounding Direction

整数除算に誤差は必ずあり、問題はどちら側に落ちるかだけです。

規則は、常にプロトコルに有利な側へ落とします。 利用者へ渡す分は少なく、債務は多く計算し、乗算を先にします。

upgradeのリスクUpgrade Risk

storage layoutのずれ、初期化の横取り、そしてupgradeできる人が全ロジックを一度に変更できることなど、upgrade可能性によるリスクです。

layout比較はCI、upgrade権限にはtimelockとeventが必要です。省くとコード監査の価値が大きく失われます。

不変条件Invariant

どの瞬間、どの操作列の後でも真でなければならない命題です。

価値は機械による反復検証です。書けないコントラクトは人が見るしかありませんが、書ければ何百万回もプログラムで衝突させられます。

実践

実践脆弱な金庫を書き、資金流出をテストで証明し、修正する任意のSolidity framework + ローカルnode0元。全て自分のローカルnetworkで行う

この練習は自分のローカル環境だけで行います。 各行が正しくても全体が間違うことを目で確認し、修正を覚えるためです。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

AI LabAIに監査させ、全結論を真の問題・誤検知・見逃しに分類し比率を測るLevel 2 · AI Copilot

成果物はbugの発見数ではなく、AI監査能力への信頼の境界を示すreportです。三段階で進めます。

第一段階:
私のコントラクトです(実践の金庫、読み取り経路を持つshareコントラクト、
自作の権限伝達問題を一つ含む二コントラクト構造を渡す)。
全ての安全性問題を見つけ、それぞれ次を示してください。
- 具体的な関数と行
- 起動の前提条件とcall順序
- 結果
- 修正方法
監査を推奨する、再入に注意など、具体性のない発言は不要です。

第二段階:
各報告を【現在のコードで失敗する】テストにしてください。
書けない場合は確信できていないので、その旨を記してください。

第三段階:
意図して少なくとも二か所の問題を残し、伝えていません。
読み取り経路と、コントラクト間の権限伝達です。
この二種類を特に探し、もう一度監査してください。

第一段階で典型的な動作が見えます。名前のある教科書的な問題(同一関数の再入、modifier欠落)は得意ですが、関数間・コントラクト間の推論では的中率が下がります。 文脈では成立しない理論上の可能性もnoiseとして列挙します。それが分類する理由です。

第二段階は、T8 のdelegatecall Labと同じ厳しいふるいです。悪いコードで失敗するテストが、問題の実在を証明します。 全結論のテストを求めると、捏造は書けないか、悪いコードで成功してしまいます。失敗テストを付けられない結論は全て誤検知に下げます。

第三段階は見逃しを測ります。誤検知は時間を浪費し、見逃しは安全だと思わせます。 実践で再現した読み取り経路は良い素材です。第一で報告せず、第三でも見つからなければ、このtoolは読み取り経路を代わりに守れないという具体的な境界が分かります。

最後は自分で、三比率を数え、一文で結論を書きます。 「名前のある問題の大半は掃除できるが、読み取り再入と権限伝達は自分で監査する」などです。今後の工程のどこに置くかの根拠になります。

最低限の原則:AIは監査の手掛かりを生成できますが、結論を出すあなたの代わりにはできません。T32 の、モデル出力をモデルで検証しない規則と同じです。確認は、赤と緑が分かれる確定的なテストで行います。

AI の回答後、自分で必ず検証すること

  • 真の問題とする全項目を、失敗するテストで証明する。証明できなければ誤検知に下げる
  • 具体的な関数、行、起動経路があるか。場所を示さず再入の可能性とだけ述べる項目は発見ではなくnoise
  • 実践で意図して残した読み取り経路の問題をコードに隠し、検知するか確認する。できなければ見逃し
  • ロックを付ければ安全と結論し、順序が根治したかを説明できていないか
  • 丸め方向を、誤差はプロトコル側という規則で自分で再確認する
  • 引用したlibrary、modifier名、標準番号が実在するかを全て自分で確認する
  • 真の問題、誤検知、見逃しの三比率を自分で数え、toolへの信頼の境界を書く

実例

各行は正しいが順序が違い、金庫から重複引き出し2016年、著名なDAO

巨額を集めたコントラクトの引き出しは、最初の例と同じ送金後にゼロ化する形でした。この順序を使い、一取引で何度も引き出して自分のshare以上の資金を持ち出しました。

影響はchainのforkにまで至りました。根因は信じ難いほど単純な、二行の順序です。

業界が記帳を先にする規則を覚えた起点であり、間違いは各行ではなく二行の間にあります。 という冒頭の説明でもあります。

読み取り価格をoracleに使い、融資protocolが空になる近年繰り返し発生

share単価を返す view を持つ金庫には再入ロックがあり、完璧に見えました。しかしshare総数を送金後に更新するため、callbackの間は価格がずれました。

別の融資protocolが価格源に使い、callback中の低い価格を読んで、本来出すべきでない融資をしました。

両protocolは個別に監査を通過していましたが、組み合わせで問題が起きました。教訓は、view も攻撃面です。さらに T21 の通り、他protocolの即時状態をoracleにすることは典型的な攻撃入口です。

関数を制限しないapproveで攻撃者へ枠を渡すaccountと権限設定で繰り返し発生

T27 の例を契約側から見ます。特定操作だけのはずの権限が関数を制限しないため、承認関数を呼んで管理下のアドレスへ枠を渡しました。その後の移転は元の確認を通りません。

金額上限は一度も起動しませんでした。承認操作自体の金額はゼロだからです。

規則は、送金だけでなく資金の支配権を変える全操作を覆います。 承認、委任、管理者変更、upgradeを一つも省けません。

無防備な初期化で実装を乗っ取り、破壊する2017年、あるmultisig wallet library

T8 の詳しい例が、初期化経路が無防備という権限境界と、実装自身へ直接call可能というupgradeリスクの両方を示します。

library自身の初期化を直接呼んでownerになり、自壊させた結果、依存する全walletが同時に空の殻になりました。

upgrade可能なコントラクトでは、実装とproxyを別々に監査し、誰でも実装を直接呼ぶと仮定します。

変数を一つ変える

もし全関数にロックを付け、CEIの順序は確認しない

各関数で少量のGasを増やし、同一関数と関数間の再入を防ぎます。一見安全です。

しかし、読み取り経路(view を守れない)と、コントラクト間の状態不整合(送金中の中間状態を統合先が読む)は残ります。

長期的には順序への警戒が緩みます。次の改版で新関数へのロックを忘れ、または節約のため外すと、残る防御は順序だけなのに、その順序を維持していません。 だから両方が必要です。

もし全external callをpull方式にする

再入はほぼ消え、バッチGas上限と、一受取人の失敗による全体停止も解決します。

代償として大半の操作が二取引になり、体験と製品の複雑さが増します。未受取という新状態も不変条件に含め、未受取合計と支払済み合計は、支払うべき合計と一致します。

万能ではなく、体験を代償に順序リスク一群を取り除く選択で、即時性を求めない場面に向きます。

もし金額計算を高精度の固定小数点にし、誤差をほぼゼロにする

最初の預金者による0 shareや、多くの精度の自滅問題はほぼ消えます。

しかし丸め方向の規則は全て必要です。誤差はゼロではなく、一度方向を逆にすれば、十分に繰り返すことで小さな誤差が累積増幅します。多くの搾取型攻撃がこの形です。

総資産を直接残高から読む根因も解決しません。share inflationの三条件で丸めは一つだけです。高精度でも内部記帳と仮想shareは必要です。

もしupgrade不可にしてupgradeリスクを全て消す

誰もロジックを変えられず、storageのずれ、初期化横取り、一鍵で全変更するリスクは消えます。

反対に、脆弱性を見つけても直せません。 公開済みの脆弱性があると、早く撤資するよう利用者を案内し、徐々に抜かれるのを見るしかない場合があります。

安全か否かではなく、恐れる制御喪失の種類を選びます。upgrade可能なら鍵の悪用、不可なら脆弱性を修正できないことです。 成熟した折衷は、timelock付きガバナンスへupgrade権限を渡し、変更を遅く透明にし、利用者が事前に見て退出できるようにします。

持ち帰る問い

9
誰がリスクを負うか?

誰がリスクを負うか?答えは、公開した人と信頼する利用者です。 server bugのようにhot fixできず、公開後は誰でも見え、不可逆で、常時監視されます。修正の基準は、正しく見えるかではなく、正しいと証明するテストがあるかです。

11
トークン価格がゼロになっても、プロダクトは動くか?

Tokenがゼロでも動くか?別の問いでは、依存する外部コントラクトが異常なら、自分はどうなるか? 外部callは想定外の値を返し、callbackで想定外の処理をし得ます。全依存を潜在的な異常源と考えて初めて、耐障害性が生まれます。

17
AI が間違えたとき、損失を負うのは誰か?

AIの誤りは誰の損失か?監査で危険なのは誤検知より見逃しで、安全と思わせます。読み取り経路とコントラクト間の権限伝達は特に漏れます。責任は署名して公開する人にあります。AIは手掛かりを生成できても結論の代わりにはできず、結論は常に成功と失敗を示すテストです。

章末チェック

要点

脆弱性の多くは構文エラーではなく、状態の順序と権限の境界から生じる。

この章の実践課題を終えたらチェックしてください進捗をすべて見る

このページの内容