name: access-control description: Missing modifiers, wrong msg.sender checks, default-public functions, missing onlyOwner / onlyRole / onlyDAO authorization. metadata: subdomain: smart-contracts when_to_use: "smart contract access control onlyowner missing modifier privilege" mitre_attack: - T1190
Access Control Playbook
Access control bugs are the most boring class — and the most common in production audits. They're cheap to find with grep + LSP. They drain millions when missed (LeetSwap, Audius, Saddle, Akropolis).
Audit steps
1. Find every state-changing function
# Functions that are NOT view/pure and NOT internal/private
grep -rE 'function [a-zA-Z_]+\(.*\)(public|external)' src/ | grep -v 'view\|pure'
# Or via slither
slither . --print function-summary
2. For each external/public state-changer, ask
- Does it modify storage that affects user funds, ownership, or configuration?
- Is there a modifier (
onlyOwner,onlyRole,onlyDAO, custom auth)? - If yes, what's the modifier checking?
- If no, should there be one?
3. Audit each modifier
Common modifier patterns + bugs:
| Pattern | Bug |
|---|---|
require(msg.sender == owner) |
owner settable by anyone? setOwner unprotected? |
require(msg.sender == tx.origin) |
Wrong — tx.origin breaks meta-transactions, AND it's phishable |
_msgSender() in OZ ERC2771Context |
Forwarder trusted but anyone can forward — does the contract validate the forwarder? |
onlyRole(MINTER) |
Who can grant MINTER? Is the admin a multisig or single key? |
require(initialized == false) |
Initializer can be called twice if initialized writeable elsewhere |
require(block.timestamp > deployTime + 24 hours) |
Time-based is often a fake delay — check if deployTime is settable |
require(approvedSigners[msg.sender]) |
Approval list managed by single key? |
4. Specific anti-patterns to grep
# Functions accidentally external (default in Solidity <0.5)
grep -rn 'function [a-zA-Z_]*[^ ]* *{' src/ | grep -v 'internal\|private\|public\|external'
# msg.sender == tx.origin (phishable)
grep -rn 'tx.origin' src/
# Reentrancy in access-control checks
grep -rn 'onlyOwner.*nonReentrant' src/ # both? often wrong order
# `delegatecall` without auth gate
grep -rn 'delegatecall' src/
# `selfdestruct` available
grep -rn 'selfdestruct\|suicide(' src/
5. Initializer bugs
// VULNERABLE
function initialize(address _owner) external {
owner = _owner;
// missing: require(!initialized); initialized = true;
}
// VULNERABLE (proxy implementation)
contract Impl {
constructor() { ... } // NEVER RUNS in proxy context
// initialize() should set proxy state but doesn't gate
}
// SAFE
function initialize(address _owner) external initializer {
// OZ's initializer modifier enforces single-call
__Ownable_init(_owner);
}
For UUPS/Transparent proxies: check _authorizeUpgrade is overridden
and gated. Default OZ override is empty (revert).
6. Function-selector collisions (proxies)
Transparent proxy admin function selectors collide w/ impl function selectors → admin functions become uncallable, OR impl functions are shadowed by admin. Check w/ slither:
slither-check-erc src/Impl.sol --erc ERC1967
7. Role grant/revoke
For AccessControl:
DEFAULT_ADMIN_ROLEis the admin of all roles by default- Anyone with DEFAULT_ADMIN_ROLE can grant any role to anyone
- If init code grants DEFAULT_ADMIN_ROLE to deployer w/ no transfer to multisig → key person risk
Check role hierarchy:
grep -rn '_setRoleAdmin\|_setupRole\|grantRole' src/
PoC template (Foundry)
function test_unauth_call() public {
address attacker = address(0xBEEF);
vm.prank(attacker);
// Call the function that should require auth
target.dangerousFunction(arg1, arg2);
// Assert state change happened
assertEq(target.criticalParam(), expectedManipulatedValue);
// No revert = vulnerable
}
function test_role_takeover() public {
address attacker = address(0xBEEF);
vm.startPrank(attacker);
// If grantRole is callable by anyone
target.grantRole(target.MINTER_ROLE(), attacker);
assertTrue(target.hasRole(target.MINTER_ROLE(), attacker));
// Now exploit MINTER_ROLE
target.mint(attacker, 1_000_000 ether);
}
function test_init_re_entry() public {
// Call initialize twice
target.initialize(address(this));
vm.expectRevert("Initializable: contract is already initialized");
target.initialize(attacker);
}
function test_upgrade_no_auth() public {
address attacker = address(0xBEEF);
address malicious = address(new MaliciousImpl());
vm.prank(attacker);
target.upgradeTo(malicious);
// Now any call goes to malicious — drain
assertEq(target.totalSupply(), 0);
}
Severity calibration
| Bug | Severity |
|---|---|
Unauth withdraw / mint / transferOwnership |
Critical |
Unauth upgradeTo (proxy) |
Critical (escalates to any) |
Unauth setOracle / setFee |
High (DoS or value manipulation) |
tx.origin auth + phishable |
High |
| Initializer re-entry | High (proxy takeover) |
| Role hierarchy lets non-admin grant admin | High |
| Function defaults to external (Solidity <0.5 only) | High |
| Selector collision in proxy | High (function unreachable) |
CVSS
- Unauth drain function: 9.8-10.0
- Unauth upgrade: 10.0
- Unauth admin / role-change: 9.0
- DoS via unauth pause: 7-8
Defender remediation
// Use OpenZeppelin patterns
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract MyContract is AccessControlUpgradeable, UUPSUpgradeable {
bytes32 public constant MINTER = keccak256("MINTER");
function initialize(address admin) external initializer {
__AccessControl_init();
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function mint(address to, uint256 amt) external onlyRole(MINTER) {
_mint(to, amt);
}
function _authorizeUpgrade(address) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}
}
Known exemplars
- Audius (Jul 2022): $6M; unauth initialize on governance proxy
- LeetSwap (Aug 2023): $625k; unauth swap fn enabled draining
- Saddle (Apr 2022): $11M; rounding + access combo
- Akropolis (Nov 2020): $2M; access + reentrancy combo
- Pickle Finance (Nov 2020): $20M; missing access on the jar
- Wormhole (Feb 2022): $325M; signature verification bug — adjacent (sig-replay/SKILL.md)
- Nomad (Aug 2022): $190M; missing message-merkle-root validation
- Multichain (Jul 2023): $126M; private-key compromise (not strictly access-control, but exploited admin)