access-control

star 4.3k

Missing modifiers, wrong msg.sender checks, default-public functions, missing onlyOwner / onlyRole / onlyDAO authorization.

PurpleAILAB By PurpleAILAB schedule Updated 6/2/2026

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_ROLE is 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)
Install via CLI
npx skills add https://github.com/PurpleAILAB/Decepticon --skill access-control
Repository Details
star Stars 4,323
call_split Forks 860
navigation Branch main
article Path SKILL.md
More from Creator