89374 w a medium stale v1 registry state reinstalls a revoked attacker resolver during v1 to v2 migration enabling ens name hijacking
Submitted on Aug 18th 2026 at 21:30:08 UTC by @calbebop for Audit Competition | ENS
- Report ID: #89374
- 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
Brief/Intro
Manager gets a V1 name's Registry owner and resolver from the V1 subgraph and caches the response for five minutes. It later copies those values into the V1-to-V2 migration without reading the live V1 Registry. If the owner revokes a former manager or replaces a compromised resolver after the page loads, the migration can restore both values. The transaction succeeds and Manager reports success, leaving the V2 name with an attacker-controlled resolver or a revoked account holding ROLE_SET_RESOLVER. This is the competition's Critical Manager-flow impact where a migration assigns the resolver to an attacker.
Vulnerability Details
Root cause and data flow
The bug is in the in-scope Manager app. The subgraph returns indexed state, and the contracts accept the migration data supplied by Manager. Manager treats the indexed owner and resolver as current when it builds the transaction.
Manager requests V1 data from the indexer
Manager requests the V1 resolver, Registry owner, and BaseRegistrar registrant from the indexer:
The indexed result is cached
That result is considered fresh for five minutes:
Eligibility checks do not read the V1 Registry
Eligibility is separately cached for 30 minutes using only the wallet and domain IDs. For an unwrapped .eth name, the live checks call BaseRegistrar ownerOf(labelhash) and V2 getStatus(labelhash). They do not call V1 Registry owner(node) or resolver(node):
Classification copies stale data
Classification copies the indexed custom resolver into v1ResolverAddress. For an unwrapped name whose indexed Registry owner differs from the registrant, it also copies that owner into managerAddress. A custom resolver selects the keep-v1 strategy:
The stale resolver is added to the migration
resolverFor returns that cached custom resolver. The atomic batch builder places it in Migration.Data.resolver and records it as the expected post-migration resolver:
The stale manager receives a role grant
When the cached Registry owner differs from the registrant, the same batch appends grantRoles(resource, 1 << 24, cachedManager). Bit 1 << 24 is V2 ROLE_SET_RESOLVER:
Submission does not refresh V1 Registry state
Immediately before submission, execution rechecks HCA/operator approvals and estimates the transaction. It rebuilds the call from the same classified snapshot and does not refresh the V1 Registry owner or resolver:
Post-state verification validates the stale plan
Post-state verification compares V2 with the resolver and manager role stored in the plan. Because those expected values came from the stale snapshot, the unsafe result passes verification:
Why simulation and the contracts do not reject the transaction
The transaction is valid even though its intent is stale. MigrationHelper.migrate verifies source-token ownership/approval, while UnlockedMigrationController._inject passes the caller-supplied md.resolver directly to V2 registration. Neither contract compares it with the live V1 Registry resolver or owner:
MigrationHelper.sol#L101-L122UnlockedMigrationController.sol#L107-L120UnlockedMigrationController.sol#L152-L165
V2 PermissionedRegistry.setResolver requires ROLE_SET_RESOLVER, which is the role Manager grants to the former manager:
The calldata is valid, so the live gas estimate and transaction both succeed. The application's verifier then accepts the unsafe post-state.
Exploit sequence
Stale state is available
An unwrapped V1 .eth name is owned by the victim at the BaseRegistrar, while a former seller/manager controls its V1 Registry node or a compromised custom resolver is configured.
The migration page caches it
The victim opens Manager's migration page, which caches the owner and resolver.
The victim revokes the authority
The victim uses BaseRegistrar reclaim(tokenId, victim) and replaces or clears the compromised resolver. The attacker now has no live V1 authority.
The victim completes migration
Within the five-minute cache window, the victim returns to the loaded migration page and completes the normal V1-to-V2 flow.
Eligibility passes
Live eligibility still passes because the victim remains the BaseRegistrar token owner and the V2 slot remains reserved.
Stale state is restored in V2
Manager installs the cached attacker resolver in V2 and, in the former-manager variant, grants the revoked manager ROLE_SET_RESOLVER.
The attacker can redirect resolution
The attacker-controlled resolver returns an attacker payment address, or the former manager uses the restored role to set such a resolver later.
This path requires no phishing, leaked key, or attacker transaction during migration. The owner completes the normal migration flow. The five-minute client cache provides the exploit window, and subgraph lag can lengthen it.
The affected path is an unwrapped V1 .eth name. Resolver restoration requires a cached nonzero custom resolver, while manager restoration requires the cached Registry owner to differ from the registrant. Known public resolvers map to the V2 default, and a name locked with CANNOT_SET_RESOLVER is not affected by the resolver-restoration path.
Impact Details
Selected in-scope impact (Critical): "Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer)," specifically "a registration or migration that assigns the name, its ownership, registry, or resolver to an attacker."
After migration, V2 points to the attacker resolver. The Foundry PoC resolves the name through ENSV2Resolver.resolve and returns the attacker's payment address. In the manager variant, the former manager also receives ROLE_SET_RESOLVER.
Payments and other resolver-dependent actions can be redirected until the owner repairs the V2 state. I am not claiming a completed payment or permanent takeover. The selected impact is Critical because the competition includes assignment of a resolver to an attacker during migration.
Recommended Remediation
Read V1 Registry state directly
Immediately before preview construction and again before the first irreversible migration submission, directly read V1 Registry owner(node) and resolver(node) for every selected name.
Derive migration values from live reads
Derive managerAddress, resolverStrategy, and Migration.Data.resolver only from those live reads.
Bind the plan to observed state
Bind the approved plan to the observed live owner/resolver and abort if either changes before submission.
Display restored values
Display every restored manager and non-default resolver address explicitly.
Verify against user-visible values
Verify postconditions against the owner and resolver values shown to the user, not values copied from the original indexer snapshot.
Setting the query cache duration to zero is insufficient because the indexer itself can lag. The final check must read the Registry directly.
References
- Audit Competition | ENS scope and impacts
- Competition resources and audited revision
- Audited Manager source snapshot
- ENS subgraph ownership terminology
- ENS ETH Registrar and
reclaim - ENSv2 contracts revision used by the Foundry PoC
Link to Proof of Concept
https://gist.github.com/calbebop/265efaa55fe6c3353e0ee30a4f3dd3a2
Proof of Concept
Supporting PoC source files (secret Gist):
https://gist.github.com/calbebop/265efaa55fe6c3353e0ee30a4f3dd3a2
I used three tests for different parts of the bug:
- an Anvil test builds and submits the HCA batch with Manager's production code, then checks the V2 resolver and former-manager role;
- a digest-pinned Foundry test runs the official ENS migration fixtures and resolves the migrated name through
ENSV2Resolver.resolve; and - a focused unit test decodes the migration and role calldata and runs Manager's post-state verifier.
All transactions run against disposable local chains. No transaction is sent to public Sepolia or mainnet.
Prerequisites
- Checkout the audited competition repository and install dependencies with its pinned Node/pnpm versions.
- Docker Desktop must be running.
- Port
8545must be free for the local Anvil fork.
PoC 1: Manager-built transaction on Anvil
PoC source file:
apps/manager/src/features/migration/service/staleV1RegistryMetadata.onchain.poc.test.ts
From the repository root, start the repository's pinned disposable Sepolia fork:
docker compose -f e2e/infra/docker-compose.yml up -d --wait anvil
Run the opt-in PoC:
$env:TZ='UTC'
$env:RUN_STALE_V1_METADATA_ONCHAIN_POC='true'
corepack pnpm --filter manager exec vitest run src/features/migration/service/staleV1RegistryMetadata.onchain.poc.test.ts --reporter=verbose
Expected result:
✓ stale V1 Registry metadata migration PoC (Anvil fork)
✓ reinstalls a revoked resolver and grants the revoked manager live V2 resolver control
Test Files 1 passed (1)
Tests 1 passed (1)
The test snapshots the fork and reverts it in finally. It then:
Create and reserve the name
Creates an unwrapped V1 .eth BaseRegistrar token and reserves the corresponding V2 slot.
Configure attacker-controlled V1 state
Makes the attacker the V1 Registry manager and custom resolver.
Capture the state
Captures that state in the same V1Domain shape supplied to Manager by the subgraph.
Revoke the attacker
Has the victim call BaseRegistrar reclaim and clear the live V1 resolver.
Confirm revocation
Reads V1 Registry owner(node) and resolver(node) and asserts the attacker is fully revoked.
Run classification and eligibility
Calls the production classifyName and runEligibilityChecks; eligibility succeeds because those checks never read current V1 Registry authority.
Deploy and approve the HCA
Deploys the HCA at its deterministic address and supplies the BaseRegistrar and V2 Registry approvals used by the application.
Build the migration batch
Calls resolveDirectMigrationRoutes and buildAtomicMigrationBatches from Manager.
Confirm stale values in the batch
Checks that the migration data contains the stale attacker resolver and the batch contains the stale manager-role grant.
Estimate gas
Runs eth_estimateGas over the app-built HCA executeByOwner call. Estimation succeeds.
Submit the transaction
Submits that outer call to the local fork.
Validate the V2 state
Reads V2 and asserts that its resolver is the attacker and the former manager has ROLE_SET_RESOLVER.
Exercise restored resolver authority
Sends an attacker-signed V2 setResolver transaction and confirms that the call succeeds.
Stop the disposable fork after validation:
docker compose -f e2e/infra/docker-compose.yml down
Result on a newly created fork: 1/1 passed.
PoC 2: resolver takeover in the ENS contract fixtures
PoC files:
poc/StaleResolverMigration.e2e.t.solpoc/run-e2e.ps1poc/README.md
From the competition repository root:
.\poc\run-e2e.ps1
The runner uses a digest-pinned Foundry image. It checks out ensdomains/contracts-v2 at 892311a7268bf6051d5fe65740f1eebfaf8db431, initializes its submodules, checks formatting, and runs the PoC.
Expected and observed result:
[PASS] test_staleResolverIsReinstalledAfterVictimRevokesIt() (gas: 695863)
Suite result: ok. 1 passed; 0 failed; 0 skipped
This test uses the official migration fixtures and a resolver that returns the attacker's address. It:
Register a V1 name
Registers an unwrapped V1 name.
Configure and cache the attacker resolver
Configures and caches the attacker resolver.
Replace it with a safe resolver
Has the victim replace it with the safe V1 resolver.
Supply stale migration data
Supplies Manager's stale value in Migration.Data.
Execute migration
Executes the UnlockedMigrationController migration.
Confirm stale resolver installation
Asserts V2 stored the attacker resolver.
Resolve the attacker payment address
Calls ENSV2Resolver.resolve and decodes the attacker payment address.
Result from an empty Docker volume: 1/1 passed.
PoC 3: calldata and post-verifier check
PoC source file:
apps/manager/src/features/migration/service/staleManagerRestoration.poc.test.ts
Run:
$env:TZ='UTC'
corepack pnpm --filter manager exec vitest run src/features/migration/service/staleManagerRestoration.poc.test.ts --reporter=verbose
Expected and observed result:
✓ POC: revoked V1 authorization state is restored during migration
✓ passes live eligibility without reading the current V1 Registry manager
✓ encodes and certifies a V2 SET_RESOLVER grant to the removed manager
✓ reinstalls a stale malicious resolver without any manager restoration
Test Files 1 passed (1)
Tests 3 passed (3)
This test uses production classification, eligibility, batch construction, ABI decoding, and post-verification. It checks that:
- the only live eligibility reads are
ownerOfandgetStatus; - decoded
MigrationHelper.migratecalldata contains the stale resolver; - decoded
grantRolescalldata gives the revoked manager1 << 24; - Manager's production verifier accepts the stale resolver and manager role; and
- the resolver-only variant requires no manager-role restoration step.