security について
Solidity 開発者向けのスマートコントラクトセキュリティガイドです。一般的な脆弱性や DeFi のリスク、アップグレード時の注意点を、防御パターンとコード例で解説します。
主な機能
- 01再入可能性とアクセス制御の解説
- 02トークン精度と安全な呼び出しへの対応
- 03オラクルと MEV のリスクの説明
- 04ERC-4626 インフレ攻撃の緩和策
- 05プロキシアップグレードと署名の安全性
活用シーン
- 01コントラクトの一般的な脆弱性の確認
- 02安全なトークン操作と価格設定の設計
- 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、USDT | 6 |
| WBTC | 8 |
| 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)
- チェック — 入力と条件を検証します
- 状態変更 — すべての状態を更新します
- 外部呼び出し — 最後に外部呼び出しを行います
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 wei を預け入れ、1 シェアを受け取る
- 攻撃者が Vault に 1000 トークンを直接送金する(預け入れ経由ではない)
- 1 シェア = 1001 トークンになる
- 被害者が 1999 トークンを預け入れ、
1999 * 1 / 2000 = 0 sharesを受け取る(切り捨て) - 攻撃者が 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
| UUPS | Transparent | |
|---|---|---|
| アップグレードロジックの配置場所 | 実装コントラクト内 | プロキシコントラクト内 |
| ユーザーのガスコスト | 低い(呼び出しごとの管理者チェックが不要) | 高い(毎回の呼び出しで 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 {}
}
重要なルール
constructorの代わりにinitializerを使う — プロキシではコンストラクターが実行されません- ストレージレイアウトを決して変更しない — 新しい変数は末尾に追加するだけにし、削除や並べ替えは行いません
- OpenZeppelin のアップグレード可能なコントラクトを使う —
@openzeppelin/contractsではなく@openzeppelin/contracts-upgradeableを使います - コンストラクターで初期化を無効にする — 誰かが実装を直接初期化するのを防ぎます
- アップグレード権限をマルチシグに移す — アップグレード権限を単一の 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 - 保護されていない状態変更関数
さらに読む
- OpenZeppelin Contracts: https://docs.openzeppelin.com/contracts — 監査済みで、実績のある実装
- SWC Registry: https://swcregistry.io — 包括的な脆弱性カタログ
- Rekt News: https://rekt.news — 実際のエクスプロイトに関する事後分析
- SpeedRun Ethereum: https://speedrunethereum.com — 実践的なセキュア開発の練習
デプロイ前のセキュリティチェックリスト
本番環境にデプロイする前に、すべてのコントラクトで必ず確認してください。例外はありません。
- アクセス制御 — 管理者権限または特権を持つすべての関数に、明示的な制限が設定されている
- 一時停止機能のトレードオフ —
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 から一行のメモを送ってください。問題点でも称賛でも構いません。