Back to Cheat Sheets

🔑 Access Control Vulnerabilities

OWASP Smart Contract Top 10 (2025) - SC01

CRITICAL RISK

📋 What Is It?

Access control is the set of rules deciding who may invoke which state-changing operation. An access-control vulnerability exists when a privileged function - one that moves funds, mints tokens, changes ownership, upgrades logic, pauses, or destroys the contract - can be called by an account that should never have been permitted. A deployed contract is public, immutable, and permissionlessly callable: there is no firewall and no login to fail. If a treasury-draining function lacks an authorization check, the check simply does not exist, and bots scanning the mempool exploit it within a block.

SC01 OWASP Rank
Critical Severity
Direct Exploit Path

⚠️ Common Exploits

  • Missing Modifier: Sensitive function has no onlyOwner/role check at all
  • tx.origin Auth: Phishing contract acts with the owner's authority
  • Open Initializer: Unguarded initialize() lets anyone become owner
  • Unguarded Upgrade: Public setImplementation / raw delegatecall
  • Unprotected selfdestruct: Anyone erases the contract and sweeps its balance
  • Inverted Modifier: != instead of == lets everyone but the owner through
  • Weak Ownership Transfer: Public or single-step setter enables takeover/lockout
  • Default Visibility: Legacy code leaves a helper externally callable

🔴 Attack Flow

1. Read verified source / decompile bytecode
↓
2. List every external, state-changing function
↓
3. Spot one with no modifier or a broken guard
↓
4. Directly call withdraw / mint / seize ownership
↓
5. IMPACT: Treasury drained, supply inflated, protocol taken over!

❌ Vulnerable Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract Treasury { address public owner; constructor() { owner = msg.sender; } // No modifier: ANY account can drain the contract function withdraw(uint256 amount) public { payable(msg.sender).transfer(amount); ← unguarded! } // tx.origin passes even when a malicious contract makes // the call, as long as the owner started the tx (phishing) function transferTo(address payable to, uint256 amt) external { require(tx.origin == owner, "not owner"); ← wrong! to.transfer(amt); } // Unguarded initializer: attacker calls it and becomes owner function initialize(address _owner) external { owner = _owner; ← no `initializer` guard } // Anyone can destroy the contract and take the balance function kill() public { selfdestruct(payable(msg.sender)); ← no auth } receive() external payable {} }

✅ Secure Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/access/Ownable2Step.sol"; contract Treasury is AccessControl, Ownable2Step { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); constructor(address admin) Ownable(msg.sender) { _grantRole(DEFAULT_ADMIN_ROLE, admin); // least privilege } // Guarded, authenticated with msg.sender via audited library function withdraw(uint256 amount) external onlyOwner { require(amount <= address(this).balance, "insufficient"); payable(owner()).transfer(amount); } // Only holders of MINTER_ROLE may mint function mint(address to, uint256 amt) external onlyRole(MINTER_ROLE) { // _mint(to, amt); } // Ownable2Step: transferOwnership() only PROPOSES; the pending // owner must call acceptOwnership() - no typo/zero-address lockout. // No selfdestruct at all; shutdown (if needed) is governance-gated. receive() external payable {} } // Upgradeable? Lock the initializer on the implementation: // constructor() { _disableInitializers(); } // function initialize(address o) external initializer { ... } // function _authorizeUpgrade(address) internal override onlyOwner {}

✓ Prevention Checklist

  • Explicit modifier on EVERY privileged function
  • Authenticate with msg.sender, never tx.origin
  • Use OpenZeppelin Ownable/AccessControl
  • Guard initializers with initializer
  • Call _disableInitializers() on implementations
  • Restrict upgrades via _authorizeUpgrade
  • Never expose a public delegatecall target
  • Two-step ownership transfer; reject zero address
  • Remove or governance-gate selfdestruct
  • Declare explicit function visibility
  • Least privilege + role separation (multisig/timelock)
  • Negative tests: unauthorized callers MUST revert

🔍 Detection & Tools

Analysis Tools:

Slither Mythril Echidna Foundry Manticore Securify

Libraries & Patterns:

OpenZeppelin Ownable2Step AccessControl Tenderly

How to Test:

  • Run slither . - detectors: suicidal, tx-origin, unprotected-upgrade
  • Run mythril analyze for reachable privileged ops
  • Enumerate every external function; map its intended caller
  • Write a reverting test for each function from a stranger address
  • Confirm the implementation cannot be initialized directly

🌍 Real-World

  • Parity Multisig Freeze (2017): An unprotected initialization function on a shared library, reached via delegatecall, let an account take ownership and call selfdestruct - bricking every wallet that depended on the library.
  • Uninitialized-Proxy Takeover class: Implementation contracts left uninitialized let an attacker call initialize, seize ownership of the logic, and hijack the upgrade path or destroy it.
  • Unprotected Privileged Function class: A mint/withdraw/setOwner shipped without its intended modifier - the most frequently reported access-control finding; searchers detect and exploit it automatically.
  • tx.origin phishing: A contract the owner is lured into calling inherits the owner's tx.origin and passes the check.

📌 Quick Tips

  • DO NOT leave any privileged function unguarded
  • DO NOT authorize with tx.origin
  • DO NOT expose a public selfdestruct or upgrade setter
  • DO use audited Ownable/AccessControl
  • DO lock initializers and disable them on the impl
  • DO transfer ownership in two steps
  • DO run Slither/Mythril and negative tests in CI