In the last few weeks two protocols were drained through the same class of bug: an access-control exploit. RISEx lost ~$673k. WEMIX.FI Lend lost ~$730k. Different codebases, same shape of mistake - a state-changing function that should have been restricted to an owner or role, but wasn't.
This is, year after year, one of the most common ways money leaves a smart contract. And it's one of the few high-impact bug classes a static tool can actually catch before deploy - because it's a property of the code, not of an off-chain secret.
The bug, in one screen
The pattern looks like this - a critical function with no guard:
// A function that moves value or changes who's in control...
function setPriceOracle(address newOracle) external {
oracle = newOracle; // no onlyOwner, no role check
}
function withdraw(address to, uint256 amount) external {
IERC20(token).transfer(to, amount); // anyone can call this
}
There's no onlyOwner, no onlyRole(...), no require(msg.sender == ...). Whoever finds it first calls it. Point an attacker's script at setPriceOracle (repoint the oracle, then liquidate the protocol at a fake price) or straight at withdraw, and the funds are gone.
The fix is trivial once you see it:
function setPriceOracle(address newOracle) external onlyOwner {
oracle = newOracle;
}
The hard part isn't the fix. It's spotting the missing modifier in a 2,000-line codebase, on a Friday, days before mainnet.
Why most scanners are useless here
Every static analyzer "checks access control." The problem is they cry wolf: they flag every function without a modifier - including view getters, internal helpers, functions guarded by an inline require(msg.sender == owner), functions with a custom checkAccess() modifier, base functions that a child overrides with auth, one-time initializers guarded by an if (initialized) revert, and so on. You get 40 "unprotected function" findings, 39 of them noise, and you learn to ignore the list - right before the one real one ships.
So the whole game is precision. A finding is only worth anything if, when it fires, it's real.
The deterministic check (calibrated to zero false positives on OpenZeppelin)
I run an open-source scanner and I calibrated its access-control detector against the most-audited code in web3 - all of OpenZeppelin, 237 production contracts - until it reported zero findings, then kept only the checks that survived. Concretely, it flags a critical function (setX, mint, upgrade, withdraw, sweep, initialize...) as unprotected only when none of these are present:
- an access modifier (
onlyOwner,onlyRole,requiresAuth,ifAdmin, customonly*/*Only/ governance-combined modifiers), - an inline guard in the body (
require(msg.sender == owner),_checkOwner(),_onlyGovernance(),if (HUB != msg.sender) revert...), - a one-time-init guard (
if (_initialized) revert,require(factory == address(0))- a set-once clone/init setter isn't "unprotected"), - an override of a base function that is guarded in the concrete contract.
It also skips view/pure, internal/private (not externally reachable), and interface/flattened declarations. Each of those exclusions came from a real false positive I hunted down by hand and turned into a permanent, deterministic rule. The result: when it fires, it's worth reading.
That's the whole philosophy - a report that's silent on OpenZeppelin but loud on a genuinely unguarded withdraw() is worth more than one that flags 600 things.
The honest limits
A scanner catches the code bug: the missing guard. It will not save you from a leaked deployer key, a compromised hot wallet, or a malicious upgrade pushed by a real admin - several of this month's other losses were exactly that, and no static tool catches those. Access control in code is the part that's checkable before you ship, and it's worth checking, because it's cheap to catch and expensive to miss.
If you're days from mainnet
Run the scanner yourself (MIT, one command, or a GitHub Action on every PR). Or send me your repo and I'll do a free first pass - I only flag what's real, and if a paid report later isn't useful to you, you don't pay.
The two protocols above didn't need a $50k audit to avoid this. They needed one person to notice one missing word.
Hack figures and classification from DefiLlama's public feed (api.llama.fi/hacks).
Top comments (0)