Pre-Mortem is a weekly series by Kostas Ferles, CTO of AuditHub. This article looks at access control checks that are present but logically broken, and a static pass that catches them before deployment. New breakdown every week. Find Kostas at @KFerles.
The audit firm Veridise just published the auditor’s view of this bug: its CEO, Jon Stephens, shows how a senior reviewer catches an inverted access control check by hand (the Auditor’s Take). This post takes the same bug from the AuditHub side, where that judgment becomes a check that runs automatically. The function that set the rewards root was guarded by an access control check, and it still let any address on the chain through. Nothing about it looks broken. The owner can update the root and the tests pass, so a review moves on. What catches a check like that before the contract ships, and what keeps catching it after the next change lands?
TL;DR
- Privileged setters with a
msg.senderguard can pass for every caller. - A Vanguard detector flags any guard whose
msg.senderclause is not==. - Run on every commit, it catches the post-audit change before deploy.
A present guard is not a restriction
A guard can be present and still restrict no one. A require on msg.sender with a clean revert string: that is what a reviewer sees at a glance, and seeing it is what ends the review early. But it settles nothing about who actually gets through. That comes down to the logic, and the logic breaks quietly: a comparison written backwards, an || where an && belongs, a condition that happens to hold for every input. The check still reads like access control. It just excludes no one.
This is not a SuperRare problem, and it is rarely a cheap one. In August 2021, Poly Network lost around $611 million this way. The function that replaced the bridge’s trusted signers was meant to be called only by a management contract, but that contract could be directed to call arbitrary targets. An attacker routed a crafted message through it, installed themselves as the signer, and drained the bridge (SlowMist’s analysis walks the full path). Rikkei Finance, in April 2022, lost about $1.1 million to a plainer version: its price-feed setter shipped with no caller check at all, so anyone could point the protocol at a feed they controlled and empty the lending pools (CertiK’s breakdown). SuperRare is the quietest of the three. The guard was present and looked right. It admitted everyone anyway.
Why every caller passes the check
The condition is short enough to check by hand, and that is the quickest way to see the bug. Here is the deployed guard on the function that sets the rewards root:
|
1 2 3 4 5 6 7 8 |
function updateMerkleRoot(bytes32 newRoot) external override { require( (msg.sender != owner() || msg.sender != address(0xc2F3...ddc)), "Not authorized to update merkle root" ); // ... elided } |
For this require to revert, both inequalities have to be false in the same call, which would need msg.sender to equal the owner and the authorized address at once. No address is both, so the condition is always true and every caller passes, the two privileged ones included.
Written correctly, the guard admits only the two privileged addresses and reverts for everyone else:
|
1 2 3 4 5 |
require( msg.sender == owner() || msg.sender == address(0xc2F3...ddc), "Not authorized to update merkle root" ); |
The slip is a half-finished negation: the comparisons flipped from == to !=, but the || stayed where the exclusion needed &&. The owner can still update the root, and the suite stays green, since no test in it checks who the guard turns away.
The failure was the predicate, not a missing guard
The contract was RareStakingV1, which handled staking for the SuperRare NFT marketplace. Stakers locked RARE and claimed rewards each round against a Merkle root, and updating that root was supposed to be limited to the owner or a single authorized address. On July 28, 2025, someone called updateMerkleRoot with a root of their own, set themselves as the recipient of the entire pool, and walked off with roughly $730,000 in RARE. A front-runner caught the pending transaction and landed a copy one block after the exploit contract went live.
One detail matters more than the rest. The team has said the broken guard arrived in a commit made after the contract’s first review, so no auditor ever had it in front of them. The manual cue, the inequality against msg.sender that tells a senior reviewer to stop and read what a guard actually permits, is what Jon Stephens works through in the Auditor’s Take. Stay here for the version that does not wait for someone to look.
What a static pass settles
This bug is a property of the source code itself, not of any state the contract has to reach. The condition is already wrong when you read it, before a single transaction runs. That makes it a job for static analysis. The check a reviewer performs by eye, stopping on a msg.sender inequality, is structural, so it can be written down once and run on every function.
The shape we need to flag is a state-changing function that anyone can call and whose only msg.sender guard uses something other than ==. A custom detector written in the query language of Vanguard, our static analyzer, encodes exactly that property:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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", } } ) |
The query keeps every concrete, externally callable, state-changing function that is not guarded by a require whose msg.sender clauses are all equality checks. Run on RareStakingV1, it returns updateMerkleRoot: the require is there, but its clauses compare with !=. It flags the simpler case from earlier just as well: a privileged function with no msg.sender check at all, the shape behind Rikkei’s loss.
The detector has two limits. It over-reports by design: a function that is public on purpose will land in the results, and so will one guarded through a pattern the query does not model yet, OpenZeppelin’s role-based modifiers being the common case. Each of those takes a moment to dismiss. It also checks the shape of a guard, not its meaning: whether a well-formed == compares against the right address is a question only a human can ultimately answer. What it does settle is the thing that mattered here: no state-changing function slips through with a msg.sender guard that is not an equality check.
From a habit to a check on every commit
What makes a detector like this worth having is when it runs, not just what it finds. Any review, manual or automated, certifies the code that existed on the day it ran. The code keeps moving after that day, and this guard is exactly the kind of change that lands in the gap: small, plausible, green in CI.
That is the argument for continuous security: these checks belong in the development workflow itself, running on every push and every pull request. Then the commit that turns == into != fails review before merge, with the finding in front of the developer who made the change, while it still costs a review comment instead of a pool. The property never changes and the code never stops changing, so the check has to travel with the code.
Want checks like this running on your codebase?
One inverted operator opened a privileged function to the whole chain. If you want this check, and many others like it, running on your codebase on every commit, try AuditHub for free or book a demo.


