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.
For any OZ Governor + TimelockController system, before reading a line of business logic, read live role state:
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.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.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.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.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.
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.
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.
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.