CtrlK
BlogDocsLog inGet started
Tessl Logo

access-control

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

64

Quality

77%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Fix and improve this skill with Tessl

tessl review fix ./packages/decepticon/decepticon/skills/standard/contracts/access-control/SKILL.md
SKILL.md
Quality
Evals
Security

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:

PatternBug
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 ERC2771ContextForwarder 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

BugSeverity
Unauth withdraw / mint / transferOwnershipCritical
Unauth upgradeTo (proxy)Critical (escalates to any)
Unauth setOracle / setFeeHigh (DoS or value manipulation)
tx.origin auth + phishableHigh
Initializer re-entryHigh (proxy takeover)
Role hierarchy lets non-admin grant adminHigh
Function defaults to external (Solidity <0.5 only)High
Selector collision in proxyHigh (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)
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.