Smart Contract Access Control Bugs Hunters Actually Find
Smart contract access control vulnerability hunting is less about exotic crypto and more about asking, every privileged path: who can call this, and did anyone forget to check? This Andrax tutorial — by Syed Zada Abrar — covers ~60% theory, ~40% local PoC patterns.
Prerequisites: Solidity Essentials; Foundry + Slither Setup. Also useful: Solidity Reentrancy Explained; Hub; How to Start Web3 Bug Hunting in 2026.
Ethics fence: every vulnerable contract below is ILLUSTRATIVE / local lab only. Run PoCs with Foundry, Anvil, Remix VM, Ethernaut-style challenges, or official CTFs. Never call privileged functions on live protocols “to confirm,” and never broadcast exploit transactions on mainnet or public testnets of in-scope code. Cite OWASP SC01:2026 Access Control Vulnerabilities. Follow Immunefi rules.
Why access control is SC01
OWASP ranks access control as SC01:2026 — improper enforcement of who may invoke privileged behavior, under which conditions, and with which parameters (SC01). Modern DeFi spreads privilege across owners, governors, guardians, proxy admins, pause roles, and cross-module “trusted” callers. One missing check on emergencyWithdraw, initialize, upgradeTo, or a hook entry point can mean full compromise.
Focus surfaces SC01 calls out (learn these names):
- Ownership / admin controls (
onlyOwner, governor, multisig) - Upgrade and pause mechanisms (proxy admins, guardians)
- Fund movement and accounting (mint/burn, fee routing, pool config)
- Cross-chain or cross-module trust boundaries (bridges, vault routers, messengers)
Attack patterns hunters actually report:
- Missing modifiers or role checks on sensitive functions
- Wrong
msg.senderassumptions (meta-tx,delegatecall, “trusted” hooks) - Unprotected initialization / re-initialization
- Privilege confusion across modules
Pattern 1 — Missing onlyOwner (and friends)
The classic: a function that looks admin-only in comments or UI, but has no modifier.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE / LOCAL LAB ONLY — do not deploy publicly
contract MissingOwnerVault {
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
/// Intended admin rescue — forgot the require
function emergencyWithdraw(address to, uint256 amount) external {
require(address(this).balance >= amount, "insufficient");
(bool ok, ) = to.call{value: amount}("");
require(ok, "send failed");
}
function transferOwnership(address newOwner) external {
// Anyone can become owner — SC01 teaching smell
owner = newOwner;
}
}
Hunter habit: for every state-changing external function, ask:
- Does this move funds, mint, burn, pause, upgrade, or change roles?
- Is there an explicit
require/ custom error /onlyOwner/onlyRole? - Is the check on the right identity (
msg.sendervs a user-supplied address)?
OpenZeppelin documents battle-tested primitives: Ownable (onlyOwner) and AccessControl (RBAC with onlyRole). Prefer those over bespoke role systems unless you have a strong reason — and then audit the bespoke path harder.
Safer sketch (still not production-complete):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE safer pattern — local teaching only
contract GuardedVault {
address public owner;
error NotOwner();
error ZeroAddress();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(address initialOwner) {
if (initialOwner == address(0)) revert ZeroAddress();
owner = initialOwner;
}
function emergencyWithdraw(address to, uint256 amount) external onlyOwner {
if (to == address(0)) revert ZeroAddress();
(bool ok, ) = to.call{value: amount}("");
require(ok, "send failed");
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
owner = newOwner;
}
receive() external payable {}
}
Production code should prefer OpenZeppelin Ownable / Ownable2Step / AccessControl rather than hand-rolled modifiers (OZ Access Control guide).
Pattern 2 — Unprotected / re-callable initialize
Upgradeable proxies cannot use constructors the way normal contracts do. Setup moves into an initialize function. If that function lacks an initializer guard (or the implementation is left initializable), an attacker may take over roles — a staple of SC01 and SC10 discussions.
OpenZeppelin’s upgrades docs show the incomplete pattern (callable forever) and the Initializable fix, plus _disableInitializers() on the implementation constructor (Writing Upgradeable Contracts).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE — broken initializer (local lab only)
contract BrokenInitVault {
address public owner;
bool public ready;
/// Missing one-time guard — anyone can call again
function initialize(address newOwner) external {
owner = newOwner;
ready = true;
}
function drain(address to) external {
require(msg.sender == owner, "not owner");
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "send failed");
}
receive() external payable {}
}
Hunter checklist for proxies:
- Proxy
initializecallable only once (and only by intended party at first use) - Implementation contract cannot be initialized by strangers (
_disableInitializersor equivalent) - No second “setup” path that grants
DEFAULT_ADMIN_ROLEwithout auth - Role admin graph makes sense (who can grant what?)
Pattern 3 — tx.origin authentication
tx.origin is the EOA that started the transaction. msg.sender is the immediate caller. Phishing-style contracts can trick a victim EOA into calling an attacker contract that then calls your vault — tx.origin still equals the victim.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE — tx.origin auth (local lab only)
contract TxOriginVault {
address public owner;
constructor() {
owner = msg.sender;
}
function withdrawAll(address to) external {
require(tx.origin == owner, "not owner"); // WRONG identity
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "send failed");
}
receive() external payable {}
}
/// ILLUSTRATIVE phishing helper — Remix VM / Foundry only
contract Phish {
TxOriginVault public vault;
address public attacker;
constructor(TxOriginVault v, address a) {
vault = v;
attacker = a;
}
/// Victim EOA is coaxed to call this; vault sees tx.origin == victim
function pounce() external {
vault.withdrawAll(attacker);
}
}
Rule of thumb for hunters: if you see tx.orin in an auth check, treat it as a finding candidate until proven intentional and safe under the threat model (almost never for fund movement).
Roles vs ownership — what good looks like
| Primitive | When hunters expect it | Failure mode to hunt |
|---|---|---|
Ownable / onlyOwner | Simple admin surfaces | Missing on emergency / upgrade / mint |
Ownable2Step | Safer ownership transfer | One-step transfer to wrong address |
AccessControl / onlyRole | Split minter / burner / guardian | Unscoped DEFAULT_ADMIN_ROLE; role never granted but function still open |
| Timelock / governance | Parameter & upgrade changes | Bypass path that skips delay |
| Hook / callback ACL | Uniswap v4-style hooks, flash receivers | Missing onlyPoolManager / trusted-caller check |
SC01’s fixed example uses explicit GOVERNANCE_ROLE and GUARDIAN_ROLE separation for emergency withdrawals (SC01). When reviewing real code, map every privileged selector to a role — including “boring” ones like setFee, setOracle, rescueTokens.
Lab — unprotected emergencyWithdraw (Foundry / Ethernaut-style)
Goal: prove an unauthorized caller can drain a local vault, then patch with onlyOwner and show the test flips.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
/// LOCAL LAB ONLY
contract MissingOwnerVault {
address public owner;
constructor() {
owner = msg.sender;
}
function emergencyWithdraw(address to, uint256 amount) external {
(bool ok, ) = to.call{value: amount}("");
require(ok, "send failed");
}
receive() external payable {}
}
contract AccessControlLabTest is Test {
MissingOwnerVault vault;
address attacker = address(0xBEEF);
function setUp() public {
vault = new MissingOwnerVault();
vm.deal(address(vault), 10 ether);
}
function test_anyoneCanEmergencyWithdraw() public {
uint256 beforeBal = attacker.balance;
vm.prank(attacker);
vault.emergencyWithdraw(attacker, 10 ether);
assertEq(address(vault).balance, 0);
assertEq(attacker.balance, beforeBal + 10 ether);
}
}
forge test --match-test test_anyoneCanEmergencyWithdraw -vvvv
Lab write-up (five sentences): root cause (missing auth), preconditions (any caller + funds in contract), impact (direct theft of contract ETH), fix (onlyOwner / RBAC + events), and why you did not touch a live protocol.
Optional: run slither . and note access-control detectors — then confirm manually; tools miss comment-only “intent” and wrong-identity checks (tx.origin, user-supplied op.sender).
Hunting RBAC and `DEFAULT_ADMIN_ROLE
When a codebase uses OpenZeppelin AccessControl, do not stop at “they imported OZ.” Walk the role graph:
- List every
onlyRole(...)/hasRolegate and the privileged body it protects. - Find who holds
DEFAULT_ADMIN_ROLE(constructor grants,grantRoleevents on a fork — read-only). - Check whether role admins can grant themselves dangerous roles without timelock.
- Look for functions that should be role-gated but only check a storage
address public admininconsistently. - Confirm renounce / transfer paths cannot brick or silently widen privilege.
OZ’s guide stresses that DEFAULT_ADMIN_ROLE is self-admin by default and carries significant risk — extensions like AccessControlDefaultAdminRules exist specifically to harden that role (Access Control). Hunters should treat “admin is a single hot EOA” as an operational risk finding when in scope, separate from missing modifiers.
Emergency paths deserve extra paranoia
emergencyWithdraw, rescueTokens, sweep, skim, pause, and setOracle are finding magnets because teams add them late under pressure. Review them as if they were upgradeTo:
- Auth present?
- Destination restricted (no arbitrary
to)? - Events emitted for monitoring?
- Callable while paused / during reentrancy windows?
Missing auth on rescue is often Critical when the contract holds user funds — prove it locally first.
How to report access control (preview)
Later spokes cover Immunefi report craft. Your notes should already contain:
- Privileged function + missing / broken check
- Who can call it now (EOA, any contract, specific role)
- Runnable Foundry test on local deployment (or in-scope fork without broadcasting)
- Impact in IVS language (unauthorized mint, drain, upgrade, pause griefing)
- Fix: OZ
Ownable/AccessControl, lock initializers, replacetx.origin, add trusted-caller checks on hooks
Common hunter traps
| Trap | Better habit |
|---|---|
Only grepping for onlyOwner | Also audit roles, initializers, hooks, “rescue” paths |
| Assuming verified OZ import ⇒ safe usage | Confirm modifiers are actually applied |
| Claiming Critical from a view-only admin leak | Show state-changing impact |
| Mainnet “just one call to check” | Local forge test is the check |
| Ignoring implementation initialize | Test the implementation address too |
Historical incidents (study, don’t recreate)
OWASP SC01 links 2025 case discussions (Balancer V2 access assumptions, Zoth admin/upgrade path, Cork Protocol hook caller validation) as access-control-class studies (SC01 and the public analyses linked there). Learn patterns — missing caller checks, brittle single-EOA admin, user-supplied identity compared to msg.sender. Rebuild analogous bugs only in local labs or official CTFs. Never validate a blog PoC against the live protocol.
Checklist before you call it access control
- Privileged behavior identified (funds, roles, upgrade, pause, config)
- Intended auth model named (owner / role / governance / hook trust)
- Actual check missing, wrong, or bypassable
- Local Foundry (or CTF) proof passes for the bug and fails after fix
- Scope & Immunefi rules respected (no mainnet abuse)
- Severity tied to impact evidence
Next steps
- Prev (live): Solidity Reentrancy Explained · Foundry + Slither Setup
- Live: Solidity Essentials · Hub
- Next (live): DeFi Flash Loan Attacks Explained (Authorized Labs Only) · Oracle Manipulation in Smart Contracts: Spot, TWAP, Staleness
- Climb Ethernaut ownership / gatekeeper-style levels and map each to missing modifier vs wrong identity vs initializer
Educational content by Syed Zada Abrar for Andrax (andraxpentester.in). Authorized research only. All vulnerable contracts ILLUSTRATIVE / local lab only.