← back to lessons

Governance / Timelock / Access-Control Audit Primitives

This is the privileged-operation attack surface present in MOST DeFi/protocol targets. Use it as the governance lens when a target has a Governor, Timelock, role-based access, or upgradeable proxy.

The 7 primitives (mechanism -> OZ guard -> transfer rule)

  1. Proposal lifecycle integrity. proposalId = keccak256(targets,values,calldatas,descriptionHash); execute re-derives id from supplied payload so voted-calldata == executed-calldata; executed flag set BEFORE external calls blocks replay+reentrancy; state-bitmap gate on every transition. RULE: confirm execution-id binds the FULL payload, executed/done flag pre-set, state gate enforced on queue/execute/cancel. Bug = calldata stored separately from id, or execute trusts a mutable stored blob.

  2. Voting power / snapshot timing. Weight = getPastVotes(account, snapshot) where snapshot fixed at propose time, strictly before vote window. RULE: weight from a PAST snapshot, never current balanceOf. Canonical attack: flash-mint/JIT-transfer gov token then vote. Also verify Governor.clock() == token.clock() unit.

  3. Quorum + counting. quorum = getPastTotalSupply(t)*numerator(t)/denom; numerator checkpointed; hasVoted prevents double-vote; for+abstain vs for>against. RULE: quorum supply read at SAME past snapshot as votes; numerator timepoint-read; double-vote guard present.

  4. Timelock bypass / delay. id=hash(target,value,data,predecessor,salt); schedule requires delay>=minDelay & Unset; execute requires Ready (eta passed) + predecessor Done; updateDelay callable ONLY by timelock itself (so delay-change is itself delayed); timelock self-administers its own DEFAULT_ADMIN_ROLE; optional constructor admin is bootstrap-only and MUST be renounced. RULE: verify delay-setter reachable only through the delay; bootstrap/deployer admin renounced; a retained instant-admin = full bypass.

4b. Governor<->Timelock seam. Governor holds PROPOSER (ideally EXECUTOR/CANCELLER); salt = bytes20(governor) ^ descriptionHash (per-governor namespacing); updateTimelock onlyGovernance. RULE: enumerate ALL holders of PROPOSER/EXECUTOR/CANCELLER besides the Governor. Extra proposer = schedule arbitrary ops bypassing the vote. Extra canceller = DoS approved proposals.

  1. AccessControl / AccessManager. grant/revoke gated by getRoleAdmin(role); DEFAULT_ADMIN_ROLE=0x00 is its own admin (single point). AccessManager: per-target-function->role map, ADMIN_ROLE=0, PUBLIC_ROLE=everyone, per-role guardian/grantDelay, per-target adminDelay, per-member executionDelay; default every fn restricted to ADMIN_ROLE. RULE (cheap high-coverage): enumerate EVERY privileged fn -> modifier -> resolved role -> role-admin; the outlier (missing modifier / wrong role / overly broad admin / EOA holding 0x00) is the bug.

  2. Upgrade / ownership. UUPS _authorizeUpgrade is internal-virtual UNIMPLEMENTED in base - integrator MUST override with auth; proxiableUUID check prevents bricking; onlyProxy context check. Ownable2Step: transfer sets pendingOwner, new owner must acceptOwnership. RULE: confirm _authorizeUpgrade overridden WITH real auth (empty/missing = anyone upgrades = hijack/brick, the canonical UUPS bug); initializers non-re-callable post-upgrade (no re-init takeover); two-step ownership; impl not left uninitialized.

  3. Execution call surface. Governor & Timelock do target.call{value}(data) over arbitrary target+calldata (trust IS the vote). Reentrancy defused by executed/done flag set before call; _governanceCall deque ensures onlyGovernance setters run ONLY during execute. RULE: the seam is anything letting executed calldata diverge from voted, or a reentrant call before the flag commits. Every weakness in 1-4 becomes an arbitrary-call exploit.

Highest-yield bug class

Privileged operation reachable WITHOUT the vote/delay: a privileged fn (upgrade, role-grant, delay-change, mint, treasury withdraw, setter) that is missing its modifier, carries the wrong role, or is reachable via a bypass path (extra timelock proposer, retained instant admin, empty _authorizeUpgrade, re-callable initializer). OZ canon is clean; the bug lives at the INTEGRATION SEAM - that is where to hunt.

First-pass cheap check (run this first on any gov/access target)

Build the 4-column table for every state-changing fn: function -> modifier -> resolved role/address -> admin-of-that-role. The mismatched/missing cell is the finding. One pass covers primitives 4, 4b, 5, 6, 7.

Generated 2026-07-02 13:15:03 UTC | auto-sync /15min