欠落した入力検証攻撃は、Solidityコントラクトがユーザーが制御するアドレス、金額、識別子、価格、または設定値を受け入れる際に、これらがコントラクトの経済的およびセキュリティ上の前提条件を満たしていることを証明せずに行われた場合に発生します。これらを防ぐには、資産の移動や会計の変更が行われる前に、許可リスト、範囲チェック、ゼロアドレスチェック、認証、および状態不変条件を設けて、信頼境界で外部入力を検証する必要があります。
その影響は具体的です:Qubitは2022-01-28にBSC上で$80.0Mを失い、「欠落した入力検証」と分類される事故を起こしました。これらの失敗は単なるトランザクションの不正な形式の問題に留まりません。検証されていない値は、存在しない担保に対してクレジットを付与したり、未信頼の送信先に資産を送ったり、借り手の実限度を超える負債を作成したりと、無効なビジネスアクションを正当化するために有効なSolidityコードを誤って実行させる可能性があります。
Solidityにおける入力検証の脆弱性とは何か、攻撃の仕組みは?
Solidityの入力検証脆弱性は、コントラクトが呼び出し者が提供した値を信頼できると誤認し、その値が権限、資産会計、価格設定、または状態遷移を制御している場合に発生します。攻撃者は、構文的にはABIに有効だが、プロトコル的には無効な値を提供し、その結果得られる状態を利用して資産を引き出したり、請求権を発行したり、制限を回避したりします。
Solidityは型を厳格に強制しますが、プロトコルの意図までは保証しません。uint256は非リバート可能な任意の数字になり得ますし、addressはEOA、悪意のあるコントラクト、ゼロアドレス、未サポートのトークンを識別します。コントラクトはこれらの意味的なルール欠落を補完する必要があります。
一般的な攻撃シーケンスは次の通りです:
- 攻撃者は未検証の金額、トークン、受取人、価格、または識別子を受け付ける公開関数を特定します。
- 攻撃者は、プロトコルの意図と異なる値を選択します。
- コントラクトは安全でない残高、権利、負債限度、または認証状態を記録します。
- 攻撃者は、その毒された状態を信頼する後続の関数を呼び出します。
- プロトコルは、その誤った状態に基づき資産を送金したり、譲渡可能な請求権を発行したりします。
- 他のユーザーがその会計の不足に気づき損失を被ります。
この重要な点の検討は、「この入力は何を表現していると主張しているのか、そしてそれについてコントラクトはどこで独立して検証しているのか?」です。もし答えが「呼び出し者が提供した」であれば、そのコントラクトには不適切な入力検証の脆弱性があります。
入力検証攻撃を引き起こすSolidityのパターンは何か?
入力検証攻撃は、一般に、コントラクトが外部との連携やユーザー体験のための柔軟性を認めている一方で、その柔軟性をプロトコルのサポート範囲に制約しきれていない場合に発生します。危険なパラメータは、「price」や「amount」のように明示的にわかりやすく命名されていないこともあります。例えば、トークンアドレス、ルート、受取人、トランシェID、証明、または外部コントラクトアドレスなどです。
一般的な危険なパターンは以下の通りです:
- 借り手が提供する担保額の値:借入能力の計算に使用する値を借り手が設定。
- 任意のトークンアドレスの受け付け:標準外の振る舞いを持つトークンも受け入れる。
- 検証されていない受取人アドレス:
address(0)やコントラクト自身、または保管ルールに違反するアドレスを許可。 - 無制限のパーセンテージや比率:手数料、LTV、割引、スリッページ、報酬率が想定範囲を超える。
- 配列関係の未検証:並行配列の長さ不一致、重複エントリー、または予期しない資産のルートの繰り返し。
- 事前条件なしの状態遷移:請求、キャンセル、償還、移行の動作が誤ったライフサイクル状態で実行される。
- 外部データを信頼:呼び出し者が提供した価格、残高、署名、または返り値を検証なしで信頼。
検証は文脈に依存します。require(amount > 0)は有用ですが、「amount」が支払可能か、担保されているか、上限内か、現状と整合しているかまでは保証しません。同様に、トークンアドレスがゼロでないかのチェックだけでは、そのトークンが承認済みであるか、期待通りに動作するか、正しい市場に属しているかは保証できません。
Sokenのレビュー手法は、外部パラメータごとにこれがどの状態遷移に関わるかを特定し、その後、その関数が状態を書き込む前に不変条件を確実に満たすことを検証します。
Solidityにおける入力検証例はどうなるか?
この入力検証の例では、借り手が自分のcollateralValueを提出します。コントラクトは、その値よりも大きなローンは許可しませんが、「借り手が担保を預け入れた」という前提は検証しません。そのため、呼び出し者は任意の値を設定し、借入資産を借りることが可能です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address to, uint256 value) external returns (bool);
}
contract UnsafeLender {
IERC20 public immutable asset;
mapping(address => uint256) public debt;
constructor(IERC20 asset_) {
require(address(asset_) != address(0), "zero asset");
asset = asset_;
}
function borrow(
uint256 collateralValue,
uint256 amount
) external {
require(collateralValue > 0, "no collateral");
require(amount <= collateralValue, "insufficient collateral");
// 脆弱:借入額の背後にある担保は、あくまで呼び出し者が提供したもの。
debt[msg.sender] += amount;
require(asset.transfer(msg.sender, amount), "transfer failed");
}
}
こちらの修正版は、実際に受け取った担保に基づいて借入能力を決定します。assetアドレスは制約された許可リストとして機能し、コンストラクタのチェックでゼロアドレスを排除し、LTVは範囲内に抑え、残高差の検証によって送金の不変性を担保しています。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract ValidatedLender {
IERC20 public immutable loanAsset;
IERC20 public immutable collateralAsset;
uint256 public immutable maxLtvBps;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
constructor(IERC20 loanAsset_, IERC20 collateralAsset_, uint256 maxLtvBps_) {
require(address(loanAsset_) != address(0), "zero loan asset");
require(address(collateralAsset_) != address(0), "zero collateral asset");
require(maxLtvBps_ > 0 && maxLtvBps_ <= 10_000, "invalid LTV");
loanAsset = loanAsset_;
collateralAsset = collateralAsset_;
maxLtvBps = maxLtvBps_;
}
function depositCollateral(uint256 amount) external {
require(amount > 0, "zero amount");
uint256 beforeBalance = collateralAsset.balanceOf(address(this));
require(collateralAsset.transferFrom(msg.sender, address(this), amount), "transfer failed");
uint256 received = collateralAsset.balanceOf(address(this)) - beforeBalance;
// 改善点:実際に受け取った担保のみを記録
require(received == amount, "unsupported token behavior");
collateral[msg.sender] += received;
}
function borrow(uint256 amount) external {
require(amount > 0, "zero amount");
uint256 limit = (collateral[msg.sender] * maxLtvBps) / 10_000;
require(debt[msg.sender] + amount <= limit, "borrow limit exceeded");
debt[msg.sender] += amount;
require(loanAsset.transfer(msg.sender, amount), "transfer failed");
}
}
あなたのプロトコルが柔軟な資産、ルート、または会計入力を受け入れている場合、アドバーサリアルなスマートコントラクト監査および侵入テストによる検証で、各主張値が資金に影響を与える前に独立して確立されているかどうかを追跡できます。
Solidityで入力検証攻撃を防ぐにはどうすればよいか?
入力検証攻撃を防ぐには、入力を最初に検証し、その後コントラクト全体のライフサイクルを通じてその前提を明示的な不変条件として保持する必要があります。最善の防御策は、単一の修飾子ではなく、「すべての意味のある状態変更に対して、許可された入力の範囲とその後も成立する条件」を明確に定める設計です。
| 手法 | 何を止めるか | コスト | 制限事項 |
|---|---|---|---|
| トークンおよび実装の許可リスト | 未承認のERC-20や悪意のあるアダプタ、任意の外部コール | ストレージ検索や不変アドレス比較 | 承認されたトークンでも経済リスクが残る場合がある |
| 範囲チェック | ゼロ金額、過剰なレート、不正なベースポイント、期限超過のリスク | 低コスト、通常数比較 | 範囲だけでは経済的正確さを保証できない |
| ゼロアドレスや受取人チェック | 資産のバーン、不正な所有権、無効な保管先 | 低コスト、アドレス比較1つ | ただしゼロアドレスは未検証のリスクあり |
| 認証とライフサイクル管理 | 不正な状態やアクターによる呼び出し | ストレージ読み取り、役割設計 | 役割の管理は安全な実装が必要 |
| バランス差の検証 | フェイクの預金クレジットや非サポートのトークン振る舞い | 追加のトークン残高読み取り | リベース資産の取扱に注意必要 |
| 不変条件の検証 | 債務不履行、供給、負債、準備金関係の破綻 | 追加読み取りと算術 | 不変条件は全経済モデルをカバーすべき |
柔軟性の高いプロトコルでは、設定時と使用時の両方で設定値を検証します。ガバナンスによるパラメータは、作成時には安全でも、依存関係の変更により安全でなくなる可能性があります。アップグレード可能なシステムでは、初期化パラメータも公開関数の入力と同様に厳格に検証します。
セキュリティの核心: Sokenの手法は、
require文ではなく、不変条件(invariant)から出発します。レビュアーが、ユーザ制御の入力処理後に何が確実に成立し続けるべきかを言語化できなければ、その検証が十分かどうか判断できません。
実際に分類された「欠落入力検証」事例は何か?
欠落した入力検証は、貸付、スワップ、資産管理など多様なコンテキストで重要なスマートコントラクト損失と関連しています。これらの事故は異なるチェーンや設計を横断しており、不適切な入力検証がネットワーク固有のSolidityのミスではなく、プロトコルのロジックの欠落であることを示しています。
| プロトコル | 日付 | 損失額 | チェーン |
|---|---|---|---|
| Qubit | 2022-01-28 | 8,000万ドル | BSC |
| Hedgey | 2024-04-19 | 4,470万ドル | Arbitrum、Ethereum |
| Transit Swap | 2022-10-02 | 2,890万ドル | Ethereum、BSC |
| Pickle | 2020-11-22 | 1,970万ドル | Ethereum |
これらの事故の共通点は、「単に成功裏にエンコードされ通過した値からだけでは、プロトコルの真実を安全に推論できない」という重要な教訓です。資産システムは、担保やサポートされている資産、認証済みルート、正しいライフサイクル遷移など、その価値を保護する特定の性質に基づいてセマンティックな検証を行う必要があります。
DeFiLlamaは、2020年から2026年までの間に発生した52件のスマートコントラクト事故を「欠落した入力検証」のカテゴリーに分類し、合計で3億3,050万ドルの損失を記録しています。その範囲の広さから、Sokenは入力検証のレビューを経済的・セキュリティ的観点からの重要な作業と捉え、表面的なrequireステートメントのチェックリストとして扱うことはありません。
不適切な入力検証の脆弱性をいかに検出できるか?
各外部から到達可能なパラメータがどのストレージ書き込み、外部呼び出し、資産送金に影響しているかを追跡し、不正またはABI的に有効な値が不変条件を破ることができるかどうかをテストすることで、不適切な入力検証を検出できます。静的解析は一部のチェック漏れを見つけますが、プロトコル固有の前提条件を確認するためにはシナリオ駆動のテストが不可欠です。
このレビューチェックリストを活用してください:
- 価値や権限、ルーティングに影響するすべての
externalおよびpublic関数のパラメータを列挙。 - それぞれのアドレスがゼロでないか、承認されているか、役割に適しているかを特定。
- すべての金額がプロトコルの最大値、残高、負債限度、供給限度、または小数点の仮定に合致しているかを確認。
- 価格や担保価値、残高の情報源が信頼できるものであり、独立に検証済みかを確認。
- 配列の長さ、一意性、順序、複数配列間のインデックス関係を検証。
- 各関数が適切なライフサイクル状態と認証された呼び出し者のみ許可しているかを確認。
- 送金操作に伴う残高差やリベース、異常なERC-20振る舞いを検証。
- 各資産移動操作の後条件を記録し、テスト。
Slitherは、危険な外部呼び出しや未検証の返り値、弱いアクセスコントロールなどのパターンを特定可能ですが、すべてのプロトコルの不変条件を推論できるわけではありません。Foundryのファジングを併用し、ゼロ値、最大値、重複配列、予期しないアドレス、未サポートトークンなどのケースを生成してください。
特に効果的なのは不変条件テストです。例として、未払い債務の総額が設定された借入能力を超えないか、クレジットされた担保が受領した資産を超えないか、認可された資産のみが特定の市場会計に影響を与えるかなどの検証です。それらの特性を中心に、広範なセキュリティレビュープロセスの構築にはSoken Hubも活用してください。
入力検証は、ユーザの意図とプロトコルの真実の境界です。契約は、呼び出し者の主張だけに基づいてクレジット、認証、価格付け、または移転を行ってはいけません。信頼できる状態から重要な値を導き出し、受入可能なドメインを制限し、結果として得られる状態を経済的に安全に保つ不変条件を徹底的に守ることが重要です。
次の実務的なステップとして、資産移動機能ごとにパラメータ-不変条件マッピングを作成してください。信頼できるソースや許容範囲、事後条件が未定義なパラメータは直ちに見直しの対象です。