Summary
Published Immunefi report #89456, the Chief finding in its group (IMM-WA-CRITICAL-89456, pp. 27-28 of the official Immunefi / ENS Audit Competition Report PDF).
The ENS Manager renewal route (/renew/$name) resolves the name in the URL through two separate pipelines that normalize differently:
- Displayed state (
normalizeEth2LdName): Everything shown to the user on the renewal screen (the name, current expiry, new expiry, ownership, and renewal price) is fetched usingviem/ensnormalize(), which applies ENSIP-15 / UTS-46 normalization. - Signed transaction payload (
parseRenewableName): The label encoded into the on-chainrenew(cleanLabel, duration, paymentToken, REFERER_ADDRESS)call comes from a custom parser that runs.trim().toLowerCase()and checks a fixed punctuation list without applying ENSIP-15.
For any label containing a code point that ENSIP-15 strips (such as U+200B zero-width space), the two pipelines diverge on the same URL. The page displays and prices the victim’s real .eth name and real expiry, while the transaction the victim signs renews a different raw label controlled by an attacker.
Where the two pipelines diverge
In the route loader apps/manager/src/routes/renew/$name.tsx:
- Line 20:
parseRenewableName(name)producesparsedName.value.label, which is later passed into the renewal transaction. - Lines 26-28:
profileExpiryQuery(name)fetches the expiry displayed on the page. - Line 51:
canRenewV2Name(name, expiryDate)gates the route using that expiry. - Lines 58-61: the loader returns
parsedName.value.labelalongsideexpiryData.expiry.
Path A (signed label): parseRenewableName in apps/manager/src/features/renew/utils/renewableName.ts delegates to parseName in apps/manager/src/features/register-v2/utils/name-parser.ts. At line 46 it runs name.trim().toLowerCase(), at line 49 it rejects /\s/, and at line 11 it rejects a fixed punctuation set. U+200B (zero-width space) is not matched by /\s/ and is not in the punctuation list, so it survives into label.
Path B (displayed data): profileExpiry.ts line 77 calls normalizeEth2LdName(name) (profileName.ts line 19, backed by normalize from viem/ens), which strips U+200B under ENSIP-15 before querying expiry and ownership.
The unnormalized label then reaches the contract call unchanged in packages/transaction-manager/src/machines/registration/registration.actors.ts (lines 1020-1031):
// registration.actors.ts:1020-1031
// cleanLabel is the loader's raw label with only ".eth" stripped
renew(cleanLabel, duration, paymentToken, REFERER_ADDRESS)
Line 1089 builds the wallet transaction description from the same raw label, and PricingDomainHeader.tsx renders it in the header, where U+200B has zero width and is invisible both in the web UI and in the wallet confirmation prompt.
Proof of concept
Running the shipped parsers and route loader against vitalik\u200b.eth demonstrates the split within a single request:
import { describe, expect, it } from 'vitest'
import { canRenewV2Name, parseRenewableName } from '@/features/renew/utils/renewableName'
import { normalizeEth2LdName } from '@/features/profile/service/profileName'
const ZWSP = String.fromCharCode(0x200B)
const VICTIM = 'vitalik.eth'
const CRAFTED = `vitalik${ZWSP}.eth`
describe('renewal renders one name and signs another', () => {
it('PATH A: the label passed to renew() keeps the invisible character', () => {
const parsed = parseRenewableName(CRAFTED)
expect(parsed.isOk()).toBe(true)
expect(parsed._unsafeUnwrap().label).toBe(`vitalik${ZWSP}`)
})
it('PATH B: the name used to look up expiry drops the invisible character', () => {
const normalised = normalizeEth2LdName(CRAFTED)
expect(normalised?.name).toBe(VICTIM)
})
it('one URL resolves to two different names in the same request', () => {
const signedLabel = parseRenewableName(CRAFTED)._unsafeUnwrap().label
const displayedName = normalizeEth2LdName(CRAFTED)?.name
expect(displayedName).toBe(VICTIM) // what the page shows and prices
expect(`${signedLabel}.eth`).toBe(CRAFTED) // what the wallet actually signs
})
})
Impact
An attacker can send a single crafted renewal link that renders the victim’s genuine ENS name, ownership, and expiry date, while the resulting transaction pays to extend a different label. The victim loses the renewal fee and their actual domain remains unrenewed.
Recommended fix
Derive the transaction label from the same ENSIP-15 normalized name that produces the displayed expiry and ownership data (normalizeEth2LdName(name)), or reject/redirect any /renew/$name request where the raw route parameter differs from its ENSIP-15 canonical form.
All published ENS competition reports by @Tradi3
Across the 27-day ENS Audit Competition (137,845 lines of TypeScript across apps/manager, apps/portal, workers, packages/transaction-manager, and packages/smart-account at commit 1c9b47f18fcddd2e864dfe385c4171061c9811ae), 20 individual reports authored by @Tradi3 are published in the Immunefi ENS Findings Index alongside the official competition report PDF: