Immunefi Audit Competitions
ENS | Audit Competition

#92913 [W&A-Medium] A keep-resolver sale strands the sold name under the seller's resolution control -- and if the seller burns the resolver role first, no one can ever undo it

Submitted on Sep 13th 2026 at 07:43:58 UTC by @I_A_J for Audit Competition | ENS

  • Report ID: #92913
  • Report Type: Websites & Apps
  • Report severity: Medium
  • Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
  • Impacts:
    • Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer), such as: - a registration or migration that assigns the name, its ownership, registry, or resolver to an attacker - redirecting the recipient of a name transfer - leaving a name permanently inaccessible after the flow
    • Taking state-modifying authenticated actions (with or without blockchain state interaction) on behalf of other users without any interaction by that user, such as: Changing registration information Notification/settings changes
    • Causing the application to display or pre-fill an incorrect address, name, or transaction detail, so that a user sends funds or submits a transaction to an unintended recipient, such as: - resolving or rendering an ENS name to an attacker-controlled address - showing the wrong recipient or amount on a transaction the user then approves

Description

The portal's keep-resolver transfer path neither revokes the seller's resolver-plane roles nor detects a pre-sale burn of the resolver role -- two missing guards in one defective flow, with two executed consequence tiers. Tier 1: the seller keeps ROLE_SET_ADDR on the sold name and re-points it at an address they control, while the buyer -- who receives a zero resolver-role bitmap -- cannot edit the records and, holding no *_ADMIN, cannot revoke the seller either. Tier 2: if the seller additionally burns ROLE_SET_RESOLVER and its admin before the sale, the buyer receives a name permanently pinned to the seller's resolver with no recovery by anyone -- not the buyer, not any future owner, not the registrar, not governance -- while the seller keeps live, updatable control of what the name resolves to. The app ships the exact revocation primitive and already reads the exact role that would reveal the pin; the sale path uses neither

One flow, two missing guards - TransferStepKind is exactly 'set-eth-addr' | 'detach-resolver' | 'detach-registry' | 'transfer-token' (features/transfer/utils/buildTransferPlan.ts:16) -- no revoke/role step exists. The keep-resolver plan is ['set-eth-addr','transfer-token']. Dependency cone: every file under features/transfer/ was scanned; not one imports features/resolver/. Revocation is unreachable from the transfer flow. - This is an unused feature, not a missing one: prepareRevokeResolverRolesTransaction and revokeResolverRoles are live in features/resolver/helpers/revokeResolverRoles.ts, wired into the UI at ResolverRolesSidebar.tsx:227,266. The app ships the exact remediation primitive and the sale path never invokes it (captured-only: harness 1's check L2 records the app's own prepareRevokeResolverRolesTransaction succeeding on the fork). - The flow already reads the exact role that would reveal tier 2: features/transfer/hooks/useTransferDetachTargets.ts:81,90 computes heldRoles.includes('ROLE_SET_RESOLVER') -- but only to decide whether to render the detach toggle. A zero-assignee (burned) state produces no warning and no refusal: the toggle simply doesn't render, and a warning attached to a toggle that never renders never fires.

Tier 1 -- the seller keeps writing the buyer's name The two-plane fact: resolver roles live in the resolver's own EnhancedAccessControl, keyed (resource, account); registry roles are keyed (registry, label) on a different contract. apps/portal/src/lib/roles/resolverRoles.ts:25-44 defines ROLE_SET_ADDR: 1n << 0n alongside ROLE_SET_ADDR_ADMIN: (1n << 0n) << 128n. An ERC-1155 safeTransferFrom on the registry cannot, even in principle, clear a role held on the resolver. The buyer cannot fix it -- only walk away from it. Revoking ROLE_SET_ADDR requires ROLE_SET_ADDR_ADMIN; the buyer receives no resolver roles at all. The revoke UI is gated on canManageRoles (ResolverRolesSidebar.tsx:108, canEdit = canManageRoles && !isSelf), which the buyer fails. Their only remedy is Change Resolver -- abandoning the resolver, not reclaiming it-- and the seller still holds the role on the old resolver afterwards (harness A, test D; captured harness 2, B9). The app documents the resulting state: features/records/hooks/useCanEditRecords.ts:38-41 -- "After a transfer that keeps the resolver, the previous owner typically retains these roles while the new token owner does not." canEdit then returns hasResolverRole -- so the buyer reads canEdit === false on a name they own while the seller reads true. The disclosure defence fails on its own text. The keep-resolver warning (SendNameForm.tsx:51) reads verbatim: "This name's other records will keep resolving after the transfer. Until the recipient updates them, it could still be listed as the primary name for an address that no longer controls it." That warns about stale data persisting. It never says the seller retains write authority. Different hazards; only the weaker one is disclosed. And for one class of name there is no choice and no warning at all: useTransferDetachTargets makes detachResolver visible only when heldRoles.includes('ROLE_SET_RESOLVER') -- a migrated/locked name can own the token yet lack that role (its own comment says so). SendNameForm:98 filters visibleOptions on visibility and :180 renders null when none are visible -- and the warnings render from that same list. So for those names: keep-resolver is forced, no toggle appears, and nothing warns either party. Tier 2 -- a pre-sale burn makes the capture permanent, with no recovery by anyone PermissionedRegistry._update -> _transferRoles(getResource(tokenId), from, to) (:494) moves to the buyer only the roles the seller currently holds; setResolver is gated on ROLE_SET_RESOLVER (:150-152). So whatever the seller does not hold at transfer time, the buyer never receives. The seller calls revokeRoles(tokenId, ROLE_SET_RESOLVER | ROLE_SET_RESOLVER_ADMIN, self) before selling, driving both assignee counts to zero. ROLE_SET_RESOLVER_ADMIN is self-administering (EACBaseRolesLib.withAdminRolesLib -- only a holder can create another), so once its assignee count is zero it can never be re-created, and ROLE_SET_RESOLVER can never be re-granted. No recovery path exists -- enumerated, not assumed (harness B, test_ENUMERATION_noRegrantPathExists): recovery would require a holder of ROLE_SET_RESOLVER (set the resolver directly) or of ROLE_SET_RESOLVER_ADMIN (grant it), at the token resource or at ROOT (root roles apply to every name). After the burn+sale, all four counts are zero -- with the ROOT read validated (getResource(0)==0 and roleCount(0)!=0 prove ROOT is read with live data). The .eth registry has no resolver-setting governance override even at baseline -- names are self-sovereign -- and the registrar cannot re-grant either (test_CRITICAL asserts its grantRoles reverts and it holds no root admin). The EAC contracts place no last-admin guard on this path: the only revoke-side revert is count underflow on an already-zero role, and the final-admin burn is executed on real deployed state in harness B (test_CRITICAL, 6/6). The pin is a property of the registration, and the control is live: - Inherited: the buyer cannot even sell out of it -- transferring onward pins the next owner too (test_POISON: Bob -> Carol, Carol's setResolver reverts, resolver stays R). - Durable funds-redirect: the seller retains ROLE_SET_ADDR on their resolver R, re-points the name post-sale (the UniversalResolver follows, since the name is pinned to R), the buyer can neither write the record nor revoke the seller's role, and a +365 day jump changes none of it (test_DURABLE). The seller keeps rotating the address to catch payments.

A buyer pays for a name whose resolution is controlled by the seller. Tier 1: the seller re-points the sold name at an address they control; anyone resolving the name -- including third parties paying it -- is directed to the seller (executed: a 5 ETH payment lands at the seller's address while the buyer receives nothing); the buyer cannot correct the records (canEdit === false) and cannot revoke the authority (no *_ADMIN); their only escape is to abandon the resolver entirely. Tier 2: the capture is permanent -- the buyer can never change the resolver, never edit the records, and never recover, nor can any future owner, the registrar, or governance, for the entire registration term, while the seller keeps updating the address the name resolves to. Permanent hijack (6286) with a live funds-redirect (6285).

https://gist.github.com/ijomha20/60d2d21e7f5805926b2c2395c6aebf4b

Proof of Concept

Proof of Concept -- three executed harnesses, all pinned, all against real live Sepolia state Every claim below is a run assertion, not code inspection. Safety: forks read chain state read-only; every write lands only on the throwaway local fork. Nothing touches live Sepolia. Harness A -- keep-fork/ (4/4 pass) -- tier 1, the primary on-chain evidence. One forge test; no anvil, no node, no app build, and nothing is deployed -- seller, resolver, name and registry are all real live Sepolia state at pinned block 11,616,799. forge-std is vendored, so it compiles from an unzipped archive with no install step. - A -- after a keep-resolver sale the seller rewrites the sold name's record; the buyer, who owns the name, cannot. The refusal is asserted as the full EACUnauthorizedAccountRoles(resource(sss.eth), ROLE_SET_ADDR, buyer), with the resource derived in-test. - B -- the harm, in wei. The full client path (getResolver -> addr(node, 60)) resolves to the seller-controlled address, and a 5 ETH payment from a third party lands there while the buyer receives nothing. This is row 6285 executed end to end. - C -- the control. The same buildTransferPlan flow's detach-resolver branch: the seller keeps the identical role and it reaches nothing. The two branches differ only in whether the resolver is detached, and only one is exploitable -- which is what makes this the app flow's defect rather than the role model's. - D -- the bound. The buyer recovers by repointing the resolver, and the payment made inside the window is not returned. Determinism is demonstrated, not asserted: three consecutive runs byte-identical in status and gas, gas identical at a second pin, the fixture guard aborts (FIXTURE DRIFTED) if pinned before the resolver existed, and two mutation controls prove the assertions can fail. Full record: keep-fork/evidence.txt. Harness B -- fork/test/ResolverPin.t.sol (6/6 PASS, pinned 11,616,799) -- tier 2, permanence. The claimed pin is a constant of the suite -- setUp hard-fails (FORK PIN MISMATCH) if the RPC serves any other block, and FORK_BLOCK is ignored; the guard is demonstrated by fork/control/wrong-pin-proxy.mjs, a deliberately mis-serving JSON-RPC proxy against which the suite fails in setUp. setUp likewise hard-fails (FIXTURE DRIFTED) if any of the four observed-state invariants has moved. - test_CONTROL_normalSale_bobCanSwapResolver -- baseline: a normal keep-resolver sale is recoverable (Bob receives ROLE_SET_RESOLVER, swaps the resolver). This isolates the defect. - test_CRITICAL_resolverPinned_noOwnerRecovery -- the burn+sale: zero assignees; Bob can't setResolver, can't self-grant; the registrar can't re-grant; name pinned to R. - test_ENUMERATION_noRegrantPathExists -- the load-bearing permanence assertion: all four re-grant/direct-set counts (token/ROOT x role/admin) are zero, with the ROOT read validated. - test_FIXINDEPENDENCE_revokeRemediationLeavesItPinned -- the revoke-seller-roles remediation and the Change-Resolver recovery both leave the name pinned. - test_POISON_pinTravelsToNextOwner -- the pin is inherited by the next owner (Carol). - test_DURABLE_attackerControlsResolution_buyerCannotRemove -- the seller keeps live, updatable control a year on; the buyer can neither write nor revoke. Harness C -- app-e2e/ (1/1 PASS, same pin, same fixture) -- tier 2 through the in-scope portal app itself. Executed via the repo's own anvil-based e2e stack (Playwright + the repo's portal fixtures and mock-indexer helper; nothing mocked except Panoptes discovery queries -- every value the pin depends on is read on-chain from the fork). One command: ENS_REPO= ./run.sh (prereqs in app-e2e/README.md); transcripts and screenshots in app-e2e/evidence/. - The burn, with cast (setup-fork.sh, transcript setup-transcript.txt): the seller revokes ROLE_SET_RESOLVER | ROLE_SET_RESOLVER_ADMIN on sss.eth; both assignee counts go to 0 (hard-fail otherwise). Disclosed staging then moves the pinned name into the browser wallet and mirrors the seller's resolver-plane ROLE_SET_ADDR on R onto it, so the portal session executes the seller's sale exactly as the real attacker's wallet would; roles are per-account, so staging leaves the pin untouched (counts still 0, asserted). - The sale, through the portal's transfer flow (pin01-resolver-pin.spec.ts): the seller opens /sss.eth/ownership/transfer, fills the buyer, and the form shows no warning that the buyer will be permanently unable to change the resolver -- the only alert is the generic "transferring ownership is irreversible" notice (screenshot 01; three pin-specific warning patterns asserted absent). The flow's TransactionModal is driven to success at default options (screenshots 02-03); the ownership page then shows the buyer as owner, and on-chain assertions confirm: buyer owns the token, resolver still R, buyer holds neither the role nor its admin, both assignee counts still 0. - The buyer, in the portal: opening /sss.eth/change-resolver renders "Permission Denied - you don't have the required ROLE_SET_RESOLVER permission" (screenshot 04) -- the app itself confirms the pin -- and the buyer's own setResolver transaction, signed with the buyer's real key (post-checks.sh, postchecks-transcript.txt), reverts on-chain with EACUnauthorizedAccountRoles(tokenId, ROLE_SET_RESOLVER, buyer); getResolver("sss") is still R. This is the same defect at the layer the rubric row describes: the app's transfer flow completes a sale that leaves the name permanently inaccessible to its buyer, and the app can see the zero-assignee state (it already reads these roles for the detach toggle) yet says nothing. Captured-only supporting evidence (stated as such -- sources not shipped in this bundle): harnesses 1 (apps/portal/poc/keep01-live.poc.test.tsx, 3/3, five labelled checks L1-L5) and 2 (keep01-fork.poc.ts, 17/17) were captured from a full ens-apps checkout at the audit pin with the app's real installed dependency tree, against an anvil fork (FORK_BLOCK=11614231). The transcripts -- excerpted under "Key excerpts" -- are the evidence; the sources are not shipped and nothing here claims they are runnable from this archive. What the captured transcripts record: L1 records the real buildTransferPlan emitting no revocation step over all 8 option combinations; L2 records the app's own prepareRevokeResolverRolesTransaction succeeding on the fork (the remediation ENS ships is never emitted by the sale path); L5 records the whole keep-resolver sale driven through the app's own step helpers -- buyer owns the token, buyer's resolver-role bitmap 0x0, seller's 0x1111...1111; B8 records the buyer's revokeRootRoles reverting (the buyer cannot revoke the seller); B9 records Change Resolver moving the name while the seller keeps the role on the old resolver. Verified by inspection, not execution (stated as such): features/transfer/** contains no import of features/resolver/** -- a structural property of the module graph, which no test can "run". It explains why L1's result holds. Steps (deterministic -- every harness is pinned) cd keep-fork && SEPOLIA_RPC_URL= ./run.sh # harness A, pinned at 11616799 cd fork && SEPOLIA_RPC_URL= ./run.sh # harness B, pin is a suite constant

harness C (through the portal UI): prereqs in app-e2e/README.md, then

cd app-e2e && ENS_REPO= ./run.sh Harness A: FORK_BLOCK overrides the pin; the suite hard-fails on any block whose fixture state is absent. Harness B: the pin is a constant of the suite; FORK_BLOCK is ignored and the wrong-pin-proxy control proves it. The block pins, stated once - Harnesses A/B/C pin 11,616,799 -- the block where the real on-chain fixture (the actual sss.eth, resolver R 0x8f2f5c43..., the seller's live retained role) exists; the fixture guard aborts earlier pins (R first has code at 11,615,090). - The captured-only harnesses 1/2 build their own fixture on the fork and were captured pinned at 11,614,231; that this pin predates resolver R's deployment confirms they do not touch the harness-A fixture. Key excerpts Harness A (keep-fork/, 4/4 -- full record in keep-fork/evidence.txt): [PASS] test_A_afterKeepResolverSale_sellerRetainsWriteAuthority() (gas: 132151) [PASS] test_B_soldNameResolvesToAttacker_andThePaymentFollows() (gas: 146528) [PASS] test_C_detachResolverBranch_makesTheLeftoverInert() (gas: 117855) [PASS] test_D_buyerRecovers_butThePaymentIsNotReturned() (gas: 157507) Suite result: ok. 4 passed; 0 failed -- byte-identical across 3 consecutive runs Harness B (fork/, 6/6 -- full record in fork/evidence.txt): [PASS] test_CONTROL_normalSale_bobCanSwapResolver() (gas: 82306) [PASS] test_CRITICAL_resolverPinned_noOwnerRecovery() (gas: 182839) [PASS] test_DURABLE_attackerControlsResolution_buyerCannotRemove() (gas: 329111) [PASS] test_ENUMERATION_noRegrantPathExists() (gas: 144406) [PASS] test_FIXINDEPENDENCE_revokeRemediationLeavesItPinned() (gas: 167920) [PASS] test_POISON_pinTravelsToNextOwner() (gas: 171393) Suite result: ok. 6 passed; 0 failed -- gas byte-identical x3 Harness C (app-e2e/, 1/1 -- transcripts + 4 screenshots in app-e2e/evidence/): 1 passed -- [PIN-01] ALL LEGS PASS POST-CHECK PASS: buyer's setResolver reverts (EACUnauthorizedAccountRoles), getResolver("sss") = R screenshots: 01 warning-free transfer form - 02-03 TransactionModal success - 04 buyer "Permission Denied" Captured harness 2 (captured-only, excerpted): [B3] BUYER resolver roles after the sale -- root=0x0 name-scoped=0x0 [B4] SELLER resolver roles after the sale = 0x1111...1111 PASS seller RETAINS ROLE_SET_ADDR [B6] seller setAddr(-> attacker): ok => name resolves to 0x...eE11 [B7] buyer setAddr(-> self): reverted [B8] buyer revokeRootRoles(seller, ROLE_SET_ADDR): reverted PASS the buyer CANNOT revoke the seller's role -- they hold no ROLE_SET_ADDR_ADMIN [B9] PASS ...but the seller STILL holds ROLE_SET_ADDR on the old resolver -- nothing was reclaimed