92635 w a insight the explorer offers set primary name based on a record the attacker controls and never checks name ownership so one click on a link replaces the victim s primary name with a
Submitted on Sep 11th 2026 at 05:31:49 UTC by @Tradi3 for Audit Competition | ENS
- Report ID: #92635
- Report Type: Websites & Apps
- Report severity: Insight
- 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
- Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters Substituting contract addresses Submitting malicious transactions Signing or submitting a transaction against the wrong chain (for example when the request's target chain is not validated) Inducing a blanket token approval (for example setApprovalForAll on the registry or NameWrapper) to an attacker-controlled address
Description
Summary
The Explorer's /$name/address page offers a "Set primary name" button whenever the name's own addr(coinType) record happens to equal the connected wallet. It never checks that the connected wallet owns the name.
That record belongs to the name, not to the viewer — so an attacker who owns any name can point its address record at a chosen victim and thereby cause the button to appear, on their own name's page, for that victim. One click writes the attacker's name into the victim's ENSIP-19 reverse record, destroying whatever primary name the victim had.
No homoglyph, no Unicode and no unnormalized name is involved. The attacker's name is an ordinary, fully-normalized .eth name.
The gate
apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx:453-458
// Offered only when the connected wallet *is* this address (setName is
// msg.sender-scoped).
const canSetPrimaryName =
!!connectedAddress &&
!!data?.address &&
isAddress(data.address, { strict: false }) &&
isAddressEqual(connectedAddress, data.address)
The comment states the reasoning exactly: they verified that you can only write your own reverse record, because setName is msg.sender-scoped. That is true and irrelevant. The question they did not ask is whether you should be allowed to point your own reverse record at a name you do not own, on the strength of a record that name's owner controls.
data.address is chosen by the name's owner, not the viewer
apps/portal/src/routes/$name/address.tsx:104-108
const addressByCoinType = new Map(
(profileQuery.data?.records?.coins ?? []).map(
(c) => [c.coinType, c.value] as const,
),
)
Those are the addr(coinType) records read from the name's resolver. Whoever owns the name writes them. So the value the gate compares connectedAddress against is entirely under the attacker's control, and setting it is an ordinary record write on their own name.
The button appears precisely because the pair is broken
AddressResolutionSidebar.tsx:351
const offerSetPrimary = status === 'mismatch' && canSetPrimaryName
status comes from the ENSIP-19 reverse check. In this scenario it is guaranteed to be mismatch, because the victim's reverse record does not name the attacker's name yet — which is the whole point of the attack. The page therefore renders a red warning that the address "does not resolve back to this name", and puts the remediation button directly under it. The UI presents adopting the attacker's name as fixing a problem.
The calldata
AddressResolutionSidebar.tsx:531-553 — both L1 branches:
if (data?.coinType === MAINNET_COIN_TYPE) {
const registrarAddress = getRegistrarAddress(60, 'sepolia')
if (!registrarAddress) return
setReverseResolution({
name,
request: {
address: registrarAddress,
abi: reverseRegistrarSetNameSnippet,
functionName: 'setName',
args: [name],
},
})
return
}
setReverseResolution({
name,
request: {
address: DEFAULT_REVERSE_REGISTRAR_ADDRESS,
abi: defaultReverseRegistrarSetNameSnippet,
functionName: 'setName',
args: [name],
},
})
name is the raw /$name/address route param — the attacker's name, straight from the link the victim followed.
The check exists, is already loaded, and is thrown away
AddressResolutionSidebar.tsx:431
const { data: owner } = useQuery(getEnsOwnerQueryOptions({ name }))
owner is referenced exactly once in the entire file — :619:
protocolVersion: owner?.protocolVersion,
The component resolves the name's owner and uses only its protocol version. The owning address is never compared to connectedAddress. The data needed to block this is sitting in the component, one isAddressEqual away.
Attack sequence
- Attacker registers an ordinary name, e.g.
freebie.eth, and sets itsaddr(60)record to the victim's address. This is a normal write on a name they own. - Attacker sends the victim a link to
/freebie.eth/address— a plain Explorer URL on ENS's own domain. - The victim opens it with their wallet connected.
canSetPrimaryNamepasses (the record names them),statusismismatch(their reverse record does not namefreebie.eth), so the red banner and the Set primary name button render. - The victim clicks once.
setName("freebie.eth")executes against their own reverse registrar. - The victim's primary name is now
freebie.eth, a name the attacker owns outright.
Impact
Unconditional, on the click: the victim's existing primary name is overwritten and their on-chain identity becomes a name the attacker owns and fully controls — its resolver, its records, its avatar — and which the attacker can transfer or let expire at will. Every ENS-aware wallet, explorer and dapp that reads the victim's primary name renders the attacker's chosen string, and it forward-verifies while the attacker keeps the address record pointed at the victim.
Escalation, requiring one further attacker step: the attacker later repoints freebie.eth's addr(60) at their own address. Anyone who has seen the victim identified as freebie.eth and resolves that name — to pay them, to whitelist them, to address them — now resolves it to the attacker. That is the row's own example, "resolving or rendering an ENS name to an attacker-controlled address", reached from a name the victim was induced to adopt as their identity.
I want to be precise about the boundary: step 5 is unconditional once the victim clicks, and needs nothing beyond the link. The funds-misdirection consequence needs the attacker to repoint the record afterwards, which costs them one transaction and nothing else.
Preconditions: the victim has a connected wallet and follows a link. There is no requirement that they own any particular name, hold any role, or have any prior relationship with the attacker.
Severity
Critical. The transaction the victim approves is presented as repairing their own configuration and instead adopts an attacker-controlled identity; the downstream effect is the row's stated example of rendering a name to an attacker-controlled address so funds reach an unintended recipient. It is also a malicious interaction with an already-connected wallet: the parameter written on chain is a name chosen by the attacker and supplied through a URL.
Why this is not an already-known or already-reported issue
Not a normalization issue. This report does not rely on a homoglyph, a soft hyphen, mixed case, or any unnormalized input. The attacker's name is an ordinary normalized .eth name, and the attack works identically whatever it is called. The published known issue about validators accepting homoglyphs and control characters is not engaged.
Not in the published known issues. There is no entry covering the primary-name write path being unauthorized. The nearest entries are the opposite failure — a user being unable to set a primary name after a transfer or a role change (QA-01, WEB-1257) — and a display-metadata note on the address view (WEB-1301). Part 2's own preamble draws the line this report sits on: a demonstrated security consequence "rather than merely erroring" is a new finding.
Distinct from the raw-vs-normalized primary-name reports. Those concern the Manager's set-primary flow writing its forward legs against one form of a name and its reverse leg against another. This is the Explorer, a different app and a different code path, and the defect is an authorization gap — a missing ownership check — not a mismatch between two spellings of the same name. Removing the normalization question entirely leaves this bug fully intact.
Recommended fix
Gate the button on ownership, using the query the component already runs:
const connectedOwnsName =
!!connectedAddress && !!owner?.owner && isAddressEqual(connectedAddress, owner.owner)
const canSetPrimaryName =
connectedOwnsName &&
!!data?.address &&
isAddress(data.address, { strict: false }) &&
isAddressEqual(connectedAddress, data.address)
The existing address comparison should stay — it is correct for what it was written for — but it is a check that the name points at you, and it must not be mistaken for a check that you control the name. The sibling reverse-resolution table already restricts its sidebar to the connected account (ReverseResolutionTable.tsx:64, isAddressEqual(account, address)); this surface has no equivalent restriction.
Failing that, the confirmation copy must state plainly that the victim is adopting a name owned by someone else, and name that owner.
Proof of Concept
What this proves
The test executes the gate expression from AddressResolutionSidebar.tsx:453-458 verbatim, against viem's real isAddress / isAddressEqual, and encodes the setName calldata with the same ABI snippet the component imports (@ensdomains/ensjs-abi/reverseRegistrar). Five assertions:
- the gate passes on a name the wallet does not own — with the attacker's
addr(60)pointed at the victim,canSetPrimaryName(victim, row)istrueon the attacker's own name's page; - the button is offered —
offerSetPrimary('mismatch', victim, row)istrue, and the three non-mismatch states arefalse, confirming the button appears precisely in the state the attack creates; - the calldata decodes back to
setName("freebie.eth")— the attacker's name — and the victim's real primary name appears nowhere in the transaction that overwrites it; - the missing check —
owner !== connectedAddresswould have blocked it, while the gate that does exist passes; - control — the gate correctly refuses on a non-matching, absent, or malformed record, so it is not trivially always-true.
Environment
Frozen revision under test: 1c9b47f. Nothing is deployed and no live service is contacted.
pnpm install
Save the file below as:
apps/portal/src/features/forward-resolution/poc-set-primary-no-ownership-check.test.ts
Run it:
cd apps/portal
pnpm vitest run src/features/forward-resolution/poc-set-primary-no-ownership-check.test.ts
Result
RUN v4.1.5 .../apps/portal
[gate] canSetPrimaryName(victim, addr=victim) === true on the ATTACKER’s name
[offer] status=mismatch + gate -> red warning + "Set primary name" button
[calldata] setName("freebie.eth") — overwrites the victim’s "alice.eth"
[missing] owner=attacker !== connected=victim — one comparison away
[control] gate refuses on a non-matching / absent / malformed record
Test Files 1 passed (1)
Tests 5 passed (5)
Reproducing in the UI
- From any wallet, register an ordinary
.ethname (freebie.eth) and set its ETH address record to the victim’s address. Both are normal operations on a name you own. - Send the victim
https://<explorer>/freebie.eth/address. - With their wallet connected, the victim sees the red banner “The set address does not resolve back to this name on Mainnet” and a Set primary name button beneath it.
- One click. The wallet prompts for
setNameon the reverse registrar; the modal describes it asSet freebie.eth as the primary name. - After confirmation the victim’s primary name is
freebie.eth. Their previous primary name is gone.
Verifying the call sites
grep -n "canSetPrimaryName" -A 6 apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx
grep -n "offerSetPrimary" apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx
grep -n "args: [name]" apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx
grep -n "getEnsOwnerQueryOptions" apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx
grep -n "owner" apps/portal/src/features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx | grep -v connectedAddress
grep -n "addressByCoinType" -A 4 apps/portal/src/routes/$name/address.tsx
The second-to-last command returns exactly two lines — the fetch at :431 and protocolVersion: owner?.protocolVersion at :619 — which is the whole of the ownership data’s use.
Full test file
/**
- PoC — the Explorer's "Set primary name" button is gated on a record the
- ATTACKER controls, never on owning the name. One click on an attacker's link
- replaces the victim's ENSIP-19 primary name with a name the attacker owns.
- THE GATE —
features/forward-resolution/components/AddressResolution/AddressResolutionSidebar.tsx:453-458: - // Offered only when the connected wallet is this address (setName is
- // msg.sender-scoped).
- const canSetPrimaryName =
-
!!connectedAddress && -
!!data?.address && -
isAddress(data.address, { strict: false }) && -
isAddressEqual(connectedAddress, data.address) - The comment shows the reasoning: they confirmed you can only write YOUR OWN
- reverse record. They never asked whether you should be allowed to point it at
- a name you do not own.
data.addressis NOT the viewer's property. It is the name's ownaddr(coinType)record, read from that name's resolver —routes/$name/address.tsx:104-108:- const addressByCoinType = new Map(
-
(profileQuery.data?.records?.coins ?? []).map( -
(c) => [c.coinType, c.value] as const, -
), - )
- — so whoever owns the name chooses the value the gate compares against.
- THE BUTTON —
AddressResolutionSidebar.tsx:351: - const offerSetPrimary = status === 'mismatch' && canSetPrimaryName
status === 'mismatch'is guaranteed in the attack: the victim's reverse- record does not yet name the attacker's name, which is exactly what the red
- "does not resolve back to this name" banner reports. The remediation button
- is rendered directly beneath it.
- THE CALLDATA —
AddressResolutionSidebar.tsx:531-553, both L1 branches: - args: [name],
nameis the raw/$name/addressroute param — the attacker's name.- THE DISCARDED CHECK. The component already fetches the answer and throws it
- away.
AddressResolutionSidebar.tsx:431: - const { data: owner } = useQuery(getEnsOwnerQueryOptions({ name }))
- and
owneris referenced exactly once in the whole file, at:619: - protocolVersion: owner?.protocolVersion,
- The owning address is never compared to
connectedAddress. - NOTE ON SCOPE: this needs no homoglyph, no Unicode and no unnormalized name.
- The attacker's name is an ordinary, fully-normalized
.ethname. The - published known issue about validators accepting homoglyphs is not involved.
- This test executes the REAL gate expression (viem
isAddress/isAddressEqual) - and encodes the REAL
setNamecalldata with the same ABI snippet the - component imports. */ import { reverseRegistrarSetNameSnippet } from '@ensdomains/ensjs-abi/reverseRegistrar' import { describe, expect, it } from 'vitest' import { type Address, decodeFunctionData, encodeFunctionData, getAddress, isAddress, isAddressEqual, } from 'viem'
// happy-dom swallows console.log under vitest; write to stdout directly. const log = (m: string) => process.stdout.write(m + String.fromCharCode(10))
const VICTIM = getAddress('0x5fa3a46a6b7f4e374e9671b9dd946d80958c9e47') const ATTACKER = getAddress('0x1c7d4b196cb0c7b01d743fbc6116a902379c7238')
/** An ordinary, fully-normalized name the ATTACKER owns. / const ATTACKER_NAME = 'freebie.eth' /* The name the victim actually owns and currently has as their primary. */ const VICTIM_REAL_PRIMARY = 'alice.eth'
/** AddressResolutionSidebar.tsx:453-458, reproduced verbatim. */ const canSetPrimaryName = ( connectedAddress: Address | undefined, data: { address: string | null } | null, ) => !!connectedAddress && !!data?.address && isAddress(data.address, { strict: false }) && isAddressEqual(connectedAddress, data.address as Address)
/** AddressResolutionSidebar.tsx:351, reproduced verbatim. */ const offerSetPrimary = ( status: string, connectedAddress: Address | undefined, data: { address: string | null } | null, ) => status === 'mismatch' && canSetPrimaryName(connectedAddress, data)
/** AddressResolutionSidebar.tsx:534-541 — the Mainnet-row branch. */ const buildSetNameCalldata = (name: string) => encodeFunctionData({ abi: reverseRegistrarSetNameSnippet, functionName: 'setName', args: [name], })
describe('Set primary name is gated on an attacker-controlled record', () => { it('the gate passes on a name the connected wallet does NOT own', () => { // The attacker owns freebie.eth and points its addr(60) at the VICTIM. // This is the only setup step, and it is a normal record write on their // own name. const row = { address: VICTIM as string }
// The victim opens /freebie.eth/address with their wallet connected.
expect(canSetPrimaryName(VICTIM, row)).toBe(true)
// The victim owns nothing here — ownership is never consulted.
log(' [gate] canSetPrimaryName(victim, addr=victim) === true on the ATTACKER’s name')
})
it('the button is offered precisely because the pair does not match yet', () => { const row = { address: VICTIM as string }
// The victim's reverse record still says alice.eth, so the UniversalResolver
// reverse of their address does not return freebie.eth -> 'mismatch'.
expect(offerSetPrimary('mismatch', VICTIM, row)).toBe(true)
// The states that would NOT offer it, for contrast.
expect(offerSetPrimary('verified', VICTIM, row)).toBe(false)
expect(offerSetPrimary('pending', VICTIM, row)).toBe(false)
expect(offerSetPrimary('unverifiable', VICTIM, row)).toBe(false)
log(' [offer] status=mismatch + gate -> red warning + "Set primary name" button')
})
it('one click signs setName(attackerName) on the victim’s own reverse record', () => { const data = buildSetNameCalldata(ATTACKER_NAME) const decoded = decodeFunctionData({ abi: reverseRegistrarSetNameSnippet, data, })
expect(decoded.functionName).toBe('setName')
expect((decoded.args as readonly unknown[])[0]).toBe(ATTACKER_NAME)
// The victim's real primary name appears nowhere in the transaction that
// overwrites it.
expect(data).not.toContain(
Buffer.from(VICTIM_REAL_PRIMARY).toString('hex'),
)
log(` [calldata] setName("${ATTACKER_NAME}") — overwrites the victim’s "${VICTIM_REAL_PRIMARY}"`)
})
it('an ownership check would have blocked it — and the data is already loaded', () => { // What the component fetches at :431 but never compares. const ownerOfAttackerName = ATTACKER
// The check that does not exist:
const wouldBeBlocked = !isAddressEqual(VICTIM, ownerOfAttackerName)
expect(wouldBeBlocked).toBe(true)
// ...while the check that DOES exist passes.
expect(canSetPrimaryName(VICTIM, { address: VICTIM as string })).toBe(true)
log(' [missing] owner=attacker !== connected=victim — one comparison away')
})
it('control: the gate correctly refuses when the record names someone else', () => { // Sanity check that the gate is not simply always-true: if the attacker // points the record anywhere other than the victim, the button is withheld. expect(canSetPrimaryName(VICTIM, { address: ATTACKER as string })).toBe(false) expect(canSetPrimaryName(VICTIM, { address: null })).toBe(false) expect(canSetPrimaryName(undefined, { address: VICTIM as string })).toBe(false) expect(canSetPrimaryName(VICTIM, { address: 'not-an-address' })).toBe(false)
log(' [control] gate refuses on a non-matching / absent / malformed record')
}) })