92764 w a critical encoded label recipient names select an attacker owned literal label but query the honest hash only node redirecting portal name transfers
Submitted on Sep 12th 2026 at 04:15:30 UTC by @Ailenia for Audit Competition | ENS
- Report ID: #92764
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- Causing the application to display or pre-fill an incorrect address, name, or transaction detail, so that a user sends funds or submits a transaction to an unintended recipient, such as: - resolving or rendering an ENS name to an attacker-controlled address - showing the wrong recipient or amount on a transaction the user then approves
Description
Brief/Intro
I found that Portal accepts [H] as a genuine hash-only ENS label in action-bearing inputs, but does not bind resolver discovery to the node queried from that resolver. In a compatible public hybrid subnamespace, an attacker can register the distinct literal [H] label and make Portal resolve the honest V1 representation to the attacker's address. A victim who approves the displayed transfer then sends their ENSv2 name NFT to the attacker.
Vulnerability Details
The affected revision is 1c9b47f18fcddd2e864dfe385c4171061c9811ae.
Affected asset: ../ens/repos/audit-comp-ens/apps/portal.
Portal accepts [64 lowercase hexadecimal characters] as the indexer representation of a label known only by hash (../ens/repos/audit-comp-ens/apps/portal/src/utils/token/isNormalized.ts:3,39-57). ../ens/repos/audit-comp-ens/apps/portal/src/utils/token/isNameOrAddress.ts:23 consequently accepts a multi-label encoded name in account-bearing inputs.
The same file recognizes that a literal bracket label is indistinguishable from an encoded labelhash, but protects only registration inputs (../ens/repos/audit-comp-ens/apps/portal/src/utils/token/isNormalized.ts:59). A public registrar can accept the distinct 66-byte literal label and recipient resolution does not reject it.
Portal's dashboard obtains V1 names (../ens/repos/audit-comp-ens/apps/portal/src/features/dashboard/hooks/useV1NamesForAddress.ts:18) and preserves the V1 name while merging (../ens/repos/audit-comp-ens/apps/portal/src/utils/names/mergeNamesData.ts:81). It renders or copies that value with EntityBadge (../ens/repos/audit-comp-ens/apps/portal/src/features/names/components/NamesTable/columns.tsx:22; ../ens/repos/audit-comp-ens/apps/portal/src/components/EntityBadge.tsx:192,502).
Without a V1 label preimage, the displayed and copied value is [H].zone.eth, identifying the honest hash-only resource. My PoC passes that exact production copy value to SendNameForm.
The vulnerable resolution flow is:
../ens/repos/audit-comp-ens/apps/portal/src/features/address/hooks/useAddressResolution.ts:43documents that the shared hook is used by transfer-recipient and other name-or-address fields. It validates the encoded input and callsgetResolvedAddress.../ens/repos/audit-comp-ens/apps/portal/src/features/address/queries/getResolvedAddress.ts:26passes the string unchanged toresolveAddressOrName.../ens/repos/audit-comp-ens/apps/portal/src/features/roles/helpers/addUser.handlers.ts:20calls Viem'sgetEnsAddresswith that string and the Universal Resolver, without checking that resolver discovery and resolver calldata refer to the same node.- The DNS packet used for resolver discovery contains the literal 66-byte
[H]label, so target-eraUniversalResolverV2traversal selects the resolver ofkeccak256(parent, keccak256(bytes("[H]"))). The record calldata usesnamehash("[H].zone.eth"); Viem recognizes the encoded notation and substitutesH, producingkeccak256(parent, H). These nodes are distinct. - The owner of the literal
[H]registration can select its resolver while that resolver is asked for the honest hash-only node. The attacker configures its resolver to return the attacker address for the decoded node. ../ens/repos/audit-comp-ens/apps/portal/src/features/transfer/components/SendNameForm.tsx:81consumes the returned address, supplies it tostartTransferat lines 106-109, and shows the full address at line 248.../ens/repos/audit-comp-ens/apps/portal/src/features/transfer/helpers/transferToken.ts:67then encodes it as the recipient ofsafeTransferFrom.
I report the in-scope failure in Portal: it admits ambiguous encoded-label notation into action-bearing inputs without identity binding. Resolver traversal, Viem encoding, V1 representation, and registrar state are execution context, not my filed root cause.
I demonstrate a locally configured, contract-permitted public hybrid .zone.eth subnamespace. I do not claim that it is canonical .eth or a currently deployed namespace. A separate bootstrap actor creates the parent infrastructure.
The ordinary zone.eth owner creates raw H before migration and later uses normal V2 token roles to configure its child registry, ENSV1Resolver mirror, and public ETHRegistrar. The attacker has no global-root, parent, token, registrar-admin, or namespace-owner authority before or after the exploit.
Before the attacker registers anything, [H].zone.eth resolves to the honest address. After the attacker registers only the distinct literal bracket label, the same input selects the attacker's resolver and returns the attacker address. My PoC also makes that resolver return the honest address for its own literal node. This proves that the attacker result depends on the discovery/calldata identity split rather than merely owning the literal resource.
This is not Known Issue EXP-INPUT-005, which covers normalization transformations and homograph-style UI confusion "rather than incorrect resolution." Here two distinct on-chain resources are interpreted inconsistently, Portal resolves the honest representation to an attacker-controlled address, and that address is placed into and used by a signed transaction.
Impact Details
This seems to enter into the Impact: "Causing the application to display or pre-fill an incorrect address, name, or transaction detail, so that a user sends funds or submits a transaction to an unintended recipient," including "resolving or rendering an ENS name to an attacker-controlled address."
In a pre-existing compatible public hybrid subnamespace, an attacker can monitor a public hash-only representation and register its literal bracket-label counterpart. When a victim uses the Portal-provided copied name as a transfer recipient, Portal resolves and pre-fills the attacker address. After the victim approves the transaction, the victim's ENSv2 name is transferred to the attacker. My PoC demonstrates that NFT loss as the consequence of the wrong-recipient impact.
Existing Safeguards and Counterarguments
- "The full address and irreversible-transfer warning are visible." This reduces likelihood but does not restore recipient provenance. The impact I chose covers a user approving after the application resolves a supplied ENS name to an attacker-controlled address.
- "The namespace topology is operator-configured." My PoC demonstrates a contract-permitted local topology, not a live or first-party deployment. Its configuration precedes the attack. The attacker needs no namespace role and uses only the public registrar plus its own resolver.
- "The contracts and Viem create the mismatch." They behave consistently with their respective literal and encoded inputs. The in-scope Portal accepts one ambiguous string in an action-bearing field without requiring those identities to match.
Duplicate Check
EXP-INPUT-005 covers normalization and homograph-style display confusion rather than incorrect resolution. My report resolves encoded labelhash notation to an attacker-controlled address, executes the resulting transfer, and proves final NFT ownership by the attacker.
Remediation
I recommend treating encoded labelhash notation as display-only unless the resolver stack can prove one identity for DNS traversal and record calldata. As an immediate application-layer fix, reject encoded labelhash components in transfer-recipient, role-grantee, owner, and other authority-bearing inputs, and require an explicit address. Add a regression covering a genuine hash-only V1 child and a separately registered literal [H] V2 child.
References
../ens/repos/audit-comp-ens/apps/portal/src/utils/token/isNormalized.ts:3../ens/repos/audit-comp-ens/apps/portal/src/utils/token/isNameOrAddress.ts:23../ens/repos/audit-comp-ens/apps/portal/src/features/dashboard/hooks/useV1NamesForAddress.ts:18../ens/repos/audit-comp-ens/apps/portal/src/utils/names/mergeNamesData.ts:81../ens/repos/audit-comp-ens/apps/portal/src/features/names/components/NamesTable/columns.tsx:22../ens/repos/audit-comp-ens/apps/portal/src/components/EntityBadge.tsx:192../ens/repos/audit-comp-ens/apps/portal/src/features/address/hooks/useAddressResolution.ts:43../ens/repos/audit-comp-ens/apps/portal/src/features/address/queries/getResolvedAddress.ts:26../ens/repos/audit-comp-ens/apps/portal/src/features/roles/helpers/addUser.handlers.ts:20../ens/repos/audit-comp-ens/apps/portal/src/features/transfer/components/SendNameForm.tsx:81../ens/repos/audit-comp-ens/apps/portal/src/features/transfer/components/SendNameForm.tsx:248../ens/repos/audit-comp-ens/apps/portal/src/features/transfer/helpers/transferToken.ts:67
Link to Proof of Concept
https://gist.github.com/niafreu/6a0880e49c34d8d7944ec31f27f77727
Proof of Concept
Proof of Concept
My PoC is available in this gist. It starts a fresh loopback Anvil process, compiles the test-only controls and pinned ENSv2 contracts, and imports the affected production Portal source from ../ens/repos/audit-comp-ens.
Prerequisites are Foundry, target dependencies installed under ../ens/repos/audit-comp-ens/apps/portal/node_modules, the target checkout at 1c9b47f18fcddd2e864dfe385c4171061c9811ae, and the ensdomains/contracts-v2 checkout at 48b3e2d39513b9dd32ef1850877a29009bc807b9 with Foundry dependencies installed. Set TARGET_ROOT and CONTRACTS_ROOT when those checkouts are not in the default supplied layout.
Download all five Gist files into one directory, then run from that directory:
TARGET_ROOT=/path/to/audit-comp-ens \
CONTRACTS_ROOT=/path/to/contracts-v2/contracts \
POC_PORT=19951 \
../ens/repos/audit-comp-ens/apps/portal/node_modules/.bin/vitest run \
--config "$PWD/vitest.config.ts" --reporter=verbose
My test performs and asserts these steps:
- Bootstrap creates parent infrastructure. The ordinary
zone.ethowner creates the raw-HV1 child before migration. Its normal V2 token roles then set its supplied child registry andENSV1Resolvermirror, and its own child registry authorizes an operator-deployed supplied publicETHRegistrarinstance. This is a locally demonstrated, contract-permitted configuration; no live deployment is claimed. - It verifies that literal and decoded nodes differ; the attacker has zero global-root, parent-root, zone-token, registrar-admin, or namespace-owner authority before the attack; the literal label is unowned; and production
resolveAddressOrNameresolves[H].zone.ethto the honest address. - The attacker calls that operator-deployed public supplied
ETHRegistrarinstance's normalcommitandregisterfunctions for the distinct literal 66-byte[H]string, assigns its own resolver, and configures that resolver to answer the decoded node with the attacker address. Zero commitment age and free price/payment controls accelerate timing and payment only; they grant no authority and do not change label identity or the registrar call path. The same authority-absence assertions run again after registration. - The test runs production
resolveAddressOrNameandgetResolvedAddressand obtains the attacker address. It executes productionmergeNamesDataandEntityBadge; a minimal mock records the value supplied by EntityBadge's real Copy action, and that exact string is pasted into the realSendNameForm. The test confirms the full attacker address and irreversible-transfer warning are visible, clicks "Transfer name," and confirms the form supplies the attacker as recipient. - It calls production
transferToken, decodes the request as ERC-1155safeTransferFrom(victim, attacker, tokenId, 1, 0x), broadcasts that exact captured request from the victim on local Anvil, and reads finalownerOf(tokenId)as the attacker.
I expect one passing test. The JSON evidence block shows the honest beforeLiteralRegistration, attackerRootRoles: "0", the EntityBadge-derived copiedRecipientName, attacker-controlled portalResolvedAddress, decodedFunction: "safeTransferFrom", attacker decodedRecipient, a successful broadcastHash, and attacker finalOwner.