mcpField
S
security

security

github.com/austintgriffithセキュリティ30240 回インストール2 日前に更新
リポジトリ

security について

Solidity 開発者向けのスマートコントラクトセキュリティガイドです。一般的な脆弱性や DeFi のリスク、アップグレード時の注意点を、防御パターンとコード例で解説します。

主な機能

  1. 01再入可能性とアクセス制御の解説
  2. 02トークン精度と安全な呼び出しへの対応
  3. 03オラクルと MEV のリスクの説明
  4. 04ERC-4626 インフレ攻撃の緩和策
  5. 05プロキシアップグレードと署名の安全性

活用シーン

  1. 01コントラクトの一般的な脆弱性の確認
  2. 02安全なトークン操作と価格設定の設計
  3. 03プロキシコントラクトのアップグレード計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/austintgriffith/ethskills --skill security

インストール後、Claude Code で/securityを入力して読み込まれたか確認します。

詳細

スマートコントラクトのセキュリティ

よくある誤解

「Solidity 0.8 以降ならオーバーフローは防げるので、安全です。」 オーバーフローは、数十ある攻撃ベクトルのうちの1つにすぎません。現在、特に大きな脅威となっているのは、リエントランシー、オラクル操作、承認を悪用する攻撃、桁数の扱いの誤りです。

「テストして動作することを確認しました。」 正しく動作することと、安全であることは同じではありません。多くの攻撃では、開発者が想定していなかった順序で関数を呼び出したり、想定外の値を指定したりします。

「小さなコントラクトなので、監査は必要ありません。」 The DAO のハッキングは、単純なリエントランシーバグが原因でした。Euler のエクスプロイトは、チェックが1つ欠けていただけでした。サイズと安全性は相関しません。

重大な脆弱性(防御コード付き)

1. トークンによって小数桁数は異なる

USDC の小数桁数は18ではなく6です。 「資金はどこへ行った?」というバグの最大の原因です。

// ❌ WRONG — assumes 18 decimals. Transfers 1 TRILLION USDC.
uint256 oneToken = 1e18;

// ✅ CORRECT — check decimals
uint256 oneToken = 10 ** IERC20Metadata(token).decimals();

一般的な小数桁数:

トークン小数桁数
USDC、USDT6
WBTC8
DAI、WETH、ほとんどのトークン18

小数桁数の異なるトークン間で計算する場合は、まず正規化します。

// Converting USDC amount to 18-decimal internal accounting
uint256 normalized = usdcAmount * 1e12; // 6 + 12 = 18 decimals
2. Solidity には浮動小数点数がない

Solidity には float や double がありません。除算では小数部分が切り捨てられます。

// ❌ WRONG — this equals 0
uint256 fivePercent = 5 / 100;

// ✅ CORRECT — basis points (1 bp = 0.01%)
uint256 FEE_BPS = 500; // 5% = 500 basis points
uint256 fee = (amount * FEE_BPS) / 10_000;

必ず乗算してから除算します。 先に除算すると、精度が失われます。

// ❌ WRONG — loses precision
uint256 result = a / b * c;

// ✅ CORRECT — multiply first
uint256 result = (a * c) / b;

複雑な計算には、PRBMath や ABDKMath64x64 などの固定小数点ライブラリを使用します。

3. リエントランシー

外部呼び出しによって、最初の呼び出しが完了する前にコントラクトへ再入できます。外部呼び出しの後に状態を更新すると、攻撃者は古い状態のまま再入します。

// ❌ VULNERABLE — state updated after external call
function withdraw() external {
    uint256 bal = balances[msg.sender];
    (bool success,) = msg.sender.call{value: bal}(""); // ← attacker re-enters here
    require(success);
    balances[msg.sender] = 0; // Too late — attacker already withdrew again
}

// ✅ SAFE — Checks-Effects-Interactions pattern + reentrancy guard
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

function withdraw() external nonReentrant {
    uint256 bal = balances[msg.sender];
    require(bal > 0, "Nothing to withdraw");
    
    balances[msg.sender] = 0;  // Effect BEFORE interaction
    
    (bool success,) = msg.sender.call{value: bal}("");
    require(success, "Transfer failed");
}

基本パターン:チェック → 状態変更 → 外部呼び出し(CEI)

  1. チェック — 入力と条件を検証します
  2. 状態変更 — すべての状態を更新します
  3. 外部呼び出し — 最後に外部呼び出しを行います

CEI に加えて、安全策として常に OpenZeppelin の ReentrancyGuard を使用します。

4. SafeERC20

一部のトークン(特に USDT)は、transfer() や approve() で bool を返しません。標準的な呼び出しでは、成功していてもリバートします。

// ❌ WRONG — breaks with USDT and other non-standard tokens
token.transfer(to, amount);
token.approve(spender, amount);

// ✅ CORRECT — handles all token implementations
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;

token.safeTransfer(to, amount);
token.safeApprove(spender, amount);

ほかにも注意すべきトークンの特性:

  • 送金手数料のあるトークン: 受取額 < 送金額になります。必ず送金前後の残高を確認します。
  • リベーストークン(stETH): 送金なしで残高が変化します。ラップ済みのトークン(wstETH)を使用します。
  • 一時停止可能なトークン(USDC): トークンが一時停止されていると、送金がリバートすることがあります。
  • ブロックリスト機能のあるトークン(USDC、USDT): 特定のアドレスが取引を禁止されることがあります。
5. DEX の現物価格をオラクルとして使わない

フラッシュローンを使うと、1回のトランザクション内であらゆるプールの現物価格を操作できます。これにより、数億ドルもの損失が発生しています。

// ❌ DANGEROUS — manipulable in one transaction
function getPrice() internal view returns (uint256) {
    (uint112 reserve0, uint112 reserve1,) = uniswapPair.getReserves();
    return (reserve1 * 1e18) / reserve0; // Spot price — easily manipulated
}

// ✅ SAFE — Chainlink with staleness + sanity checks
function getPrice() internal view returns (uint256) {
    (, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
    require(block.timestamp - updatedAt < 3600, "Stale price");
    require(price > 0, "Invalid price");
    return uint256(price);
}

オンチェーンの価格データを使う必要がある場合:

  • 30分以上の TWAP(時間加重平均価格)を使用します。単一ブロックでの操作に耐性があります。
  • Uniswap V3 には observe() による組み込みの TWAP オラクルがあります(検証済みアドレス:addresses/SKILL.md)。
  • 高額な判断においては、依然として Chainlink より安全性が劣ります。
6. Vault のインフレーション攻撃

ERC-4626 Vault の最初の預金者は、シェア価格を操作して、後から預け入れるユーザーから資金を盗むことができます。

攻撃の手順:

  1. 攻撃者が 1 wei を預け入れ、1 シェアを受け取る
  2. 攻撃者が Vault に 1000 トークンを直接送金する(預け入れ経由ではない)
  3. 1 シェア = 1001 トークンになる
  4. 被害者が 1999 トークンを預け入れ、1999 * 1 / 2000 = 0 shares を受け取る(切り捨て)
  5. 攻撃者が 1 シェアを償還し、3000 トークンすべてを受け取る

対策 — 仮想オフセット:

function convertToShares(uint256 assets) public view returns (uint256) {
    return assets.mulDiv(
        totalSupply() + 1e3,    // Virtual shares
        totalAssets() + 1        // Virtual assets
    );
}

仮想オフセットにより攻撃の採算が合わなくなります。比率を操作するには、攻撃者は膨大な量のトークンを寄付する必要があります。

OpenZeppelin の ERC4626 実装では、v5 以降、この緩和策がデフォルトで含まれています。

7. 無制限の承認

承認額に type(uint256).max を使用しないでください。

// ❌ DANGEROUS — if this contract is exploited, attacker drains your entire balance
token.approve(someContract, type(uint256).max);

// ✅ SAFE — approve only what's needed
token.approve(someContract, exactAmountNeeded);

// ✅ ACCEPTABLE — approve a small multiple for repeated interactions
token.approve(someContract, amountPerTx * 5); // 5 transactions worth

無制限の承認を受けたコントラクトが悪用された場合(プロキシのアップグレードバグ、ガバナンス攻撃、未発見の脆弱性など)、攻撃者は無制限のアクセスを許可したすべてのユーザーから、承認されたトークンをすべて引き出せます。

8. アクセス制御

状態を変更する関数には、すべて明示的なアクセス制御が必要です。「誰がこの関数を呼び出せるべきか?」を最初に考えましょう。

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

// ❌ WRONG — anyone can drain the contract
function emergencyWithdraw() external {
    token.transfer(msg.sender, token.balanceOf(address(this)));
}

// ✅ CORRECT — only owner
function emergencyWithdraw() external onlyOwner {
    token.transfer(owner(), token.balanceOf(address(this)));
}

複雑な権限設定には、OpenZeppelin の AccessControl を使い、ロールベースで分離します(ADMIN_ROLE、OPERATOR_ROLE など)。

9. 入力検証

入力を決して信用しないでください。すべてを検証しましょう。

function deposit(uint256 amount, address recipient) external {
    require(amount > 0, "Zero amount");
    require(recipient != address(0), "Zero address");
    require(amount <= maxDeposit, "Exceeds max");
    
    // Now proceed
}

見落とされがちな検証項目:

  • ゼロアドレス(トークンを 0x0 に送ると、永久に焼却されます)
  • ゼロ数量(ガスを無駄にし、ゼロ除算が発生する可能性があります)
  • バッチ処理における配列の長さの不一致
  • 配列内の重複エントリー
  • 妥当な上限を超える値

MEV とサンドイッチ攻撃

MEV(最大抽出可能価値): バリデーターやサーチャーは、ブロック内のトランザクションの順序を入れ替えたり、挿入したり、検閲したりできます。ユーザーのトランザクションを先回りして実行したり、後続で実行したり、あるいはその両方を行って利益を得ます。

サンドイッチ攻撃

DeFi ユーザーに対する、最も一般的な MEV 攻撃です。

1. You submit: swap 10 ETH → USDC on Uniswap (slippage 1%)
2. Attacker sees your tx in the mempool
3. Attacker frontruns: buys USDC before you → price rises
4. Your swap executes at a worse price (but within your 1% slippage)
5. Attacker backruns: sells USDC after you → profits from the price difference
6. You got fewer USDC than the true market price
対策
// ✅ Set explicit minimum output — don't set amountOutMinimum to 0
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter
    .ExactInputSingleParams({
        tokenIn: WETH,
        tokenOut: USDC,
        fee: 3000,
        recipient: msg.sender,
        amountIn: 1 ether,
        amountOutMinimum: 1900e6, // ← Minimum acceptable USDC (protects against sandwich)
        sqrtPriceLimitX96: 0
    });

ユーザー/フロントエンド向け:

  • Flashbots Protect RPC(https://rpc.flashbots.net)を使います。トランザクションはプライベートメンプールに送信され、サンドイッチボットから見えなくなります。
  • スリッページの上限を厳しく設定します(主要トークンは 0.5~1%、小規模トークンは 1~3%)。
  • パブリックメンプールではなくソルバーを経由してルーティングする、MEV 対応の DEX アグリゲーター(CoW Swap、1inch Fusion)を使います。

MEV が問題となる場合:

  • DEX でのあらゆるスワップ(特に大規模なスワップ)
  • あらゆる大規模な DeFi トランザクション(預け入れ、引き出し、清算)
  • 需要の高い NFT のミント(ボットが先回りして最初にミントします)

MEV が問題とならない場合:

  • 単純な ETH/トークンの送金
  • L2 のトランザクション(シーケンサーはトランザクションを順番どおりに処理するため、パブリックメンプールで順序を入れ替えられることはありません)
  • プライベートメンプールのトランザクション(Flashbots、MEV Blocker)

プロキシパターンとアップグレード可能性

スマートコントラクトは、デフォルトでは不変です。プロキシを使うと、同じアドレスと状態を保ったままロジックをアップグレードできます。

プロキシを使う場面
  • プロキシを使う: リリース後にバグ修正や機能追加が必要になる可能性がある、長期運用のプロトコル
  • プロキシを使わない: MVP、シンプルなトークン、設計上不変のコントラクト、「誰にも変更できないこと」自体が価値提案であるコントラクト

プロキシは、複雑さ、攻撃対象領域、信頼に関する前提を増やします。 ユーザーは、管理者が悪意ある実装にアップグレードしないと信頼しなければなりません。使えるからという理由だけでプロキシを使わないでください。

UUPS と Transparent Proxy
UUPSTransparent
アップグレードロジックの配置場所実装コントラクト内プロキシコントラクト内
ユーザーのガスコスト低い(呼び出しごとの管理者チェックが不要)高い(毎回の呼び出しで msg.sender をチェック)
推奨はい(OpenZeppelin 推奨)レガシーパターン
リスク_authorizeUpgrade を忘れるとコントラクトがロックされますガスのオーバーヘッドが増えます

UUPS を使ってください。 より安価でシンプルであり、OpenZeppelin も推奨しています。

UUPS の実装
// Implementation contract (the logic)
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyContractV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    uint256 public value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // Prevent implementation from being initialized
    }

    function initialize(address owner) public initializer {
        __Ownable_init(owner);
        __UUPSUpgradeable_init();
        value = 42;
    }

    function _authorizeUpgrade(address) internal override onlyOwner {}
}
重要なルール
  1. constructor の代わりに initializer を使う — プロキシではコンストラクターが実行されません
  2. ストレージレイアウトを決して変更しない — 新しい変数は末尾に追加するだけにし、削除や並べ替えは行いません
  3. OpenZeppelin のアップグレード可能なコントラクトを使う — @openzeppelin/contracts ではなく @openzeppelin/contracts-upgradeable を使います
  4. コンストラクターで初期化を無効にする — 誰かが実装を直接初期化するのを防ぎます
  5. アップグレード権限をマルチシグに移す — アップグレード権限を単一の EOA に残さないでください
// ❌ WRONG — reordering storage breaks everything
// V1: uint256 a; uint256 b;
// V2: uint256 b; uint256 a;  ← Swapped! 'a' now reads 'b's value

// ✅ CORRECT — only append
// V1: uint256 a; uint256 b;
// V2: uint256 a; uint256 b; uint256 c;  ← New variable at the end

EIP-712 署名と Delegatecall

EIP-712:型付き構造化データの署名

EIP-712を使うと、ドメイン分離とリプレイ保護を適用しながら、ユーザーが構造化データ(生バイト列だけではありません)に署名できます。ガスレス承認、メタトランザクション、オフチェーンでの注文署名に使用されます。

使用する場面:

  • Permit(ERC-2612) — ガスレスのトークン承認(ユーザーが署名し、誰でも送信できます)
  • オフチェーン注文 — 買い注文や売り注文にオフチェーンで署名し、オンチェーンで決済します(0x、Seaport)
  • メタトランザクション — ユーザーが意図に署名し、リレイヤーが送信してガス代を支払います
// EIP-712 domain separator — prevents replay across contracts and chains
bytes32 public constant DOMAIN_TYPEHASH = keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

bytes32 public constant PERMIT_TYPEHASH = keccak256(
    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);

function permit(
    address owner, address spender, uint256 value,
    uint256 deadline, uint8 v, bytes32 r, bytes32 s
) external {
    require(block.timestamp <= deadline, "Permit expired");

    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline
    ));
    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01", DOMAIN_SEPARATOR(), structHash
    ));

    address recovered = ecrecover(digest, v, r, s);
    require(recovered == owner, "Invalid signature");

    _approve(owner, spender, value);
}

主な特性:

  • ドメインセパレーターは、異なるコントラクトやチェーンで署名がリプレイされるのを防ぎます
  • ノンスは、同じ署名が2回リプレイされるのを防ぎます
  • 期限は、古い署名が後で使用されるのを防ぎます
  • 実際には、OpenZeppelinのEIP712とERC20Permitを使用してください。ゼロから実装しないでください
Delegatecall

delegatecallは、呼び出し元のストレージコンテキストで別のコントラクトのコードを実行します。呼び出されたコントラクトのロジックが実行されますが、読み取りと書き込みはあなたのコントラクトのストレージに対して行われます。

信頼できないターゲットに対して実行すると、非常に危険です。

// ❌ CRITICAL VULNERABILITY — delegatecall to user-supplied address
function execute(address target, bytes calldata data) external {
    target.delegatecall(data); // Attacker can overwrite ANY storage slot
}

// ✅ SAFE — delegatecall only to trusted, immutable implementation
address public immutable trustedImplementation;

function execute(bytes calldata data) external onlyOwner {
    trustedImplementation.delegatecall(data);
}

Delegatecallのルール:

  • ユーザーから提供されたアドレスに対してdelegatecallを実行しないでください — 任意のストレージ操作が可能になります
  • あなたが管理するコントラクトに対してのみdelegatecallを実行してください — できれば不変のコントラクトを使用してください
  • ストレージレイアウトを一致させる必要があります — 呼び出し元のコントラクトとターゲットのコントラクトでは、ストレージ変数の順序が完全に同一でなければなりません
  • プロキシはこの仕組みで動作します — プロキシが実装コントラクトにdelegatecallを実行するため、実装コントラクトのコードがプロキシのストレージ上で実行されます。アップグレード可能なコントラクトでストレージレイアウトが非常に重要なのはこのためです。

自動セキュリティツール

デプロイ前に、以下を実行してください。

# Static analysis
slither .                     # Detects common vulnerabilities
mythril analyze Contract.sol  # Symbolic execution

# Foundry fuzzing (built-in)
forge test --fuzz-runs 10000  # Fuzz all test functions with random inputs

# Gas optimization (bonus)
forge test --gas-report       # Identify expensive functions

絶対に無視してはいけないSlitherの検出結果:

  • リエントランシーの脆弱性
  • 戻り値の未確認
  • 任意のdelegatecallまたはselfdestruct
  • 保護されていない状態変更関数

さらに読む


デプロイ前のセキュリティチェックリスト

本番環境にデプロイする前に、すべてのコントラクトで必ず確認してください。例外はありません。

  • アクセス制御 — 管理者権限または特権を持つすべての関数に、明示的な制限が設定されている
  • 一時停止機能のトレードオフ — Pausable と onlyOwner を追加した場合は、ビルダーに伝えてください。全ユーザーを凍結できる単一の鍵は、検閲の手段となります。タイムロックまたはマルチシグガバナンスを提案してください。
  • 再入可能性の保護 — CEI パターンを採用し、外部呼び出しを行うすべての関数に nonReentrant を指定している
  • トークンの小数桁の処理 — 小数桁が異なる可能性のあるトークンに、1e18 をハードコードしていない
  • オラクルの安全性 — DEX のスポット価格ではなく、Chainlink または TWAP を使用している。古いデータを検出するチェックがある
  • 整数演算 — 除算の前に乗算している。重要な計算で精度が失われない
  • 戻り値の確認 — すべてのトークン操作に SafeERC20 を使用している
  • 入力検証 — すべての公開関数で、ゼロアドレス、ゼロ量、範囲をチェックしている
  • 入力検証(高度なケース) — 配列の長さの不一致、配列内の重複エントリー、不合理な値の範囲を拒否している
  • イベントの発行 — オフチェーンで追跡できるよう、すべての状態変更でイベントを発行している
  • インセンティブ設計 — 十分なインセンティブがあれば、誰でもメンテナンス関数を呼び出せる
  • 無制限の承認がない — 正確な金額、または上限を設けた少額の倍数を承認している
  • 手数料徴収トークンへの対応 — 任意のトークンを受け入れる場合、実際の受取額を測定している
  • MEV/サンドイッチ攻撃への保護(スワップを行う場合) — amountOutMinimum が明示的に設定され、スリッページのデフォルト値が厳しく、ユーザー向けスワップではプライベートメンプール経由のルーティングを検討している
  • プロキシの安全性(アップグレード可能な場合) — initializer を使用している(コンストラクターで初期化しない)。実装コントラクトのイニシャライザーを無効化し、OpenZeppelin のアップグレード可能なコントラクトを使用している
  • ストレージレイアウトの安全性(アップグレード可能な場合) — ストレージ変数は末尾に追加するのみで、並べ替えたり削除したりしない
  • アップグレード権限(アップグレード可能な場合) — アップグレード権限をマルチシグまたはタイムロックに移譲し、単一の EOA に与えない
  • EIP-712 のリプレイ安全性(署名を使用する場合) — ドメインセパレーター、ノンスの処理、期限が適切に適用されている
  • Delegatecall の安全性(使用する場合) — ユーザー指定のターゲットに delegatecall しない。互換性のあるストレージレイアウトを持つ、信頼できる管理下の実装のみを使用する
  • 自動解析の実行 — デプロイ前に Slither/Mythril とファジングテストを実行している
  • 解析ツールの重大な指摘を解消 — 再入可能性、戻り値の未確認、任意の delegatecall/selfdestruct、保護されていない状態変更関数に関する未解決の問題がない
  • エッジケースのテスト — ゼロ値、最大値、権限のない呼び出し元、再入可能性の試行をテストしている
  • ブロックエクスプローラーでソースを検証 — デプロイのたびに yarn verify または forge verify-contract を実行している。未検証のコントラクトはユーザーが監査できず、詐欺と見分けがつきません

作業を終える前に、feedback/SKILL.md から一行のメモを送ってください。問題点でも称賛でも構いません。

コメント