91698 w a critical crafted renew url renews an attacker s look alike name while the page displays the victim s own name and on chain expiry
Submitted on Sep 3rd 2026 at 18:27:31 UTC by @SaiyanVegeta for Audit Competition | ENS
- Report ID: #91698
- 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
Bug Description
Brief. /renew/$name and /renew-v1/$name run two different canonicalizers over the same route param and never reconcile them. Ownership, protocol and expiry are resolved with ENSIP-15 normalize() — the victim's node — while the label placed in the renew() calldata comes from parseName, which only does trim().toLowerCase(). A link to the genuine app therefore renders the victim's own name and their real expiry date, and signs a renewal of an attacker-controlled node.
Details (verified at HEAD).
Both renewal routes are affected. The v2 route — the current protocol, and the one demonstrated end-to-end in PoC 3 — is the weaker of the two (see Aggravators); /renew-v1 is affected by the same defect.
Guard / display side — ENSIP-15:
apps/manager/src/routes/renew/$name.tsx:26andapps/manager/src/routes/renew-v1/$name.tsx:21,36callprofileExpiryQuery(name, …)/profileOwnerQuery(name).features/profile/service/profileOwner.ts:25→normalizeEthName(params.name);features/profile/service/profileExpiry.ts:77→normalizeEth2LdName(name).- Both live in
features/profile/service/profileName.ts:18,62and call viem'snormalize(). So the protocol check, the existence/ownership check andcurrentExpiryall resolvenamehash(normalize(name)).
Signed side — trim().toLowerCase():
features/renew/utils/renewableName.ts:25→parseRenewableName→parseName(features/register-v2/utils/name-parser.ts:46).- Its entire canonicalization is
name.trim().toLowerCase(), guarded by a/\s/test, a..test and an ASCII-punctuation blacklist/[&*@#$%^()[\]{}|\\:;"'<>?,=+~!]/. **There is no ENSIP-15 anywhere on this path.**parseName` is a syntax check being used as a canonicalizer.
The loader hands the UI one field from each side:
// routes/renew/$name.tsx:60-63 (routes/renew-v1/$name.tsx:50-53 is identical in this respect)
return {
label: parsedName.value.label, // SIGNED side — the attacker's node
currentExpiry: expiryData.expiry, // GUARD side — the victim's on-chain expiry
}
label flows RenewalPage → RenewalUiProvider → ConfirmPurchase → renewalUi.machine → submitRenewActor → encodeRenewData (packages/transaction-manager/src/machines/registration/registration.actors.ts:1020-1031), which places it verbatim into renew(label, duration, paymentToken, referrer).
Why the invisible character survives. JavaScript's \s class covers - but not U+200B (zero-width space); U+00AD (soft hyphen) and U+FE0F are likewise excluded, and toLowerCase() does not remove any of them. All three pass parseName untouched while normalize() strips them.
Aggravators — the renewability gate does not catch it on either route, and on v2 it embeds the very same divergence.
- v2 (
/renew/$name, the current protocol — weaker): there is no on-chain check on the signed label at all.canRenewV2Name(features/renew/utils/renewableName.ts:40-47) callsisRenewableV2EthName(${parsed.value.label}.eth, expiryDate), and that function (features/grace/utils/gracePeriod.ts:41-55) does exactly two things: a regex shape test/^[^.]+\.eth$/on the raw label — which a zero-width-space label passes, since it contains no dot — and a grace-window check on theexpiryDatethe loader took from the victim's node. The gate therefore validates the attacker's label against the victim's expiry: the divergence is baked into the guard itself. A raw label that does not exist on-chain at all still reaches the signature step. - v1 (
/renew-v1/$name):features/renew/data/queries/v1Renewable.query.ts:19,28does run the real on-chainisRenewable, but onparseRenewableName(name).label— i.e. on the attacker's node, which is renewable precisely because the attacker owns it. The one genuine safety gate is aimed at the wrong name and passes. - The guard that would catch this already exists in the codebase and is simply not wired into these routes.
features/renew/utils/thirdPartyRenewalWarning.tsimplements exactly the needed check — its own test is titled "warns when the connected user is not the owner" — andThirdPartyRenewalDialogconsumes it. But the only call site isfeatures/profile/components/view/ProfileRenewAction.tsx, i.e. the profile page. Neitherroutes/renew/$name.tsxnorroutes/renew-v1/$name.tsxcompares the name's owner against the connected wallet, so a victim arriving from a link never sees the "this is not your name" notice the project already wrote. (Verified: the v2 loader gates only on expiry, protocol, grace window andcanRenewV2Name— no ownership check at all.)
Impact
The victim approves a transaction whose every inspectable field describes their own name — the rendered name, their real on-chain expiry, and the correct price for that tier — while the calldata renews a different node.
- Fee loss (direct, bounded). The victim pays a full renewal for a name they do not own, scaled by the duration they select. The ERC-20 approval is
price + 10%, not unlimited, so this is not a wallet drain. - Asset loss (indirect, larger). The victim's own name is not renewed, and the success screen tells them it was: the "Expires …" line is computed from
currentExpiry(their real expiry) plus the chosen duration, so they receive a false confirmation and have no reason to re-check. The name reaches expiry, passes through grace, and becomes registrable — with the attacker best placed to take it, knowing the exact date. - Attacker gain. The attacker's registration is extended at the victim's expense.
No wallet compromise, no XSS, no cross-origin content: the victim is on the genuine origin the whole time.
Risk Breakdown
This is not the dashboard bulk-renew path — stated first, because the two are easy to conflate and they are mirror images rather than one defect. toBulkRenewName (features/dashboard/components/NamesTable.tsx) already applies ENSIP-15 on the signing side: it signs domain.normalizedName, and any divergence there lives in the display string set beside it. The renew routes do the opposite — they sign the un-normalized parseName label and normalize on the lookup side. There is nothing to transplant from one to the other. The decisive test: one patch does not fix both. Correcting toBulkRenewName + dashboard/utils.ts leaves routes/renew*/$name.tsx returning parseName's label exactly as before; correcting parseName / renewableName.ts leaves the dashboard's display string exactly as before. The input source differs too — two indexer columns versus a URL route param — as does the trigger: bulk renewal requires the attacker to have first moved the twin token into the victim's own dashboard, whereas this needs only a link. And this path carries a harm the dashboard one does not: the page presents the victim's real on-chain expiry, read from the other node, as the expiry of the name being renewed.
- Attacker-controlled trigger. Two steps, no privileged position. (a) Register the non-normalized look-alike label directly against the registrar — permissionless, and confirmed on the real deployed contract (
isAvailable(<twin>) == true, PoC 3). Picking a victim label of ≥5 code points keeps the twin in the same price tier, so the displayed price is the honest one. (b) Send a link to the real origin, e.g.https://app.ens.dev/renew/alice%E2%80%8B.eth, framed as an expiry reminder. Neither route defines aparams.parse, so the raw param reaches the loader unmodified. - No dependency on indexer data. The whole attack is driven by a URL parameter. Nothing has to appear in, or be served by, the indexer.
- Not EXP-INPUT-005. That issue is scoped to display confusion "rather than incorrect resolution". Here the app resolves two different nodes at once — it reads owner/protocol/expiry from
namehash(normalize(name))and signslabelhash(parseName(name))— and funds move. The fix locus is the renew loaders /parseName, not any display component. - Not EXP-INPUT-009 / the retry family. No notification payload and no retry state are involved.
- Distinct from the profile-page resolution issue. That defect lives in
features/profile/service/profileRecords.ts— the records lookup keyed on the raw name while its siblingprofileOwner.tsnormalizes — and its fix is to make those two lookups agree. That patch touches neither renew route and leavesparseNameunchanged. - Distinct fix locus here: apply ENSIP-15 (or assert equality against it) in
features/renew/utils/renewableName.ts/features/register-v2/utils/name-parser.ts, or in the two route loaders. See the mirror-image analysis at the top of this section. - Severity High (honest ceiling). Per-event loss is one renewal fee; the larger harm — the victim's name lapsing — is delayed rather than immediate, and the victim must click the attacker's link and complete the renewal. No connected-wallet tampering and no unlimited approval, so I do not claim Critical.
Recommendation
Make the renew routes derive the signed label from the same canonicalization used for the ownership/expiry lookups:
- In
features/renew/utils/renewableName.ts, apply ENSIP-15 (normalizeEth2LdName) and return that label — or, minimally, assert`${parsed.label}.eth` === normalizeEth2LdName(name)?.nameand reject on mismatch. - Better, fix the root:
parseNameis a syntax validator, not a canonicalizer. Every call site that feeds a transaction should normalize; consider renaming it so the distinction is not re-lost. - Point the v1
isRenewablecheck (v1Renewable.query.ts:28) at the canonical label, and add an existence/renewability check to the v2 route. - Wire the existing
thirdPartyRenewalWarning/ThirdPartyRenewalDialog(today only used byProfileRenewAction.tsx) into both renew routes, so a user arriving at/renew/<name>for a name they do not own is warned. The primitive already exists; only the call site is missing.
References
apps/manager/src/routes/renew/$name.tsx:20,26,52,60-63andapps/manager/src/routes/renew-v1/$name.tsx:15,21,36,50-53apps/manager/src/features/grace/utils/gracePeriod.ts:41-55(isRenewableV2EthName— regex shape test + grace check on the victim's expiry)apps/manager/src/features/renew/utils/renewableName.ts:25apps/manager/src/features/register-v2/utils/name-parser.ts:46(trim().toLowerCase(), no ENSIP-15)apps/manager/src/features/profile/service/profileName.ts:18,62(viemnormalize())apps/manager/src/features/profile/service/profileOwner.ts:25,profileExpiry.ts:77apps/manager/src/features/renew/data/queries/v1Renewable.query.ts:19,28packages/transaction-manager/src/machines/registration/registration.actors.ts:1020-1031(encodeRenewData)
Proof of Concept
Proof of Concept
PoC 1 — the two canonicalizers diverge (vitest, the app's real functions, 5/5 pass)
Drop into apps/manager/src/audit/ and run npx vitest run src/audit/renew-label-divergence.test.ts from apps/manager.
import { ethRegistrarRenewSnippet } from '@ensdomains/ensjs-abi/v2'
import { decodeFunctionData, encodeFunctionData, labelhash, namehash } from 'viem'
import { describe, expect, it } from 'vitest'
import { normalizeEth2LdName } from '@/features/profile/service/profileName'
import { parseRenewableName } from '@/features/renew/utils/renewableName'
const VICTIM_NAME = 'alice.eth'
const VICTIM_LABEL = 'alice'
// exactly what encodeRenewData() does (registration.actors.ts:1020-1031)
const buildRenewCalldata = (label: string) =>
encodeFunctionData({
abi: ethRegistrarRenewSnippet,
functionName: 'renew',
args: [label.replace('.eth', ''), 31557600n,
'0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
'0x0000000000000000000000000000000000000000000000000000000000000000'],
})
const CRAFTED = [
['ZWSP U+200B', 'alice.eth'],
['soft hyphen U+00AD', 'alice.eth'],
['fullwidth uppercase', 'ALICE.eth'],
['variation selector U+FE0F', 'alice️.eth'],
] as const
describe('renew route: guard normalizes, signature does not', () => {
it.each(CRAFTED)('%s — guard resolves the victim, calldata signs another node', (_k, crafted) => {
const parsed = parseRenewableName(crafted)
expect(parsed.isOk()).toBe(true) // the route accepts it
const signedLabel = parsed._unsafeUnwrap().label
const guard = normalizeEth2LdName(crafted)
expect(guard!.name).toBe(VICTIM_NAME) // owner/expiry = the victim's node
expect(guard!.label).toBe(VICTIM_LABEL)
expect(labelhash(signedLabel)).not.toBe(labelhash(VICTIM_LABEL))
expect(namehash(`${signedLabel}.eth`)).not.toBe(namehash(VICTIM_NAME))
const decoded = decodeFunctionData({
abi: ethRegistrarRenewSnippet, data: buildRenewCalldata(signedLabel),
})
expect(decoded.args?.[0]).toBe(signedLabel) // calldata carries the twin
expect(decoded.args?.[0]).not.toBe(VICTIM_LABEL)
})
})
Result: Test Files 1 passed · Tests 5 passed.
PoC 2 — the divergence, standalone against the repo's pinned viem
JS \s matches U+200B? false | U+00AD? false | U+200C? false
victim owns: "alice.eth" labelhash= 0x9c0257114eb9399a
kind | parseName label (SIGNED) | normalize (GUARD/LOOKUP) | same node?
ZWSP U+200B | "alice" 0x2707eeb0 | alice.eth 0x9c025711 | DIFFERENT
soft hyphen U+00AD | "alice" 0x98438388 | alice.eth 0x9c025711 | DIFFERENT
ZWNJ U+200C | label="alice" | GUARD REJECTED (null)
fullwidth uppercase | "alice" 0xb01db196 | alice.eth 0x9c025711 | DIFFERENT
variation sel U+FE0F | "alice️" 0xb9c6c557 | alice.eth 0x9c025711 | DIFFERENT
=> crafted names that pass parseName, resolve to the victim under ENSIP-15,
yet sign a different node: 4 / 5
(The U+200C row is included deliberately: normalize() rejects it outright, so the route errors instead of mis-signing — the guard is not uniformly broken, only unreconciled with the signing path.)
PoC 3 — the exact harm, end-to-end on the real deployed contracts (Sepolia fork)
anvil --silent --port 8545 --chain-id 11155111 --fork-url <any Sepolia RPC>, then node onchain__s1-e2e-fork.mjs (attached). The script embeds byte-exact copies of the app's parseName and normalizeEth2LdName, registers the victim's name and the attacker's twin against the real v2 registrar, and sends the renew() the app would encode for the crafted URL. All writes are served by the local fork; nothing is sent to Sepolia.
crafted URL param : "alice459459.eth" (/renew/alice459459%E2%80%8B.eth)
parseName -> SIGNED : "alice459459" labelhash 0x224934026a
normalize -> GUARD : "alice459459.eth" labelhash 0x15bd36a73a
the two differ : true
guard == victim name : true
registrar isAvailable(twin with U+200B): true
registered "alice459459.eth" -> VICTIM
registered "alice459459.eth" -> ATTACKER (twin)
what the PAGE shows (currentExpiry, read via the GUARD/normalized node): 1790878719
calldata decodes to : renew(label="alice459459", ...)
displayed name was : "alice459459.eth"
=== S1 END-TO-END RESULT (real renew tx on real forked contracts) ===
{
"victim_own_name_expiry_delta": "0",
"attacker_twin_expiry_delta": "2419200", // +28 days
"victim_usdc_spent": "613701"
}
ASSERTIONS:
registrar accepted the non-normalized twin : true
guard/expiry resolved the VICTIM's node : true
signed label != guarded label : true
ATTACKER twin extended by the victim's renewal : true
VICTIM's own name NOT renewed : true
VICTIM paid for it : true
>>> S1 HARM REPRODUCED END-TO-END <<<
Every link is demonstrated rather than inferred: the registrar accepts the non-normalized label, the guard resolves the victim's node (so the page shows the victim's own expiry), the calldata carries the twin, and on-chain the attacker's registration gains the paid duration while the victim's gains nothing and the victim's balance falls.