92914 w a critical missing ensip 15 normalization check on the primary name render path lets an attacker s address be displayed as another party s name
Submitted on Sep 13th 2026 at 07:44:19 UTC by @Magnum for Audit Competition | ENS
- Report ID: #92914
- 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
The Explorer renders the ENS name of an arbitrary address without ever checking that the name is ENSIP-15 normalized. That name is read from a reverse registrar, where its value is written by the owner of the address being displayed, so it is attacker-authored input. An attacker can therefore register a visual twin of a well-known name, point it at their own address, and have the application present their address to every other user as that name. In three of the nine render sites the name replaces the address entirely, so the viewer has nothing left to compare against. In production this turns any Explorer surface that lists addresses — transaction history, the profile owner field, the events table, the transfer recipient preview, the registry role sheet — into an identity-spoofing surface, where a user approves a name transfer, grants a role, or trusts a counterparty on the strength of a name the attacker authored.
Vulnerability Details
The write side accepts an un-normalized name
packages/l2-primary/src/utils/setReverseName.ts::createSetReverseNameRequest passes the caller's string directly into the contract call:
if (targetAddress) {
return {
address: registrarAddress,
abi: l2ReverseRegistrarSetNameForAddrSnippet,
functionName: 'setNameForAddr',
args: [targetAddress, name] as const,
}
}
return {
address: registrarAddress,
abi: l2ReverseRegistrarSetNameSnippet,
functionName: 'setName',
args: [name] as const,
}
There is no normalization and no validation. The same is true of writing an L1 reverse record directly on-chain, which requires no application involvement at all.
The read side verifies ownership but not normalization
apps/portal/src/features/profile/hooks/usePrimaryName.ts:
- line 40 — ensjs
getNameperforms the L1 default reverse (coin 60); the result is accepted whenmatch === true(line 43) - line 76 — the ENSIP-19 fallback reads
nameForAddr(address)from each L2 reverse registrar inL2_FALLBACK_COIN_TYPES = [10, 42161, 8453, 59144, 534352], then forward-verifies againstaddr(name, coinType)and returns on a match (line 95)
Both branches return the registrar string exactly as read. isNormalized is never called.
Forward verification is not a defence here and this report does not claim it is bypassed. The attacker genuinely owns the homograph name and sets its address record to their own address, so ENSIP-19 verification passes correctly. The missing step is the normalization check after verification.
The same applies to wagmi's useEnsName: viem's getEnsName calls reverse(bytes) on the UniversalResolver and returns the name verbatim. The module contains no reference to normalization.
The render side prints it, and often instead of the address
apps/portal/src/components/table/EventsDataTable/AddressDisplay.tsx:29-30:
const displayName = ensName || (short ? truncateAddress(address) : address)
const variant = ensName ? 'name' : variantProp
When a name exists the address is not rendered at all. The second line is worth attention on its own: the badge relabels the entity from Address to Name, so the application is not merely printing a string, it is asserting that the value is a name. EntityBadge applies no normalization either.
The check exists in this application and is not applied here
apps/portal/src/utils/token/isNormalized.ts:
export const isNormalized = (name: string) =>
name.split('.').every((label) =>
isEncodedLabelhash(label) || isNormalizedLabel(label))
Its doc comment is explicitly about display contexts: encoded labelhashes are accepted because such names "are real, registered, resolvable on-chain names and must not be rejected as invalid". It has a dedicated test file, isNormalized.test.ts.
A sweep over apps/portal/src, excluding tests and the helper's own module, gives:
isNormalizedcall sites: 1 —routes/$name/token.tsx:290- chain-sourced name render sites: 9
routes/addr/$addr/index.tsx:39 useEnsName components/WalletMenu.tsx:23 getPrimaryNameQueryOptions components/table/EventsDataTable/AddressDisplay.tsx:17 useEnsName features/transfer/hooks/useTransferName.ts:95 getPrimaryNameQueryOptions features/transfer/components/SendNameForm.tsx:255 getPrimaryNameQueryOptions features/history/components/AccountBadge.tsx:52 useEnsName features/dashboard/components/DashboardProfilePreview.tsx:18 useEnsName features/profile/components/Owner.tsx:26 useEnsName features/registry/components/v2/RegistryEditUserSheet.tsx:69 useEnsName
The helper's siblings isValidEnsName and getLabelRegistrationError are used in buildSearchSuggestions.ts, isNameOrAddress.ts, nameValidation.ts, routes/$name.tsx, routes/$name/index.tsx and routes/$name/create-subname.tsx — every one of them a user-input path. The application validates what the user types and never validates what it renders from chain data.
Relationship to EXP-INPUT-005
The published known-issues document lists EXP-INPUT-005 (Medium): name validators accept homoglyphs, bidirectional-control and other control characters, with the stated impact being "homograph-style display confusion in our UI rather than incorrect resolution". That entry concerns the permissiveness of input validators.
This report is the mirror defect on the other side of the boundary: the render path performs no normalization check at all on chain-sourced, third-party-authored names, while the helper written for exactly that purpose exists in the same application, is unit-tested, and is wired to one unrelated route. The root cause is not "the validator is too permissive" but "the render path has no validator", and the measured ratio is 1 call site against 9 render sites.
QA-06 (a deleted or non-resolving subname still shown as primary) is a stale-state defect with a different cause. WEB-1301 concerns which fields the address view sources from a primary name, not the trustworthiness of the name itself. No published entry addresses normalization on display.
Impact Details
Selected impact: causing the application to display or pre-fill an incorrect name so that a user submits a transaction to an unintended recipient — specifically "rendering an ENS name to an attacker-controlled address".
Attack sequence:
- The attacker registers a visual twin of a well-known name. On the local Sepolia fork the registrar reports
aliceas taken and the fullwidth variant as available, so the twin is obtainable. - The attacker sets the twin's address record to their own address, which makes forward verification succeed.
- The attacker sets their reverse record to the twin, on L1 or via the Explorer's own L2-primary feature.
- Every Explorer surface listing that address now renders the twin.
Concrete losses, by surface:
- Name transfer (
SendNameForm): the recipient preview shows the attacker's address labelled with a name the victim reads as a party they trust. An ERC-1155 name transfer is irreversible; the loss is the full value of the transferred name. - Registry roles (
RegistryEditUserSheet): role grants are made against a list of accounts identified by name. GrantingROLE_SET_RESOLVERorROLE_SET_SUBREGISTRYto a misidentified account hands control of resolution for that name to the attacker. - Events table, profile owner field, history badge, dashboard preview (
AddressDisplay,Owner,AccountBadge,DashboardProfilePreview): the address is not rendered at all, and the badge asserts the value is aName. A user reading provenance or ownership is given a false identity with no fallback to verify against.
No privileged position is required. The attacker needs only to own a name and control their own address. The cost of the attack is one name registration.
References
apps/portal/src/features/profile/hooks/usePrimaryName.ts— lines 40, 43, 76, 95apps/portal/src/components/table/EventsDataTable/AddressDisplay.tsx— lines 17, 29, 30apps/portal/src/utils/token/isNormalized.tsandisNormalized.test.tsapps/portal/src/routes/$name/token.tsx:290— the sole call site ofisNormalizedpackages/l2-primary/src/utils/setReverseName.ts—createSetReverseNameRequestnode_modules/.pnpm/viem@*/node_modules/viem/_esm/actions/ens/getEnsName.js— no normalization- ENSIP-15 (name normalization), ENSIP-19 (L2 primary names)
- Tested at
1c9b47f, HEAD ofaudit-comp-ready
Link to Proof of Concept
https://gist.github.com/Magnat79A/3a92ff436ab911a8355ff790803c8c03
Proof of Concept
Overview
Two runnable test files, both in the repository's own test harness, no network required. The first proves the check is absent; the second renders the real component and shows what a viewer sees. A third layer confirms on-chain that the visual twin is obtainable.
All results below are from a clean run at revision 1c9b47f.
Step 1 — Setup
- Clone the scope repository and check out
audit-comp-ready pnpm install --frozen-lockfile- Copy the two test files (from the linked gist) to:
apps/portal/src/features/profile/hooks/poc-c05-primary-name.test.tsapps/portal/src/components/table/EventsDataTable/poc-c05-render.test.tsx
Step 2 — The check is absent
Run: pnpm --filter portal test poc-c05-primary-name
Result: 5 passed (5)
Assertions:
- The application's own display-context helper rejects the twin.
isNormalized("alice.eth")-> true,isNormalized("alice.eth")-> false - The write side forwards an un-normalized name to the registrar.
createSetReverseNameRequest({ name: "alice.eth", reverseRegistrarChainId: 10, targetAddress })-> the returnedargscontain"alice.eth"unchanged - The read side returns it unflagged. ensjs
getNameis mocked to{ name: "alice.eth", match: true }, i.e. a reverse record that forward-verifies because the attacker owns the name. ->getPrimaryNameQueryOptions(attackerAddress).queryFn()returns"alice.eth" - What the UI receives is not a normalized name.
isNormalized(displayed)-> false;displayed.normalize("NFKC")->"alice.eth"
Printed output:
[ { "displayedToVictim": "alice.eth", "looksLike": "alice.eth", "isNormalized": false, "nfkcFoldsTo": "alice.eth", "rendersIdentically": true } ]
Mocking note: three of the five assertions are pure — no mocks at all. The one mocked assertion stubs only the module boundary (safeGetClient and ensjs getName); getPrimaryName itself executes unmodified.
Step 3 — The consequence, in the real component's DOM
Run: pnpm --filter portal test poc-c05-render
Result: 4 passed (4)
AddressDisplay is rendered unmodified. wagmi is mocked partially via importOriginal, so src/lib/wagmi.ts still builds the real config and only useEnsName is swapped — it stands in for the on-chain reverse read. Router, react-query and NameAvatar are stubbed exactly as the repository's own src/components/EntityBadge.test.tsx does, so the tree mounts without providers or network.
Assertions:
- The attacker-authored string is present in the DOM.
- The address is absent — neither the full address nor its truncated form appears.
- The rendered text NFKC-folds to the real, well-known name.
- Control: with no reverse record the address IS shown. The fallback a viewer would otherwise rely on is suppressed precisely when an attacker acts.
Printed output:
{ "attackerAddress": "0x00000000000000000000000000000000000000a1", "reverseRecordWrittenByAttacker": "alice.eth", "renderedWithoutReverseRecord": "WalletAddressCopyCopy0x0000…00a1", "renderedWithReverseRecord": "NameNameCopyCopyalice.eth", "nfkcOfRendered": "NameNameCopyCopyalice.eth", "appsOwnCheckWouldReject": true, "addressVisibleToViewer": false }
Note the badge label across the two rows. Without a reverse record the component renders as Wallet / Address and shows the truncated address. With one it renders as Name and shows only the attacker-authored string. This is AddressDisplay.tsx:30:
const variant = ensName ? 'name' : variantProp
The application relabels the entity as a name, which is an explicit identity claim about a value the attacker wrote.
Step 4 — The visual twin is obtainable on-chain
Bring up the repository's own local stack: pnpm e2e:infra:up This is an Anvil fork of Sepolia at chain id 11155111, so contract addresses match the deployed ones.
cast call 0xa88553F454b77203B0D036A05c894d555EAAa2Cc
"isAvailable(string)(bool)" "alice" --rpc-url http://127.0.0.1:8545 -> false
cast call 0xa88553F454b77203B0D036A05c894d555EAAa2Cc
"isAvailable(string)(bool)" $'\uff41lice' --rpc-url http://127.0.0.1:8545 -> true
The real name is registered; its visual twin is free. The twin can equally be registered by calling the registrar directly, so no application involvement is required for this step.
Step 5 — Measure the radius
From the repository root:
grep -rn "\bisNormalized(" --include=.ts --include=.tsx apps/portal/src | grep -v "isNormalized.ts|.test." -> 1 result: apps/portal/src/routes/$name/token.tsx:290
grep -rnE "useEnsName(|getPrimaryNameQueryOptions(" --include=.tsx --include=.ts apps/portal/src | grep -v ".test.|usePrimaryName.ts" -> 9 results, listed in the Description
Step 6 — Confirm the dependency does not normalize
grep -rn "normalize" node_modules/.pnpm/viem@*/node_modules/viem/_esm/actions/ens/getEnsName.js -> no results
viem calls reverse(bytes) on the UniversalResolver and returns the result verbatim, which is why the useEnsName consumers are affected and not only the usePrimaryName path.
Full-chain reproduction
An optional shell script running all of the above in sequence is in the linked gist (c05-full-chain.sh), producing the log 08-c05-chain.log. It walks: entry (twin obtainable) -> path (write unnormalized) -> exit (read unchecked) -> radius (1 vs 9) -> dependency -> both test suites.
Artifacts (secret gist, linked above)
- poc-c05-primary-name.test.ts
- poc-c05-render.test.tsx
- 07-c05.txt — output of step 2, md5 14b128000a518557996ed1bd7f0770bb
- 09-c05-render.txt — output of step 3, md5 748fd148a2e826331e6ea21ee7b155cd
- 08-c05-chain.log — full chain run, md5 edfa9b188e0287e705864e0009a752e7
Counter-arguments, stated up front
- "Duplicate of EXP-INPUT-005." Different side of the boundary: input validation versus render validation. The distinguishing evidence is the 1-versus-9 ratio and the existence of an unused, unit-tested display-context helper.
- "The viewer can see the address." True only for
SendNameForm. Step 3 shows that inAddressDisplaythe address is not rendered at all once a reverse record exists, and the badge relabels itselfName. - "Forward verification protects this." It does not, and this report does not claim it is bypassed. The attacker owns the name; ENSIP-19 passes correctly. The missing step is the normalization check after it.
- "The contracts should reject such names." ENS registrars are byte-transparent by design; normalization is a client responsibility, which is why this repository ships
@adraffy/ens-normalizeand applies it on input paths.