91641 w a critical explorer resolver role editor writes grants and revokes to a wrongly derived eac resource so revoking a delegate s permissions silently leaves them in place
Submitted on Sep 3rd 2026 at 08:54:31 UTC by @risinghorse for Audit Competition | ENS
- Report ID: #91641
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- 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
- 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
Description
Brief/Intro
Under ENSv2 a resolver permission is granted against a specific EAC resource, and resource(0,0) (all names), resource(node,0) (one name) and resource(node, partHash(key)) (one record key of one name) are three distinct values. The Explorer's Roles page discards which resource each grant came from: groupRolesByAccount merges every role row for an account into one table row, and the sidebar then derives a single scope for that row with resolvedNames.find((n) => n !== '(root)') ?? '', using it for every grant and revoke it builds. The scope the transaction targets is therefore frequently not the scope the grant lives at — and in the worst case not the scope the UI displays one line above the controls. In production, a name owner who opens the sidebar, unticks every permission for a delegate and clicks Save gets a successful transaction, a refetch and a closed sheet, while the delegate keeps the permission — typically ROLE_SET_ADDR, the role that controls where the name points.
Severity: High Asset: apps/portal (ENS Explorer app) — in scope per the program's Assets in Scope Target: ENS Manager & Explorer — Audit Competition Audited revision: 63772fd872af472ced58b009499355f3430c2a86
Selected impact: 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 — the "showing the wrong … detail on a transaction the user then approves" limb. The terminal state also reaches the sub-bullet "resolving or rendering an ENS name to an attacker-controlled address"; I am claiming High rather than Critical because that outcome is conditional on a prior delegation (see Impact Details).
Testing & rules compliance. No testing was performed against mainnet or any public testnet. The defect is in pure, exported client-side functions; the proof of concept runs entirely offline. No public-testnet or mainnet contract was registered, called, or written to.
Vulnerability Details
1. The indexer supplies per-role name attribution; the app's own type drops it
apps/portal/src/features/resolver/hooks/useResolverOverview.ts:14-22
export type ResolverRole = {
readonly account: string
readonly resource: string
readonly roleBitmap: string
readonly blockNumber: number
readonly transactionHash: string | null
readonly timestamp: number | null
readonly name: string | null // <-- per-role name, supplied by the indexer
}
and it is requested in the GraphQL selection set (useResolverOverview.ts:90-98):
roles {
account
resource
roleBitmap
blockNumber
transactionHash
timestamp
name
}
But the grouping helper's input type omits it (apps/portal/src/lib/roles/resolverRoles.ts:162-166):
type RoleInput = {
readonly account: string
readonly resource: string
readonly roleBitmap: string
}
The attribution needed to do this correctly is already on the wire and is discarded at the type boundary.
2. Grouping merges every resource into one row
apps/portal/src/lib/roles/resolverRoles.ts:193-215
for (const role of roles) {
const account = role.account.toLowerCase()
const resource = role.resource.toLowerCase()
const decoded = decodeResolverRoleBitmap(role.roleBitmap)
const resolvedName = resourceToName?.get(resource) ?? null
const existing = grouped.get(account)
if (existing) {
existing.roles.push(role)
existing.decodedRoles.push(...decoded) // <-- flattened; resource discarded
if (resolvedName) existing.resolvedNames.add(resolvedName)
} else {
// ...
}
}
decodedRoles becomes a flat list with no record of which resource each role came from, and resolvedNames is an unordered Set.
3. The name→resource map can only ever resolve part = 0 resources
apps/portal/src/lib/roles/resolverRoles.ts:138-141
export const computeNameResource = (name: string): string =>
keccak256(
encodePacked(['bytes32', 'bytes32'], [namehash(name), ZERO_BYTES32]),
)
apps/portal/src/lib/roles/resolverRoles.ts:147-160
export const buildResourceToNameMap = (
nodes: readonly { readonly name: string }[],
): Map<string, string> => {
const map = new Map<string, string>()
map.set(ROOT_RESOURCE, '(root)')
for (const node of nodes) {
if (node.name) {
map.set(computeNameResource(node.name).toLowerCase(), node.name)
}
}
return map
}
Only ROOT_RESOURCE and resource(node, 0) entries can ever exist, so a part-scoped resource is never resolvable and contributes nothing to resolvedNames.
4. One scope is derived for the whole merged row and drives both mutations
apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:166-169
const decodedRoles = (row?.original.decodedRoles ?? []) as ResolverRoleKey[]
const resolvedNames = row?.original.resolvedNames ?? []
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''
That single string is what both handlers submit — handleSaveChanges (:294-302) and handleRemoveUser (:306-321):
setPendingAction({
type: 'save',
name: roleName, // ResolverRolesSidebar.tsx:299
account: selectedAccount,
rolesToGrant: rolesToGrant,
rolesToRevoke: rolesToRevoke,
})
5. The scope selector maps to a different on-chain resource
apps/portal/src/features/resolver/helpers/revokeResolverRoles.ts:45-61
const writeParams = revokeResolverRolesWriteParameters(
walletClient as Parameters<typeof revokeResolverRolesWriteParameters>[0],
name === ''
? { resolverAddress, targetAccount: account, scope: 'root', roles: roles as ResolverRole[] }
: { resolverAddress, targetAccount: account, scope: 'name', name, roles: roles as ResolverRole[] },
)
The convention is documented on the grant side (grantResolverRoles.ts:20):
/** Dotted name (e.g. "myname.eth") or empty string for ROOT_RESOURCE (all names). */
readonly name: string
So roleName === '' targets ROOT_RESOURCE and any non-empty roleName targets resource(namehash(roleName), 0). Whichever concrete function @ensdomains/ensjs emits for each scope, the two address different EAC resources by construction, and neither addresses resource(node, partHash(key)).
On-chain the three resources are distinct (contracts-v2/contracts/src/resolver/libraries/PermissionedResolverLib.sol:71-80):
function resource(bytes32 node, bytes32 part) internal pure returns (uint256 ret) {
if (node != bytes32(0) || part != bytes32(0)) {
assembly {
mstore(0, node)
mstore(32, part)
ret := keccak256(0, 64)
}
}
}
(resource(0,0) short-circuits to 0, i.e. ROOT_RESOURCE.)
and EnhancedAccessControl clears bits only in the resource it is given (contracts-v2/contracts/src/access-control/EnhancedAccessControl.sol:146-156):
function revokeRoles(uint256 resource, uint256 roleBitmap, address account)
public virtual canRevokeRoles(resource, roleBitmap) returns (bool)
{
if (resource == ROOT_RESOURCE) {
revert EACRootResourceNotAllowed();
}
return _revokeRoles(resource, roleBitmap, account, true);
}
(Note: because revokeRoles reverts on ROOT_RESOURCE, ensjs must emit revokeRootRoles for scope: 'root'. I state this as an inference — node_modules is not installed at this revision, so I cannot quote ensjs. It does not affect the finding: either way the root path and the name path address different resources.)
Part-scoped grants are created by the authorize*Roles family, e.g. contracts-v2/contracts/src/resolver/PermissionedResolver.sol:369-388:
function authorizeAddrRoles(bytes calldata toName, uint256 coinType, address account, bool grant)
external returns (bool updated)
{
bytes32 node = NameCoder.namehash(toName, 0);
uint256 roleBit = PermissionedResolverLib.ROLE_SET_ADDR;
uint256 nodeResource = PermissionedResolverLib.resource(node, bytes32(0));
uint256 partResource =
PermissionedResolverLib.resource(node, PermissionedResolverLib.partHash(coinType));
if (grant) {
_checkCanGrantRoles(nodeResource, roleBit, msg.sender);
// ...
return _grantRoles(partResource, roleBit, account, true); // <-- granted at partResource
and they are honoured at write time by onlyPartRoles (PermissionedResolver.sol:178-187), which accepts resource(node, part) or resource(0, part). A part-scoped grant is therefore fully effective on-chain and entirely invisible to this UI.
The three divergences
A. Root + name mix — the UI contradicts its own calldata. An account holds ROLE_SET_ADDR at ROOT_RESOURCE (all names) and ROLE_SET_TEXT on foo.eth. Both decode into one row; resolvedNames = ['(root)', 'foo.eth']. roleName is 'foo.eth', so the revoke is name-scoped and the root grant survives — the grant authorising setAddr on every name on that resolver. The caption for that same row reads (ResolverRolesSidebar.tsx:389-396):
{selectedAccount && resolvedNames.length > 0 && (
<p className="text-sm text-muted-foreground">
{resolvedNames.includes('(root)')
? 'Global roles (all names)'
: `Roles scoped to ${resolvedNames.filter((n) => n !== '(root)').join(', ')}`}
</p>
)}
The UI states the grant is global while building a name-scoped revoke.
B. Two names — one is silently skipped. Roles on foo.eth and bar.eth merge into one row; roleName is whichever entered the Set first. The revoke lands on that one and the other is untouched, with no indication a second scope existed.
C. Part-scoped grants — the revoke goes to an unrelated resource. A grant made with authorizeAddrRoles / authorizeTextRoles / authorizeDataRoles lives at resource(node, partHash(key)), which buildResourceToNameMap can never resolve. resolvedNames is empty, roleName is '', and the revoke is issued against ROOT_RESOURCE — a resource the account may hold nothing at. The part-scoped grant survives untouched.
Recommended fix
- Stop discarding the resource. Add
name: string | null(and the rawresource) toRoleInput(resolverRoles.ts:162) and keygroupRolesByAccounton(account, resource)rather thanaccountalone — or keep one row per account but carry a per-resource breakdown ofdecodedRoles. - Make the sidebar scope-aware. Replace the single
roleName(ResolverRolesSidebar.tsx:169) with a per-grant scope and issue one grant/revoke per resource. - Never infer a scope by falling back to
''. An unresolvable resource currently degrades silently toROOT_RESOURCE; it should be rendered with its raw resource hash and made non-editable, or block the mutation with an explicit message. - Represent part-scoped grants so per-record delegations are visible and revocable; today they are neither.
Impact Details
What an exploit costs the victim
The direct loss is the continued existence of a permission the owner believes they removed, plus the false assurance that they removed it.
- The owner's only remedy silently fails. Revocation through the Explorer is the mechanism a name owner has for cutting off a compromised or untrusted delegate. It reports success — transaction confirmed, data refetched, sheet closed — while leaving the permission in place. The owner stops looking.
- The surviving grant is usually the most powerful one. In divergence A the grant that survives is the root-scoped one: authority over every name on that resolver, not just the one named in the caption. The UI told the user that was exactly what they were removing.
- Terminal state is address substitution. The role most commonly delegated here is
ROLE_SET_ADDR. A delegate who retains it can re-point the name'saddrrecords at an address of their choosing at any time, at which point the name resolves to an attacker-controlled address and any funds sent to it are lost. That loss is unbounded and irreversible. - Blast radius scales with the resolver. A
PermissionedResolvermay serve many names; a surviving root grant covers all of them.
Mapping to the program's in-scope impacts
Claimed:
- ✅ Causing the application to display or pre-fill an incorrect address, name, or transaction detail, so that a user … submits a transaction to an unintended recipient — the scope the UI displays and the scope the approved transaction carries are different values.
- ✅ (terminal, conditional) resolving or rendering an ENS name to an attacker-controlled address — via the retained
ROLE_SET_ADDR.
Explicitly not claimed:
- ❌ Malicious interactions with an already-connected wallet / modifying transaction arguments or parameters — the wrong argument originates in the application's own logic, not in attacker-supplied input; selecting this bullet would misdescribe the mechanism.
- ❌ Hijacking or bricking an ENS name — ownership and registry are untouched; only resolver record permissions are affected.
- ❌ Taking state-modifying authenticated actions on behalf of other users — the victim signs their own transaction; nothing executes as them.
- ❌ Direct theft of user funds — no funds move as a direct result of this bug alone.
Why High and not Critical
The Critical outcome is real but conditional: the victim must first have delegated a resolver role to the attacker, which is a deliberate act the attacker cannot induce. The unconditional, always-present impact is the failed revocation together with the false success signal. I would rather state that than overstate the report; the project is better placed to judge whether the terminal state lifts the severity.
Prerequisites
The victim must previously have granted the delegate a resolver role and must then attempt to revoke it. Divergences A and C additionally require the delegate to hold grants at more than one resource, or at a part-scoped resource — both of which the Explorer's own Add-user sheet produces, since its node selector defaults to the root scope (ResolverAddUserSheet.tsx:39, const ROOT_NODE_VALUE = '', used as initial state at :127).
Duplicate analysis
Not a duplicate of WEB-716 ("No way to replace an existing subregistry"), which concerns the Registry page offering the deploy-subregistry action only when the slot is empty — different page, different gate, different root cause. The only shared trait is the general class "UI gate disagrees with on-chain permissions."
Also distinct from QA-01 / WEB-1257 (role combinations breaking follow-up actions after a transfer), which describe flows that error. This report describes a transaction that succeeds while targeting the wrong resource. The Known Issues document explicitly carves this out: "a demonstrated security consequence of one of them (for example, a role combination that grants authority it should not, rather than merely erroring) goes beyond the known breakage and is a new finding." Here authority is not granted in error but retained in error, with a false success signal.
No Known Issues entry covers groupRolesByAccount, ResolverRolesSidebar, or resolver EAC resource derivation.
References
In-scope code (audited revision 63772fd872af472ced58b009499355f3430c2a86):
apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:166-169— the single derived scopeapps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:294-302, 306-321— both mutations use itapps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:389-396— the "Global roles (all names)" captionapps/portal/src/lib/roles/resolverRoles.ts:138-141—computeNameResource, mirrorsresource(node, 0)apps/portal/src/lib/roles/resolverRoles.ts:147-160—buildResourceToNameMapapps/portal/src/lib/roles/resolverRoles.ts:162-166—RoleInput, dropsnameapps/portal/src/lib/roles/resolverRoles.ts:179-215—groupRolesByAccountapps/portal/src/features/resolver/helpers/revokeResolverRoles.ts:45-61— scope selectionapps/portal/src/features/resolver/helpers/grantResolverRoles.ts:20— the documented conventionapps/portal/src/features/resolver/hooks/useResolverOverview.ts:14-22, 90-98— the discardednameattributionapps/portal/src/features/resolver/components/ResolverRolesTable.tsx:103— the grouping call siteapps/portal/src/features/resolver/components/ResolverAddUserSheet.tsx:39, 127— root scope is the default grant
Contracts (reference; the contracts themselves are out of scope, but they define the resource model the app must match):
contracts-v2/contracts/src/resolver/libraries/PermissionedResolverLib.sol:71-80—resource(node, part)contracts-v2/contracts/src/resolver/libraries/PermissionedResolverLib.sol:85-90—partHash(uint256)contracts-v2/contracts/src/resolver/PermissionedResolver.sol:178-187—onlyPartRolescontracts-v2/contracts/src/resolver/PermissionedResolver.sol:369-388—authorizeAddrRolesgrants atpartResourcecontracts-v2/contracts/src/access-control/EnhancedAccessControl.sol:146-156—revokeRolesis per-resource
Documentation:
- ENS documentation — https://docs.ens.domains/
- ENSv2 contracts overview (role-based access control, up to 32 roles) —
contracts-v2/contracts/README.md
Proof of Concept
Proof of Concept
The defective logic is in exported pure functions, so it is provable offline with no chain and no network.
Part 1 — Reproduce the wrong scope in the repository's own test runner
Save as apps/portal/src/lib/roles/poc-h1.test.ts and run pnpm test:portal:
import { describe, expect, it } from 'vitest'
import { encodePacked, keccak256 } from 'viem'
import { namehash } from 'viem/ens'
import {
buildResourceToNameMap,
computeNameResource,
groupRolesByAccount,
resolverRoles,
} from './resolverRoles'
// Exactly the derivation used at ResolverRolesSidebar.tsx:169
const deriveScope = (resolvedNames: readonly string[]) =>
resolvedNames.find((n) => n !== '(root)') ?? ''
const ROOT_RESOURCE = `0x${'0'.repeat(64)}`
const MALLORY = '0x1234567890abcdef1234567890abcdef12345678'
const NAME = 'alice.eth'
describe('H1 — resolver role revoke targets the wrong EAC resource', () => {
it('A: a root-scoped grant is revoked name-scoped, so it survives', () => {
const groups = groupRolesByAccount(
[
// granted at ROOT_RESOURCE -> authorises setAddr on EVERY name
{ account: MALLORY, resource: ROOT_RESOURCE,
roleBitmap: `0x${resolverRoles.ROLE_SET_ADDR.toString(16)}` },
// granted at resource(namehash('alice.eth'), 0)
{ account: MALLORY, resource: computeNameResource(NAME),
roleBitmap: `0x${resolverRoles.ROLE_SET_TEXT.toString(16)}` },
],
buildResourceToNameMap([{ name: NAME }]),
)
expect(groups).toHaveLength(1) // two resources -> ONE row
const row = groups[0]
expect(row.resolvedNames).toContain('(root)') // caption: "Global roles (all names)"
const scope = deriveScope(row.resolvedNames)
expect(scope).toBe(NAME) // ...but the tx is name-scoped
expect(scope).not.toBe('') // '' would be the ROOT_RESOURCE path
})
it('C: a part-scoped grant resolves to no name, so the revoke goes to ROOT_RESOURCE', () => {
// resource(namehash(name), partHash(60)) — exactly what authorizeAddrRoles
// grants at (PermissionedResolver.sol:376-377; partHash(uint256) at :85-90)
const partHash60 = keccak256(encodePacked(['uint256'], [60n]))
const partResource = keccak256(
encodePacked(['bytes32', 'bytes32'], [namehash(NAME), partHash60]),
)
const groups = groupRolesByAccount(
[{ account: MALLORY, resource: partResource,
roleBitmap: `0x${resolverRoles.ROLE_SET_ADDR.toString(16)}` }],
buildResourceToNameMap([{ name: NAME }]),
)
expect(groups[0].resolvedNames).toEqual([]) // part resources are unresolvable
expect(deriveScope(groups[0].resolvedNames)).toBe('') // -> scope 'root', not the part resource
})
})
Test A shows the caption and the calldata disagreeing; test C shows a part-scoped grant being revoked at an unrelated resource.
Verification performed. node_modules is not installed in this checkout, so vitest could not be executed directly. I re-implemented buildResourceToNameMap, groupRolesByAccount (resolverRoles.ts:147-215) and the roleName derivation (ResolverRolesSidebar.tsx:169) verbatim and ran them on the inputs above. Observed output:
A rows : 1
A resolvedNames : [ '(root)', 'alice.eth' ]
A decodedRoles : [ 'ROLE_SET_ADDR', 'ROLE_SET_TEXT' ]
A derived scope : "alice.eth" <-- name-scoped revoke
A caption shows : Global roles (all names) <-- while the UI claims global
C resolvedNames : []
C derived scope : "" <-- falls back to scope 'root'
Row A is the contradiction in a single frame: the caption asserts the grant is global, and the scope the transaction carries is alice.eth.
Part 2 — Confirm the three resources are distinct (offline computation)
import { encodePacked, keccak256 } from 'viem'
import { namehash } from 'viem/ens'
const ZERO32 = `0x${'0'.repeat(64)}` as const
const node = namehash('alice.eth')
const rootResource = 0n // resource(0,0) short-circuits
const nameResource = keccak256(encodePacked(['bytes32','bytes32'], [node, ZERO32]))
const partHash60 = keccak256(encodePacked(['uint256'], [60n]))
const partResource = keccak256(encodePacked(['bytes32','bytes32'], [node, partHash60]))
// All three differ -> a revoke aimed at one leaves the other two intact.
console.log(rootResource, nameResource, partResource)
This mirrors PermissionedResolverLib.resource() (:71-80) and partHash(uint256) (:85-90).
Part 3 — Optional local-chain confirmation
If an end-to-end check is wanted it can be run against the repository's local Anvil fork only (pnpm e2e:infra:up, chain on 127.0.0.1:8545): deploy a PermissionedResolver, grant ROLE_SET_ADDR at ROOT_RESOURCE and ROLE_SET_TEXT at resource(node,0) to a second account, submit the revoke calldata the app builds for that row, then read hasRoles(ROOT_RESOURCE, ROLE_SET_ADDR, account) and observe it is still true. No public testnet or mainnet is involved.