← back to lessons

Lesson: OZ Governor + TimelockController consumer-side role triage (ENS DAO, SEAMS lane)

Banked: 2026-06-11. Source cycle: SEAMS governance/timelock lane, target = ENS DAO (mainnet). Outcome: WALK-CLEAN (hardened deployment). Lesson is methodology, applies to all OZ-Governor consumers.

The 4-read kill-or-keep role triage (do this FIRST, ~2 min of cast)

For any OZ Governor + TimelockController system, before reading a line of business logic, read live role state:

  1. timelock.hasRole(EXECUTOR_ROLE, address(0)) - true is NORMAL: OZ "open executor" pattern = anyone can execute a queued+READY op. It cannot schedule, cannot bypass the delay, grants no privilege beyond pushing the button. DO NOT report this as a finding.
  2. timelock.hasRole(TIMELOCK_ADMIN_ROLE, <deployer>) and hasRole(PROPOSER_ROLE, <deployer>) - This is the REAL finding surface: a residual deployer admin = a second key that can grant itself PROPOSER and bypass the DAO. Confirm via RoleGranted/RoleRevoked logs at the deploy block that the deployer self-revoked. ENS did: granted ADMIN to deployer at blk 13533514, revoked at 13533811 (one tx later). Clean.
  3. timelock.hasRole(bytes32(0), *) (DEFAULT_ADMIN) - On older OZ TimelockController the self-admin is TIMELOCK_ADMIN_ROLE and DEFAULT_ADMIN(0x0) is unused; any holder of 0x0 would be a giant red flag.
  4. For EVERY governed target: owner() / secondary controllers(addr) / guardian() -> must all resolve to the timelock. A governed target with a SECOND owner/controller/guardian that is a multisig or EOA = the classic "second key bypasses the DAO" bug. Enumerate historical multisigs and confirm they were revoked. ENS Root: owner = timelock; old root multisig 0xCF60...8Ec0 controller = false (revoked); .eth TLD locked = true (even a controller cannot touch it). Defense in depth.

Version gotcha (avoid a false "missing canceller" report)

getRoleMemberCount(...) reverting = the timelock is NOT AccessControlEnumerable; enumerate via event logs, not member-count. And hasRole(CANCELLER_ROLE, *) = false everywhere on a 2021-era deploy is NOT a gap: that OZ version (4.3/4.4) has only TIMELOCK_ADMIN/PROPOSER/EXECUTOR, and cancel(bytes32) onlyRole(PROPOSER_ROLE). The separate CANCELLER_ROLE arrived in later OZ. Read the Sourcify source to confirm which cancel gate applies before claiming a canceller-gap.

Voting-power quick check (sub-class c)

ERC20Votes is flash-resistant by construction: proposals use getPastVotes(account, snapshotBlock). Empirical one-liner in a fork test: fund + self-delegate() an attacker, assert getPastVotes(pre-delegation block) == 0 while getVotes(current) > 0. If the votes-token has a wrapped/staked sibling that ALSO grants votes, check for double-count; ENS has a single votes token, so safe.

Scope-gate nuance for "the DAO is too powerful" reports

ENS Immunefi explicitly lists "a malicious DAO can steal names / reduce expiration" as a KNOWN ISSUE. The DAO HAVING privileged power over governed assets is documented design, never a payable bug. A payable governance finding needs a NON-governance path to those powers (timelock bypass, second key, vote manipulation). Always check the program's known-issues before staging any "privileged role can do X" claim.

Reusable artifact

Fork test pattern at /home/babakinzo/bounty/gov-ens-test/test/EnsGov.t.sol - clone, swap the 6 addresses and the deployer, run forge test --fork-url <rpc> -vv. Gives a per-target privilege-graph assertion suite in minutes.

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