tradi3_ Kurt Landman
Public report archive

ENS T3-011

Renewal route prices and displays the normalized name but signs the raw label

The ENS renewal route derives displayed ownership, expiry, and pricing from the ENSIP-15 normalized name while encoding the unnormalized URL label into renew(), letting a crafted link with an invisible character renew a lookalike name using the victim's funds.

Severity
Critical
Outcome
paid
Competition
Audit Competition | ENS
Official report ID
IMM-WA-CRITICAL-89456
Published

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:

  1. Displayed state (normalizeEth2LdName): Everything shown to the user on the renewal screen (the name, current expiry, new expiry, ownership, and renewal price) is fetched using viem/ens normalize(), which applies ENSIP-15 / UTS-46 normalization.
  2. Signed transaction payload (parseRenewableName): The label encoded into the on-chain renew(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) produces parsedName.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.label alongside expiryData.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.

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:

Report IDSeverityCanonical Finding in PDFPublished Report on Immunefi
#89456Critical (Chief)IMM-WA-CRITICAL-89456 (pp. 27-28)Renewal route prices and displays the normalized name but signs the raw label
#92632CriticalIMM-WA-CRITICAL-93081 (p. 9)Resolver role row with unresolvable scope is saved as a root grant over every name on the resolver
#92626CriticalIMM-WA-CRITICAL-90190 (pp. 33-34)Manager bulk renewal renders raw indexer name on every row and signs the normalized label
#92565CriticalIMM-WA-CRITICAL-90094 (pp. 31-32)Explorer Extend flow renders raw indexer name on every surface but signs ENSIP-15 normalized label
#89960CriticalIMM-WA-CRITICAL-89278 (pp. 19-20)Raw-name ownership check can transfer a different normalized ENSv2 token
#92588CriticalIMM-WA-CRITICAL-89278 (pp. 19-20)Transfer ownership signs the token ID of getLabel(name) while every screen shows the raw route param
#92572CriticalIMM-WA-CRITICAL-89601 (pp. 29-30)ENSv2 registration applies no ENSIP-15 normalization at any step
#89611CriticalIMM-WA-CRITICAL-89253 (p. 8)Resolver Remove user revokes only one name and leaves global address-upgrade authority active
#91095CriticalIMM-WA-CRITICAL-89253 (p. 8)Portal Remove user from all roles sends a one-name revoke and leaves root resolver authority usable
#92352CriticalIMM-WA-CRITICAL-89253 (p. 8)Portal says it removed all roles but sends a one-name revoke
#92591CriticalIMM-WA-CRITICAL-89456 (pp. 27-28)Manager reads profile with ENSIP-15 and writes renewal with toLowerCase
#92568CriticalIMM-WA-CRITICAL-89270 (pp. 17-18)Profile editing authorizes the normalized name but encodes the raw route param
#92570CriticalIMM-WA-CRITICAL-90544 (p. 8)Set Primary Name writes forward legs against raw name and signs reverse leg against normalized name
#93111CriticalIMM-WA-MED-91314 (p. 10)ENS shows victim as owner of attacker’s v1 name via unconsented setOwner, suppressing renewal warning
#93001HighIMM-WA-HIGH-91441 (p. 9)Any contract can write persistent fabricated rows into any ENS name’s History page
#89616MediumIMM-WA-MED-89249 (p. 10)Migration restores a stale former ENSv1 manager, allowing post-reclaim resolver hijacking
#92341MediumIMM-WA-MED-89249 (p. 10)Manager restores v2 resolver authority to a v1 manager removed before confirmation
#92578MediumIMM-WA-MED-89254 (p. 10)Notification channel state is read and written by target address alone with no user ID predicate
#92648Medium—Explorer certifies a resolver as official from a storage word any contract can forge
#92635InsightIMM-WA-MED-93007 (p. 10)Explorer offers Set Primary Name based on an attacker-controlled record without checking ownership