The Auditor’s Take is a weekly series by Jon Stephens, CEO of Veridise. This article is about an access control bug that can be easily overlooked since a check is present but effectively does not restrict access. New take every week. Find Jon at @FormallyJon.
A require statement can correctly identify the authorized parties but be implemented in such a way that effectively no restriction is enforced, allowing almost every caller to proceed. That is the shape of the bug that drained $730,000 from SuperRare’s reward contract in July 2025.
TLDR:
- A negated condition in one require statement let any address pass the access control check
- Two clauses in a disjunction compared
msg.senderto a different address with!=, so every caller satisfies a clause - The fix asserts membership in the privileged set, not exclusion from it
- When checking access control, comparing
msg.senderwith!=is the cue to stop - A simple Vanguard custom detector flags this shape on every commit
Access Control Fails Even When the Check Is Present
Access control is the first thing an auditor looks for and the easiest to overlook. A function carries a modifier or a require on msg.sender, and a reviewer scanning a function list sees the markers of a working guard and keeps going.
None of that is a guarantee. A guard restricts the caller set only if its condition is false for some callers. When that fails in the code, a privileged function ends up callable by addresses it was meant to exclude, and no stolen key is involved: the contract’s own logic is the flaw.
This class has produced some of the largest losses in blockchain security. Poly Network lost about $611 million in August 2021. A privileged function that set the protocol’s trusted signers could be reached through another contract that was allowed to call anything, so an attacker made themselves the only authorized signer and drained the bridge wallets. SlowMist confirmed no signer’s private key was leaked. Rikkei Finance lost about $1.1 million in April 2022. The function that set its price feed shipped with no access control, so any caller could swap in a malicious one and drain the lending pools, a loss CertiK traced to that single missing check.
SuperRare is the same class in a quieter form. A check was present, but its logic let every caller through. No function was intentionally left unguarded and no key was stolen; the guard was just written so that it never restricted anyone.
A Check That Passes for Every Address
SuperRare is an NFT marketplace on Ethereum. Its staking contract, RareStakingV1, let users stake RARE and claim rewards each round, with a Merkle root encoding the per-address allocations. Only the owner or an authorized address was meant to update that root.
On July 28, 2025, an attacker called updateMerkleRoot with a root they controlled, listed themselves as the recipient for the full pool, and drained 11,907,875 RARE, about $730,000.
By the team’s account, the check was introduced in a change made after the contract’s initial review.
The Property the Check Was Supposed to Enforce
Let’s state the property in plain English first: updateMerkleRoot should execute only when msg.sender is the owner or the authorized address. Everything else should revert.
The deployed code:
|
1 2 3 4 5 6 7 |
function updateMerkleRoot(bytes32 newRoot) external override { require( (msg.sender != owner() || msg.sender != address(0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc)), "Not authorized to update merkle root" ); // ... } |
The condition is a tautology. No single address is both owner() and 0xc2F3... at the same time, so at least one of the two != clauses is always true, the || is always true, and the require never reverts. Take each caller in turn: the owner satisfies the second clause, the authorized address satisfies the first, and everyone else satisfies both. Everyone passes.
The intended check:
|
1 2 3 4 |
require( msg.sender == owner() || msg.sender == address(0xc2F394a45e994bc81EfF678bDE9172e10f7c8ddc), "Not authorized to update merkle root" ); |
The correct condition asserts membership in the privileged set. The deployed one negated each comparison while keeping the ||. The De Morgan-correct exclusion would have flipped the connective too, to != && !=. The result resembles how a developer would write the same guard with a custom error: if (msg.sender != owner && msg.sender != authorized) revert NotAuthorized(). That form is correct because it joins the two != checks with &&. Dropped into a require and joined with || instead, the same two comparisons invert into a condition that is true for everyone.
From a distance the function has all the markers of a working guard, a require, a msg.sender check, and an error string, which is exactly why a reviewer scrolls past it.
How a Senior Auditor Reads a Caller Check
Access control review is a core step in any smart contract audit: find the functions that check msg.sender to decide who can call them, then confirm that check actually restricts the caller set for each one. The second step is where this bug lives.
When you read an access control check, an inequality against msg.sender should stop you. Few contracts have a rule that one specific address may not interact with them, and for good reason: an exclusion rule is trivial to defeat. Anyone you name and block can deploy a fresh address that is not on the list and walk straight in. Access control names who is allowed, not who is kept out. So when a require says msg.sender != x, that is the cue to stop and work the cases by hand rather than read past the shape. The tell is the direction of the comparison. require(msg.sender != owner()) already restricts the wrong thing: it lets everyone except the owner through, the opposite of what a guard should do. Adding a second != joined by ||, as SuperRare did, widens that to every address including the owner. Either way, the moment a privileged check turns on != instead of ==, stop and read what it actually permits.
In the cases our team has reviewed, an inequality against msg.sender inside a caller check is uncommon. That is what makes it worth stopping on. It is not proof of a bug, and an inequality on msg.sender is perfectly normal in different types of checks. It is a high-signal cue that the access control deserves to be read rather than skimmed.
From a Manual Habit to a Check That Runs on Every Commit
The habit is a structural one: in an access control check, a clause using msg.sender should compare two values using an equality check, and anything else is worth a second look. This structural code smell can be identified with the help of tooling, so these oddities are not overlooked, which is where tooling extends the lens instead of replacing it.
Vanguard is the static analyzer that fits here. A custom detector, written in its query language, encodes the whole property in a single pass:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
FIND Contract c, Function f IN c, WHERE -- this is to skip interface declarations c.isContract && c.isConcrete, f.isExternallyCallable, -- restrict to functions that have side-effects f.mutability != "view", f.mutability != "pure", -- (2) that is NOT guarded by a require/revert involving msg.sender (!EXISTS MsgSender m IN f.reachable, RequireLike r IN m.interprocForwardSlices -- (3) whose clauses are ALL in the `msg.sender == x` format WHERE { !EXISTS ConditionClause cl IN r.clauses, MsgSender m_ IN cl.value.interprocBackwardSlices WHERE { cl.kind != "EQ", } } ) |
This is one way to write a simple access control detector. It identifies every external function that can modify state without validating msg.sender with an equality comparison. In SuperRare’s case, the buggy function modifies state but has no matching msg.sender == x check, so the detector flags it.
What a structural check cannot settle is whether a present, well-formed guard names the correct address. That still comes down to reading the logic. The detector narrows where to look; the auditor decides whether the restriction actually holds.
The detector is deliberately broad, and it will raise false alarms on functions meant to be open to anyone. This is a starting point rather than a finished access control detector as it does not yet account for popular patterns like OpenZeppelin’s access control modifiers. Even so, the results are quick to review. Running it on every commit is what keeps a post-audit change covered. A check like this passes every test that does not specifically probe who it rejects, so the detector catches the inverted condition the moment the change lands.
What Carries Beyond This Contract
Two
!=clauses joined by||restrict no one, because the condition is true for every caller. A check can namemsg.senderwhile enforcing no actual restriction.If you maintain contracts, the durable safeguard is a test. For every privileged function, write negative tests that confirm a caller without the right privileges is rejected. A test proving an unauthorized address cannot call
updateMerkleRootwould have caught SuperRare before it shipped, and it catches the next inverted guard too.Read the next access control check you write as a claim about a set, not as a line of boilerplate. The question is never whether the guard is there. It is whether the set it admits is the set you intended.
Working on a similar protocol?
If your contracts gate a priviledged function with a check on msg.sender, an inverted condition can pass every test that does not specifically probe what calls revert.
If you want a second pair of eyes before your next deploy, talk to us.
The Takeaway
A smart contract access control vulnerability can exist even when the require statement is present: something as small as an inverted operator can break critical invariants a contract intends to enforce. In SuperRare’s RareStakingV1, that negated check let any address update the reward Merkle root and drain $730,000. Catching this class means verifying what an access control check evaluates to, not just confirming a check exists.