ROOTVIELROOTVIEL
← Knowledge Base

Web3 & smart contracts

Verified by ROOTVIEL13 min

Broken Access Control & Authority

A privileged capability — ownership, an admin function, a fund movement — is reachable by someone who was never granted the authority to use it, whether through a missing modifier, an initializer that can be called again, or logic that moves value without checking either authority or entitlement.

Classification

Domain
Web3 & smart contracts
ROOTVIEL support
Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
Evidence scope
Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
Analysis family
RootRaven
CWE
CWE-284, CWE-862
OWASP
SC01

Definition

Broken access control is the class of defects in which the gap between "what a function does" and "who is allowed to make it do that" is not correctly enforced. A function changes privileged state or moves value, but the check that should bind that power to an authorized caller is missing, misordered, bypassable, or simply wrong.

In smart contracts this takes several recurring shapes: a state-changing admin function with no onlyOwner (or equivalent) guard; an initializer that can be invoked a second time to seize ownership of a contract (especially proxies); a privileged transition (pausing, upgrading, minting, withdrawing) gated on the wrong condition; and value-moving logic that satisfies neither an authority rule ("is the caller an admin?") nor an entitlement rule ("is the caller spending their own balance?").

Context

Every contract that distinguishes privileged from ordinary callers has an access-control surface: owners, admins, minters, pausers, upgraders, treasuries, keepers. The surface is largest in upgradeable (proxy) systems, where the implementation contract is initialized rather than constructed, and in multi-role systems where roles are granted and revoked at runtime. Factory-deployed contracts and modules that trust msg.sender transitively are also common sources.

  • ▸Admin/owner functions (set fees, pause, upgrade, sweep funds) missing or mis-applying a modifier.
  • ▸Proxy implementations with an initialize() that is not locked after first use (re-initializable takeover).
  • ▸Functions left at default (public) visibility that were meant to be internal.
  • ▸tx.origin used for authorization, allowing a phished user’s contract call to pass the check.
  • ▸Fund-moving logic that checks neither authority nor the caller’s own entitlement.

Technical fundamentals

Authorization in the EVM is entirely application-level. The platform tells a contract only who called it (msg.sender) and, for a library-style delegatecall, preserves the original context; it does not enforce any notion of "owner" or "role". Every privilege boundary is therefore code the developer must write and must place on every path that reaches the capability. A capability with two entry points needs the check on both.

Two distinct questions must be separated. Authority asks "is this caller permitted to perform privileged actions at all?" (owner, admin, role holder). Entitlement asks "is this caller acting only within what belongs to them?" (spending their own balance, withdrawing their own deposit). A correct value-moving function satisfies at least one appropriate rule; the dangerous bug is logic that moves value while satisfying neither.

Authority
The right to perform privileged operations — ownership or a granted role.
Entitlement
The right to act on a specific resource because it is yours — e.g. your own recorded balance.
Initializer
In proxies, the function that sets initial state in place of a constructor; it must be made single-use or it can be re-run to seize control.
tx.origin vs msg.sender
tx.origin is the externally-owned account that started the transaction; msg.sender is the immediate caller. Authorizing on tx.origin lets an intermediary contract impersonate the victim.

Root cause

The defect is that a privileged capability is reachable on some path without a correct, binding authority or entitlement check. The specific failure may be omission (no modifier), incorrect binding (checking the wrong variable, or tx.origin), lifecycle error (an initializer that stays callable), or visibility error (a function public that should be internal). In all cases the invariant "only an authorized principal can trigger this effect" is violated for at least one reachable caller.

The SWC Registry catalogs several concrete instances of this family (for example function default visibility and unprotected privileged operations); because the manifestations are varied, it is best to reason from the invariant and reference the registry generally alongside the specific entries that match. OWASP Smart Contract Top 10 ranks access control as SC01.

How the attack works, step by step

  1. 1The attacker enumerates the contract’s externally reachable functions and the state each one changes (owner, balances, implementation address, pause flag).
  2. 2They identify a privileged effect whose path lacks a correct check — e.g. setOwner() with no modifier, or an initialize() that was never locked.
  3. 3For the uninitialized-proxy case: the attacker calls initialize() themselves, passing their own address as owner, and now holds every owner-gated power.
  4. 4For the missing-modifier case: the attacker calls the privileged function directly (sweep funds, mint, upgrade) with no authority at all.
  5. 5They exercise the stolen authority to move funds, change the implementation, or otherwise capture value — satisfying neither a legitimate authority nor an entitlement rule.

Vulnerable example

A synthetic contract with two independent access-control flaws: a re-callable initializer that lets anyone become owner, and an admin withdrawal with no authority check at all.

synthetic Solidity — do not deploy; illustrates the flaw
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: initializer is re-callable, and withdrawAll() is unguarded. contract Treasury { address public owner; bool public initialized; // never actually enforced below // BUG 1: no guard that this can only run once — anyone can re-initialize // and set themselves as owner (classic uninitialized/re-init takeover). function initialize(address _owner) external { owner = _owner; // attacker passes their own address } function deposit() external payable {} // BUG 2: moves all funds with NO authority and NO entitlement check. function withdrawAll(address payable to) external { to.transfer(address(this).balance); // anyone can drain the treasury } // BUG 3: authorizes on tx.origin, so a malicious intermediary contract // can act on behalf of a phished owner. function setOwner(address newOwner) external { require(tx.origin == owner, "not owner"); owner = newOwner; } }

Corrected example

Lock the initializer to a single use, bind every privileged function to msg.sender against an explicit authority (or entitlement) rule, and never authorize on tx.origin. Here a one-shot initializer, an onlyOwner modifier on both admin paths, and entitlement-based withdrawals for ordinary users.

synthetic Solidity — explicit authority and a one-shot initializer
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // SAFER: single-use initializer, msg.sender-bound authority, // and entitlement checks for user funds. contract Treasury { address public owner; bool private _initialized; mapping(address => uint256) public balanceOf; modifier onlyOwner() { require(msg.sender == owner, "not owner"); // bind to msg.sender _; } // One-shot: a second call reverts, so no re-init takeover. function initialize(address _owner) external { require(!_initialized, "already initialized"); require(_owner != address(0), "zero owner"); _initialized = true; owner = _owner; } function deposit() external payable { balanceOf[msg.sender] += msg.value; // records entitlement } // ENTITLEMENT: a user may only withdraw their own recorded balance. function withdraw(uint256 amount) external { require(balanceOf[msg.sender] >= amount, "insufficient"); balanceOf[msg.sender] -= amount; (bool ok, ) = payable(msg.sender).call{value: amount}(""); require(ok, "transfer failed"); } // AUTHORITY: only the owner may perform a privileged sweep, // and ownership transfer is two-step-friendly and msg.sender-bound. function transferOwnership(address newOwner) external onlyOwner { require(newOwner != address(0), "zero owner"); owner = newOwner; } }

Why it works: the initializer can run exactly once, so no later caller can seize ownership; every privileged path checks msg.sender against the real owner; user funds move only under an entitlement rule tied to the caller’s own balance; and tx.origin is never used, so an intermediary contract cannot impersonate a victim.

Note

Limits of the fix: correct per-function checks do not protect against a compromised or malicious owner key — access control defines who is trusted, not whether that trust is safe. Concentrated single-owner authority remains a single point of failure; consider a multisig/timelock and least-privilege roles. In proxies, also verify storage layout and that the implementation’s own initializer is locked (see the Proxy & Upgrade article). An audit that finds no missing check is UNKNOWN about keys and governance, not proof the system is safe.

Impact

  • ▸Total compromise: an attacker who gains ownership or an admin role can usually drain funds, mint arbitrarily, pause the system, or upgrade it to malicious code.
  • ▸Direct theft: an unguarded withdraw/sweep moves funds with no authority at all.
  • ▸Governance capture: taking over a role that controls parameters or upgrades is equivalent to owning the protocol.
  • ▸Impersonation: tx.origin authorization lets a phished user’s transaction be redirected to privileged effects.

Detection

Static analysis is effective at the structural level: enumerate functions that write privileged state or move value, and check each reachable path for a binding authority/entitlement guard on msg.sender; flag public-visibility functions that mutate sensitive state, re-callable initializers, and any use of tx.origin for authorization. These are strong leads. Confirmation is behavioral: on a local chain, have an unauthorized account actually call the function and verify the privileged effect occurs (ownership changes, funds move). An observed unauthorized state change is a Finding; a missing modifier that some other path still guards is only a signal.

How RootRaven analyzes it

Support level
Verified — access control is a core execute-and-verify family: the privilege boundary is tested by actually crossing it.
The truth it decides
Whether someone can acquire authority or ownership that was never granted to them — covering uninitialized/re-initializable takeover, a privileged state change performed without authority, and a fund movement that satisfies neither an authority nor an entitlement rule.
Evidence required
An executed transaction from an unauthorized account that produces a privileged effect (ownership seized, funds moved, implementation changed), confirmed by a deterministic state-diff verifier.

RootRaven turns a missing-check Observation into a Hypothesis ("an unprivileged account can trigger this effect"), then has the AI compose a candidate transaction from an attacker account. The AI only proposes; it does not rule on truth. The candidate runs on a disposable local blockchain or a local fork of real on-chain state — never against a live public network — and an AI-free verifier diffs state before and after against the family’s precise rule: did an account with no legitimate authority or entitlement end up owning, draining, or re-pointing something it should not? A confirmed unauthorized effect is promoted to a Finding; anything else stays a signal.

Verified

The verifier decides on an actual unauthorized state change, not on the absence of a modifier in source. A function that looks unguarded but is unreachable or guarded elsewhere yields no Finding, and a clean run is reported as "no unauthorized authority was proven within the tested scope", never as "access control is secure". Key compromise and governance risk are outside this family’s truth and are not implied by a clean result.

Note

Entitlement rules can be subtle (e.g. delegated allowances). RootRaven encodes the specific entitlement rule being tested; a result is only as complete as the rule set exercised.

Mitigation

Make authority explicit and least-privilege, bind every check to msg.sender, and lock lifecycle functions. The structural control is placing the right check on every reachable path.

  • ▸Preventive: apply a vetted access-control pattern (OpenZeppelin Ownable/AccessControl) and put the guard on every privileged function and every entry point that reaches it.
  • ▸Preventive: lock initializers to a single use (initializer/_disableInitializers in upgradeable contracts) and disable initialization on implementation contracts behind proxies.
  • ▸Preventive: never authorize on tx.origin; use msg.sender. Set non-API functions to internal/private; review default visibility.
  • ▸Preventive: separate authority from entitlement — user fund movements check the caller’s own balance/allowance; admin actions check a role.
  • ▸Corrective: use a multisig/timelock for the most powerful roles, grant least privilege, and add monitoring that alerts on ownership/role changes.

Verifying the mitigation

Verify by attempting the bypass, not by reading modifiers. On a local chain, call every privileged function from an unauthorized account and confirm each reverts; call initialize() a second time and confirm it reverts; attempt a tx.origin-style impersonation through an intermediary contract and confirm it fails; attempt to withdraw more than the caller’s entitlement and confirm it reverts. Report "unauthorized access attempts failed within the tested scope", and separately note that key/governance trust was not evaluated.

Note

Negative tests must enumerate every privileged path and every role. A single untested entry point can still be the open door; completeness of the path set is what makes the result meaningful.

Common mistakes

  • ▸Guarding one entry point to a capability but leaving a second, equivalent path unguarded.
  • ▸Forgetting to lock the initializer on a proxy implementation, leaving a re-initialization takeover open.
  • ▸Authorizing on tx.origin, which a phished user’s contract interaction silently defeats.
  • ▸Leaving a state-mutating function at default public visibility when it was meant to be internal.
  • ▸Confusing authority with entitlement — letting an admin check stand in for a per-user ownership check, or vice versa.
  • ▸Declaring the system safe after a clean check, when key compromise and governance capture were never in scope.

References

  1. [1]OWASP Smart Contract Top 10 — SC01 Access Control Vulnerabilities
  2. [2]SWC Registry (overview)
  3. [3]OpenZeppelin Contracts — Access Control (Ownable, AccessControl)
  4. [4]MITRE CWE-284 (Improper Access Control)
  5. [5]MITRE CWE-862 (Missing Authorization)

References point to public, well-known technical sources for further study.

Related articles