Every year the post-mortems pile up, and every year the same category sits at the top of the loss chart. Not reentrancy. Not oracle manipulation. Access control. A function that should have been guarded was not, an owner check was the wrong check, or a privileged role landed in the wrong hands. The bugs are simple to describe and brutal in their consequences, because the attacker does not need to outsmart your math. They just need to call a function you forgot to lock.
If you audit contracts, this is the category you check first. Here is the full landscape, with vulnerable and fixed Solidity for each pattern, and an audit checklist at the end.
Why Access Control Tops the Charts
Access control bugs share three traits that make them the dominant loss category:
- They are easy to introduce. A missing modifier is a single forgotten word.
- They are easy to exploit. No flash loan, no precise block timing, just a direct call.
- They are hard to spot by eye. A privileged function looks identical to a safe one until you trace who is allowed to call it.
Pattern-matching tools catch some of these, but the worst ones live in the gaps between functions and in the gap between what the code says and what the developer intended. Let's walk through them.
1. The Missing Modifier
The classic. A state-changing function that should be restricted to an admin, shipped with no guard at all.
// Vulnerable: anyone can change the protocol fee
function setFee(uint256 newFee) external {
fee = newFee;
}
Nothing stops a random address from calling this. The fix is one line, and a thousand audits have written it:
// Fixed
function setFee(uint256 newFee) external onlyOwner {
fee = newFee;
}
The reason this still happens in production is that the function looks finished. It compiles, it works in the happy-path test, and the missing word is invisible until someone reads the function asking "who is allowed to call this?" Every setter, every withdrawal, every parameter change needs that question answered explicitly.
2. The Unprotected Initializer (Proxy Front-Running)
Upgradeable proxies cannot use constructors, because the constructor runs in the context of the implementation contract, not the proxy. So the pattern moved to an initialize() function that sets the owner. The footgun is that initialize() is just a regular external function.
// Vulnerable: initialize can be called by anyone, even twice
contract Vault {
address public owner;
bool private initialized;
function initialize(address _owner) external {
owner = _owner;
}
}
If the proxy is deployed and the deployer forgets to call initialize() in the same transaction, an attacker watching the mempool calls it first and sets themselves as owner. This uninitialized-proxy pattern has drained multiple protocols, and it is one of the highest-impact bugs in the category because it hands over the entire contract.
// Fixed: use OpenZeppelin's Initializable
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Vault is Initializable {
address public owner;
function initialize(address _owner) external initializer {
owner = _owner;
}
}
The initializer modifier guarantees the function runs exactly once. Just as important: lock down the implementation contract itself with _disableInitializers() in its constructor, so nobody can initialize the logic contract directly and use it as an attack surface.
3. tx.origin Authentication
tx.origin is the original external account that started the transaction chain. msg.sender is the immediate caller. Using tx.origin for authorization breaks the moment a contract sits in the middle.
// Vulnerable to phishing
function withdraw() external {
require(tx.origin == owner, "Not owner");
payable(msg.sender).transfer(address(this).balance);
}
The attack: the owner is tricked into calling a malicious contract. That contract calls withdraw(). The check tx.origin == owner passes, because the owner did start the chain, even though they never intended to withdraw. The funds go to msg.sender, which is the attacker's contract.
// Fixed: authenticate the immediate caller
function withdraw() external {
require(msg.sender == owner, "Not owner");
payable(msg.sender).transfer(address(this).balance);
}
The rule is absolute: never use tx.origin for authorization. Its only legitimate uses are rare and defensive (for example, refusing to be called by any contract at all), and even those are increasingly discouraged with account abstraction in the picture.
4. Default Visibility and Exposed Internals
Modern Solidity forces you to declare visibility, which killed the old pre-0.5.0 footgun of functions defaulting to public. But the underlying mistake survives: marking something public or external when it should have been internal.
// Vulnerable: a helper that should never be externally callable
function _mint(address to, uint256 amount) public {
totalSupply += amount;
balances[to] += amount;
}
If that mint helper is public, anyone can print tokens. The fix is to scope it correctly and expose only a guarded wrapper:
// Fixed
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balances[to] += amount;
}
function mint(address to, uint256 amount) external onlyMinter {
_mint(to, amount);
}
When you read a contract, every public and external function is part of the attack surface. Helpers with a leading underscore that are not internal or private are a red flag worth a second look.
5. Flawed Role Management
OpenZeppelin's AccessControl is the standard for multi-role systems, and it is solid. The bugs come from misusing it: granting roles too broadly, or worse, leaving the role admin open.
// Vulnerable: anyone can grant themselves the minter role
contract Token is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
function grantMinter(address account) external {
_grantRole(MINTER_ROLE, account);
}
}
The custom grantMinter wrapper bypasses the role-admin check entirely. Anyone calls it, grants themselves MINTER_ROLE, and mints freely.
// Fixed: gate role grants behind the role's admin
function grantMinter(address account) external onlyRole(getRoleAdmin(MINTER_ROLE)) {
_grantRole(MINTER_ROLE, account);
}
A related footgun: forgetting to set DEFAULT_ADMIN_ROLE at all, which can leave roles unmanageable, or granting DEFAULT_ADMIN_ROLE to too many addresses, where any one of them can rewrite the entire permission graph. Map out the role hierarchy explicitly and confirm who can grant what.
6. Unprotected selfdestruct and Upgrade Functions
Two of the most dangerous functions a contract can expose are the ability to destroy it and the ability to swap its logic. Both must be locked down hard.
// Vulnerable: anyone can brick the contract or hijack the proxy
function kill() external {
selfdestruct(payable(msg.sender));
}
function upgradeTo(address newImplementation) external {
implementation = newImplementation;
}
An unprotected upgradeTo is the same severity as an unprotected initialize: the attacker points the proxy at their own logic and owns everything. An unprotected selfdestruct lets anyone delete the contract (and where it still force-sends ETH, redirect the balance). This pattern of a public self-destruct sitting on a shared library has frozen large sums when triggered by accident.
// Fixed
function upgradeTo(address newImplementation) external onlyOwner {
implementation = newImplementation;
}
If you use OpenZeppelin's UUPSUpgradeable, the guard goes in _authorizeUpgrade, and that override must contain a real access check. An empty _authorizeUpgrade body is the same bug wearing a respectable name.
7. Ownership Transfer Footguns
Single-step ownership transfer is a foot-gun because there is no confirmation. Pass the wrong address and ownership is gone forever.
// Risky: a typo permanently locks you out of admin functions
function transferOwnership(address newOwner) external onlyOwner {
owner = newOwner;
}
There is no validation that newOwner is correct, reachable, or even nonzero. The fix is the two-step pattern, where the new owner must accept:
// Fixed: OpenZeppelin Ownable2Step
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract Vault is Ownable2Step {
// transferOwnership now only sets a pending owner;
// the new owner must call acceptOwnership() to take control
}
Also guard against the silent renounceOwnership call, which is inherited from Ownable and, if left callable, can permanently disable every admin function with one transaction. Decide deliberately whether your contract should be able to renounce ownership at all.
The Audit Checklist
Run this against every privileged surface in the contract:
| Check | What to confirm |
|---|---|
| Modifiers | Every state-changing admin function has an access modifier |
| Initializers |
initialize() uses initializer; implementation calls _disableInitializers()
|
| tx.origin | Zero uses of tx.origin for authorization |
| Visibility | No public/external function that should be internal; helpers scoped correctly |
| Roles | Role grants gated behind role admin; DEFAULT_ADMIN_ROLE set and minimized |
| Upgrade/destroy |
upgradeTo, _authorizeUpgrade, and any selfdestruct are guarded |
| Ownership | Two-step transfer; deliberate decision on renounceOwnership
|
| Zero address | Owner and role assignments reject address(0)
|
How AI Tooling Flags This
The deterministic part of access control auditing is the part tools already handle well: list every external and public function, and flag the ones that mutate state without a modifier. The hard part is the reasoning part, the cross-function privilege paths where each function looks fine alone but together they form an escalation route, like a setFeeRecipient with no guard feeding a withdrawFees that trusts whoever the recipient happens to be.
That is where an LLM-based reviewer earns its place. In spectr-ai, the engine first enumerates every privileged function and checks for a guard, then reasons about whether the guards that exist are the correct ones and whether any sequence of unguarded calls leads to a privileged state. It treats an unprotected initialize, an empty _authorizeUpgrade, and a tx.origin check as high-severity findings, because those are the patterns that consistently top the annual loss reports.
The takeaway: access control bugs win the loss chart every year because they are cheap to make and cheaper to exploit. Read every privileged function asking one question, "who can call this and is that who I meant?", run the checklist above, and let tooling enumerate the surface so nothing slips through.
spectr-ai is open source and runs with Claude or local models via Ollama.
Top comments (0)