92064 w a critical incorrect eac resource scoping in the resolver role editor leads to an unintended global set addr grant
Submitted on Sep 6th 2026 at 20:15:58 UTC by @abu2win for Audit Competition | ENS
- Report ID: #92064
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- 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
Description
Brief/Intro
The Explorer resolver role editor combines all role assignments for the same account even though each assignment belongs to a separate EAC resource. A record-part role, such as permission to update one text key on one ENS name, cannot be resolved by the current resource-to-name map. The sidebar converts that missing name to an empty string, and the transaction helper treats an empty string as ROOT_RESOURCE. As a result, adding the Address permission to the affected row prepares grantRootRoles(ROLE_SET_ADDR, delegate). A delegate that previously had access to only one text key can then change address records for every name using that resolver.
Vulnerability Details
ENS Enhanced Access Control associates a role with both an account and a resource. Resolver resources can represent the entire resolver, one name, or a specific record part. These scopes must remain separate because a role on ROOT_RESOURCE applies to every resolver resource.
The problem starts in buildResourceToNameMap() in apps/portal/src/lib/roles/resolverRoles.ts:147-159. The map includes only ROOT_RESOURCE and whole-name resources computed as:
keccak256(
encodePacked(['bytes32', 'bytes32'], [namehash(name), ZERO_BYTES32]),
)
A text-record permission uses a more specific resource:
keccak256(namehash(name) || keccak256(textKey))
That resource is not present in the map, even when the corresponding name is in the resolver's node list.
groupRolesByAccount() at resolverRoles.ts:179-220 then groups assignments using only the account address:
const account = role.account.toLowerCase()
const existing = grouped.get(account)
It retains the original assignments in roles, but the later transaction path does not use their resource values. For an account that holds only record-part roles, the resulting row has:
resolvedNames = []
The table renders no Name value when this array is empty at ResolverRolesTable.tsx:65-70. The sidebar also omits its scope description because that description is rendered only when resolvedNames.length > 0 at ResolverRolesSidebar.tsx:391-396. The owner is therefore not shown a warning that editing the blank-scope row will create a global role.
ResolverRolesSidebar.tsx:165-169 derives one name for both Save and Remove:
const resolvedNames = row?.original.resolvedNames ?? []
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''
The empty array therefore becomes roleName = ''.
The Save path forwards this value at ResolverRolesSidebar.tsx:295-303. The production transaction helper at grantResolverRoles.ts:45-60 interprets the empty string as root scope:
name === ''
? {
resolverAddress,
targetAccount: account,
scope: 'root',
roles,
}
: {
resolverAddress,
targetAccount: account,
scope: 'name',
name,
roles,
}
For ROLE_SET_ADDR, the exact calldata generated by this path decodes to:
grantRootRoles(1, 0xf98dad9f1aa054cdec857f9bb98f0be9b1f74b32)
The complete calldata is:
0x072d5d770000000000000000000000000000000000000000000000000000000000000001000000000000000000000000f98dad9f1aa054cdec857f9bb98f0be9b1f74b32
This behavior is deterministic for an unresolved record-part row. It is not caused by malformed indexer data. The resolver emits record-part resources as a normal part of its supported text-role authorization flow.
The same account-only grouping also causes incorrect revocations. If an account has roles on two names, Remove user revokes only the first resolved name. If an account has both root and name roles, the sidebar displays Global roles but selects the non-root name for the revocation. These are secondary consequences of the same resource identity loss.
Impact Details
The selected impact is the program's Critical impact for modifying transaction arguments or parameters in an interaction with an already-connected wallet. The owner initiates a normal role-management action and approves a prompted transaction, but Explorer changes the security scope represented by the row to ROOT_RESOURCE and generates grantRootRoles.
The local-fork PoC establishes that the delegate can initially update only its authorized text key and that setAddr reverts with EACUnauthorizedAccountRoles. After the exact production-generated calldata is submitted locally from the resolver owner, the delegate can execute setAddr, and addr(node) returns the attacker-controlled address.
The impact is limited to names that use the affected PermissionedResolver. It does not grant control over all ENS names. A successful attack requires an existing narrowly scoped delegate and a resolver owner or administrator who edits that delegate through Explorer and approves the resulting transaction. This report does not claim a zero-click takeover, private-key compromise, or direct theft of funds.
Risk Breakdown
The security consequence is a scope escalation from one record part to the resolver root. The attacker cannot perform this escalation alone. The required conditions are:
- The attacker already has a legitimate, narrowly scoped record-part role.
- A resolver owner or administrator opens that delegate's unresolved row in Explorer and adds a permission such as Address.
- The owner or administrator approves the transaction prepared by Explorer.
The transaction is valid at the contract layer, so the wallet prompt does not correct the Portal's lost scope. It asks the owner to authorize the production-generated grantRootRoles call. Once approved, the new role is global within that PermissionedResolver rather than limited to the original name or record part.
This matches the selected Critical website impact because an already-connected wallet is induced to submit a transaction with a materially broader authorization target than the role-management interface represents. The preconditions above should be considered when assessing likelihood.
Recommendation
Preserve the EAC resource throughout the table, sidebar, and transaction preparation path. Rows should be identified by both account and resource rather than account alone. Represent root, name, and record-part scopes with an explicit discriminated type instead of using an empty string as the root sentinel.
If a resource cannot be resolved to a supported editable scope, disable Save and Remove and show the raw resource or an explicit unsupported-scope message. Do not silently convert an unresolved resource into ROOT_RESOURCE. The transaction review should also display the exact scope that will be granted or revoked before requesting wallet approval.
References
- Affected source commit
buildResourceToNameMap()andgroupRolesByAccount()- Blank Name rendering
- Sidebar scope selection
- Omitted scope description
- Save path
- Root/name transaction selection
- ENS Enhanced Access Control
- ENS name processing
Link to Proof of Concept
https://gist.github.com/AbuCTF/95fcc8e05be8cf9ebdf107ec86993f03
Proof of Concept
Proof of Concept
This runs the affected Portal code against a disposable local Anvil fork. It reproduces the empty scope, submits the transaction prepared by the Portal, and checks the resolver state before and after the grant.
Safety
No state-changing call is sent to Sepolia or mainnet. exploit.mjs exits if its RPC host is not 127.0.0.1 or localhost.
No wallet, private key, testnet ETH, or public transaction is needed.
Files
Copy the three secret Gist files into the competition repository:
poc/ens-001/
├── ENS.md
├── exploit.mjs
└── run.sh
Requirements:
- Repository dependencies installed with pnpm
- Node.js
mise- Foundry 1.8.1
State Used
- Resolver:
0x694a2f963164c152a23b91ef0de53fe9b02ae1e3 - Resolver owner:
0xaa1aef44dde610f433f271c6a8749139dd5162e1 - Delegate:
0xf98dad9f1aa054cdec857f9bb98f0be9b1f74b32 - Name:
agent.salviega.eth - Existing text permission:
moor.agent.state - New address value:
0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
The name, delegate, and text permission already exist in the forked state. The owner is impersonated only inside Anvil. The new address is just used for the final read-back.
Run
From the competition repository root:
./poc/ens-001/run.sh
For a deterministic run, use an archive-capable Sepolia RPC and the pinned block:
ENS_POC_FORK_RPC_URL='https://your-sepolia-archive-rpc' \
ENS_POC_FORK_BLOCK_NUMBER=11648298 \
./poc/ens-001/run.sh
Reproduction
run.shstarts Anvil onhttp://127.0.0.1:18547.exploit.mjsimports the productionbuildResourceToNameMap(),groupRolesByAccount(), andprepareGrantResolverRolesTransaction()functions.- The script builds the existing
moor.agent.stateresource foragent.salviega.eth. - The Portal code returns
resolvedNames: []. The sidebar fallback turns that into an emptyroleName. - The production transaction helper turns that empty value into
grantRootRoles(1, delegate)for the unresolved record-part row. - Before applying the grant, the delegate's authorized
setTextsimulation succeeds and itssetAddrsimulation reverts withEACUnauthorizedAccountRoles. - The resolver owner is impersonated inside Anvil and the exact Portal-generated calldata is submitted to the local fork.
- The delegate calls
setAddr. The finaladdr(node)read returns0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF. run.shstops Anvil and discards the local state.
Expected Result
portal emits grantRootRoles(1, delegate)
setAddr before grant denied
root grant 0x...
addr change 0x...
addr now 0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF
The two hashes vary per run. The denied call is the before-state; the final address read is the after-state. Together they show that the Portal turned an unresolved record-part row into root SET_ADDR authority.