92685 w a medium stale v1 resolver state in manager migration restores an attacker controlled resolver and hijacks the migrated ens name
Submitted on Sep 11th 2026 at 15:15:45 UTC by @NuttyHare14789 for Audit Competition | ENS
- Report ID: #92685
- 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
ENS Manager caches the V1 name inventory, including each name's resolver address, for five minutes and copies that address into the v1-to-v2 migration plan.
If the legitimate new owner replaces a hostile resolver after Manager's first read but before confirming migration, Manager does not re-read the canonical V1 registry. It submits the old resolver from the cached plan. The final V2 name therefore points to resolver code controlled by the former owner, although the live V1 resolver was safe when the victim confirmed Upgrade Names.
Manager reports the migration as successful because its post-state verification compares the final V2 resolver against the same stale value used to construct the transaction.
Scope and severity
- Asset:
https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/manager - Category: Websites & Apps
- Severity: Critical
- Selected impact: Hijacking or bricking an ENS name during a Manager flow (v1-to-v2 migration): the migration assigns the name's resolver to an attacker.
- Official audited revision:
63772fd872af472ced58b009499355f3430c2a86 - Competition mirror base used for the PoC:
1c9b47f18fcddd2e864dfe385c4171061c9811ae
The relevant migration files are identical between the audited revision and the competition mirror base used for the PoC. The mirror base adds only the competition fork commit and an unrelated hono dependency update.
Impact
An ENS resolver controls the addresses and other records returned for a name. In the executed PoC, the victim authorizes the normal Manager migration, but the final V2 registry points to the former owner's resolver. Calling addr(bytes32) on that resolver returns the former owner's address.
Applications resolving the migrated name can consequently receive an attacker-controlled address rather than the victim's intended address. This is the exact scoped Critical state: a Manager migration assigns the name's resolver to an attacker.
The PoC does not rely on a theoretical incorrect display and does not claim unproven direct theft. It demonstrates the on-chain name-hijack state and attacker-controlled resolution.
Preconditions and attack path
- The attacker is the legitimate ordinary owner of an existing V1 name before transferring or selling it to the victim.
- While still authorized, the attacker sets a custom resolver they control.
- The attacker transfers the registration to the victim.
- The victim reclaims V1 registry manager authority. The former owner can no longer change the resolver directly.
- The victim opens the new Manager dashboard. Manager reads the hostile resolver into its migration state.
- The victim repairs the name through the official legacy ENS Manager using More → Resolver → Edit → Latest resolver → Update.
- The victim returns to the already-open new Manager and confirms Upgrade Names before the five-minute query becomes stale.
- Manager migrates the cached hostile resolver instead of the live safe resolver.
This is a normal post-transfer recovery and migration sequence. The attacker does not require access to the victim's device, network, wallet, credentials, or private key. The attacker's only actions are ordinary transactions performed while they legitimately own the name.
Root cause
The defect is a missing freshness invariant across preview, transaction construction, and verification:
apps/manager/src/features/migration/hooks/useV1Names.ts:12-29setsstaleTime: 5 * 60 * 1000for the V1 inventory.packages/migration/src/service/classifyNames.ts:50-88storesv1ResolverAddressand selectskeep-v1for an unknown custom resolver. Lines 137-183 copydomain.resolver.addressinto the classified migration name.apps/manager/src/features/migration/service/encodeMigration.ts:24-50returns the storedname.v1ResolverAddressforkeep-v1.apps/manager/src/features/migration/service/buildAtomicMigrationBatches.ts:453-491places that address intoMigrationData.resolverasexpectedResolver.apps/manager/src/features/migration/service/migrationService.ts:1161-1180refreshes mutable approval state before the first wallet prompt, but does not refresh or compare the live V1 resolver.buildAtomicMigrationBatches.ts:544-565records the same stale value as the post-state expectation.verifyAtomicMigrationBatch.ts:263-275then checks only that the final V2 resolver equals that stale expectation.
Both the migration calldata and the success verifier therefore agree on the same obsolete attacker-controlled address. Verification accepts the exploit result instead of detecting it.
Why this is not a disclosed known issue
QA-01concerns errors in follow-up actions for certain post-transfer role combinations. Here migration succeeds and assigns an attacker-controlled resolver.WEB-1305concerns a role transfer to a V1 name hanging. This report concerns a completed v1-to-v2 migration with stale resolver calldata.WEB-390concerns subnames missing from the migration list. The PoC uses ordinary second-level.ethnames that appear in the migration list.SEC-TXM-002concerns a cached smart-account address. This finding concerns a cached V1 resolver in Manager's migration plan, with a different root cause and a demonstrated final impact.
Recommended remediation
Immediately before the first wallet prompt and before each migration batch, read the resolver for every selected source name from the canonical V1 ENS registry. Compare it with the resolver used to construct the preview and calldata. If any value changed, fail closed, invalidate the plan, refetch the V1 inventory, and require the user to review a rebuilt transaction.
Post-state verification must bind to the fresh pre-submit source value rather than the old cached plan. If supported by the migration contracts, the strongest fix is to include the expected source resolver in the atomic migration and revert on-chain when the V1 resolver differs, eliminating the residual check-to-sign race.
Link to Proof of Concept
https://gist.github.com/framarche83/a1b9c48694ffe63dbfcce820a37c6e37
Proof of Concept
PoC resources
- Network: local Ethereum Sepolia fork, chain ID
11155111 - Pinned fork block:
11681965 - Execution date: 2026-09-11
The secret Gist contains the complete PoC patch series, final Playwright source, deterministic Docker override, demonstration fix, and executed-result record. The steps and expected results are also included below, so the Gist is not a replacement for this Proof of Concept.
Prerequisites
- Node.js 22
- pnpm 10 through Corepack
- Docker Desktop or Docker Engine
- Git
- a reviewer-supplied Sepolia RPC endpoint
All exploit execution occurs on the local fork. Nothing is broadcast to public Sepolia.
Step 1 — Prepare the scoped Manager repository
The commands below use PowerShell. Create an empty working directory and run the commands from that directory.
$work = (Get-Location).Path
$managerRepo = Join-Path $work 'audit-comp-ens'
$pocRepo = Join-Path $work 'ens-stale-resolver-poc'
$legacyRepo = Join-Path $work 'ens-app-v3'
git clone --branch audit-comp-ready https://github.com/immunefi-team/audit-comp-ens.git $managerRepo
git clone https://gist.github.com/a1b9c48694ffe63dbfcce820a37c6e37.git $pocRepo
git -C $managerRepo checkout 1c9b47f18fcddd2e864dfe385c4171061c9811ae
git -C $managerRepo am `
"$pocRepo\0001-test-capture-stale-V1-resolver-migration-impact.patch" `
"$pocRepo\0002-test-emit-submission-capture-artifacts.patch" `
"$pocRepo\0003-test-bind-V1-graph-fields-to-live-fork-state.patch" `
"$pocRepo\0004-test-reproduce-stale-resolver-through-legacy-manager.patch"
Set-Location $managerRepo
corepack enable
corepack pnpm install --frozen-lockfile
Step 2 — Prepare the official legacy recovery UI
git clone https://github.com/ensdomains/ens-app-v3.git $legacyRepo
git -C $legacyRepo checkout 89f8681d524e9f40511dadd016ee95a6abff346d
Set-Location $legacyRepo
corepack pnpm install --frozen-lockfile
The legacy application is used only for the victim's ordinary resolver recovery. The browser PoC injects the victim's local test wallet and points the UI to the local fork.
Step 3 — Start a deterministic Sepolia fork and services
Use a fresh fork and fresh Panoptes volume. The tests mutate their three real names, so reset the environment before repeating the suite.
Set-Location $managerRepo
if (-not $env:SEPOLIA_FORK_URL) {
throw 'Set SEPOLIA_FORK_URL to a reviewer-controlled Sepolia RPC endpoint.'
}
$env:PANOPTES_CONTRACTS_FILE = './panoptes/contracts.stale-resolver-poc.json'
docker compose `
-f e2e/infra/docker-compose.yml `
-f "$pocRepo\docker-compose.stale-resolver-poc.override.yml" `
down -v
docker compose `
-f e2e/infra/docker-compose.yml `
-f "$pocRepo\docker-compose.stale-resolver-poc.override.yml" `
up -d
The supplied override pins Anvil to block 11681965.
Step 4 — Start both ENS applications
Manager, in the first PowerShell window:
Set-Location $managerRepo
corepack pnpm --filter manager dev --mode ci --host 127.0.0.1
Official legacy Manager, in the second PowerShell window:
Set-Location $legacyRepo
$env:NEXT_PUBLIC_CHAIN_NAME = 'sepolia'
$env:NODE_NO_WARNINGS = '1'
corepack pnpm exec next dev -p 3001
Step 5 — Run the vulnerable case and timing control
In a third PowerShell window:
Set-Location "$managerRepo\e2e"
$env:MANAGER_APP_URL = 'http://127.0.0.1:3000'
$env:LEGACY_MANAGER_APP_URL = 'http://127.0.0.1:3001'
corepack pnpm exec playwright test `
--config=projects/manager/playwright.stale-resolver-poc.config.ts `
--grep "vulnerable:|counterfactual:"
Expected runner result:
2 passed (approximately 1.2 minutes)
The vulnerable pampa.eth test performs and asserts every load-bearing edge:
- The existing holder is an ordinary EOA with no code and is not a
BaseRegistrarcontroller. - The existing V2 premigration resolver is nonzero; the PoC does not seed or register the V2 name.
- The former owner deploys and sets a malicious resolver while authorized, then transfers the real V1 registration to the victim.
- The victim reclaims registry manager authority.
- A simulated post-transfer
setResolverfrom the former owner reverts. - Manager reads the malicious resolver into its V1 migration state.
- The victim uses the official legacy UI to set the latest safe resolver and authorizes the emitted wallet transaction.
- The canonical V1 registry is asserted to contain the safe resolver.
- The victim authorizes Manager's displayed Upgrade Names flow.
- The final V2 resolver is asserted to equal the old malicious resolver.
addr(bytes32)on the final resolver is asserted to return the former owner's address.
Captured vulnerable result:
name = pampa.eth
ordinary former owner = 0x4f26c09d3900C39ae8506bDb3359Cc3e4a527806
existing V2 premigration resolver = 0xae66c62AcAE72098BdAc57d8E8AED53EF000b2Ba
malicious resolver = 0xc13fd5fedfca81b990c71d68c4fbd397f49395a1
live V1 resolver after recovery = 0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5
final V2 resolver = 0xC13fd5fEDfcA81B990C71d68C4Fbd397F49395a1
final addr(bytes32) answer = 0x4f26c09d3900C39ae8506bDb3359Cc3e4a527806
Playwright = passed in 40.4 seconds
The agentmesh.eth timing control changes only when the official recovery reaches Manager: recovery occurs before the first Manager read. The malicious resolver is not installed, and the final V2 resolver is the safe resolver 0xE99638b40E4Fff0129D56f03b55b6bbC4BBE49b5. This test passed in 28.6 seconds.
The PoC creates its own browser traces and screenshots under:
audit-comp-ens/.bounty-evidence/manager-stale-v1-resolver-resurrection/runtime/
The directory contains vulnerable-trace.zip, counterfactual-trace.zip, the final-state screenshots, and screenshots of the official legacy recovery UI. These are generated locally by the test and require no Immunefi file attachment.
Step 6 — Run the same-order fixed-code control
Reset the Docker environment with the down -v and up -d commands from Step 3. Apply the demonstration fix:
git -C $managerRepo am "$pocRepo\0005-fix-reject-stale-V1-resolver-migration-previews.patch"
Restart Manager, then run:
Set-Location "$managerRepo\e2e"
$env:MANAGER_APP_URL = 'http://127.0.0.1:3000'
$env:LEGACY_MANAGER_APP_URL = 'http://127.0.0.1:3001'
corepack pnpm exec playwright test `
--config=projects/manager/playwright.stale-resolver-poc.config.ts `
--grep "patched:"
Expected runner result:
1 passed (approximately 1.4 minutes)
The luoyingjie.eth control preserves the vulnerable ordering: Manager reads the hostile resolver, the victim recovers through the official UI, and the victim confirms migration. The added live-registry check rejects the stale plan before any wallet transaction request. V2 remains at the premigration resolver 0xae66c62AcAE72098BdAc57d8E8AED53EF000b2Ba.
This establishes causality: with vulnerable code the stale resolver is migrated; with only the claimed freshness check added, the identical attack ordering is blocked.
Test-environment integrity
pampa.eth,agentmesh.eth, andluoyingjie.ethare real, valid, already-premigrated Sepolia V1 names at the pinned fork block.- No ENS owner, registrar controller, governance address, or V2 registrar is impersonated.
- No V2
registeror V2 state-seeding call is used by the final PoC path. - Fork impersonation is limited to the ordinary former owner and reproduces only transactions this actor can sign with their own key: deploy a resolver, set it while owning the name, and transfer the name.
- The victim uses a controlled local test key and authorizes the recovery and migration through the browser UIs.
- The V1 Graph adapter reads resolver, manager, registrant, and expiry from the canonical fork state on every request. It observes the safe resolver after recovery and does not create the stale value.
- The stale value originates in Manager's five-minute application cache.
Evidence generated by the PoC
vulnerable-trace.zip— complete vulnerable browser tracevulnerable-final.png— final migrated statepampa-official-v1-recovery.png— official recovery UIcounterfactual-trace.zip— timing-control tracecounterfactual-final.png— safe timing-control resultagentmesh-official-v1-recovery.png— timing-control recovery UIpatched-code-trace.zip— same-order patched-code tracepatched-code-blocked.png— patched failure screenluoyingjie-official-v1-recovery.png— patched-case recovery UI
Additional verification completed: 83 focused unit tests passed across three files, Manager typecheck passed, and Biome passed for every changed file.
All testing was performed on a local fork. No public-chain name, third-party funds, or external system was modified.