89551 w a insight ens explorer renders a forged forward match true primary name verification badge for an attacker s unverified default reverse name
Submitted on Aug 19th 2026 at 20:29:18 UTC by @Cryptor for Audit Competition | ENS
- Report ID: #89551
- 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
Description
Summary
The Explorer's reverse-resolution view looks up an address and shows the ENS name(s) that resolve back to it, alongside a "Forward match" column. That column's green ✓ True + Primary name badge is the app's trust signal that the name's forward addr() record actually points back to the looked-up address — the check that distinguishes a genuine primary name from an unverified claim.
For rows that inherit the account's default.reverse name (the ENSIP-19 cross-chain fallback, coin type 0x80000000), that badge is hardcoded to True and Primary name without performing the forward-match check, and the resolution deliberately discards the match result. Because a default.reverse record is settable by anyone for their own address with no ownership of the claimed name, an attacker can make the Explorer present any name (for example a well-known one) against the attacker's own address, decorated with a forged "forward-verified primary name" badge.
Vulnerability Details
1. The default.reverse name is resolved with mismatches allowed, and the forward-match flag is thrown away.
useReverseResolution.ts, lines 236–241:
const defaultResult = await getName(l1Client, {
address,
coinType: DEFAULT_EVM_COIN_TYPE, // 0x80000000 — the default.reverse record
allowMismatch: true, // return the name even if forward addr() does NOT point back
})
const defaultName = defaultResult?.name ?? null // keeps only .name; .match is discarded
getName(..., { allowMismatch: true }) returns { name, match }, where match is the forward confirmation that the name's addr() record resolves back to address. Only .name is retained; the .match flag is never carried onto the row.
2. The table hardcodes the positive verification badge for inherited-default rows.
columns.tsx, the forwardMatch column cell, lines 96–112:
if (!name && defaultName && canInheritDefault(row.original.coinType)) {
return (
<div ...>
<Badge ...><CheckCircle2 .../><span>True</span></Badge> {/* not verified */}
<Badge ...><SquareUser .../><span>Primary name</span></Badge> {/* not verified */}
<Badge ...>Default</Badge>
</div>
)
}
The own-name branch of the same cell (lines 125–138) correctly gates the True / Primary name badges on row.original.forwardMatch. The inherited-default branch does not — it asserts them unconditionally.
3. The source value is fully attacker-controlled and unauthenticated.
default.reverse (coin type 0x80000000) is a per-address record any account can set for its own address via the DefaultReverseRegistrar; setting it does not require owning, or being authorized on, the name it points to. So defaultName is an arbitrary attacker-chosen string, rendered as a forward-verified primary name.
Impact
The "Forward match" column exists specifically to tell a user whether a name↔address pairing is trustworthy — the forward record confirming the reverse claim. For inherited-default.reverse rows that signal is forged: the Explorer displays a name the address's owner does not own, and whose forward record does not point back, next to a green ✓ True and Primary name badge indistinguishable from a genuinely verified pairing.
An unprivileged attacker (any address holder, with no name ownership and no separate step required to make the badge appear) points their default.reverse at a name a victim trusts. When the victim looks that address up in the Explorer — the normal use of the tool — they see the address presented as the forward-verified primary of that name across all L2 rows. A user who relies on this verification badge to conclude "this address is the canonical/verified address for <name>" is deceived about the name↔address association, which is the precondition for sending funds or approving a transaction to the wrong recipient. This is the program's wrong-address impact family — "resolving or rendering an ENS name to an attacker-controlled address." The "Forward match" column is a verification control whose sole purpose is to confirm a name↔address pairing before a user trusts it; the bug makes the application affirmatively assert a false confirmation (✓ True / Primary name) for an attacker-controlled address claiming a name the attacker does not own, with no privilege, no name ownership, and no social-engineering step required to make the false verification appear.
Proof of Concept
Proof of Concept
Setup (per the program Resources page)
Requirements: Node v22, pnpm v10. Audited revision: commit 63772fd872af472ced58b009499355f3430c2a86 (immunefi-team/audit-comp-ens, fork commit cda79ac).
git clone https://github.com/immunefi-team/audit-comp-ens && cd audit-comp-ens
git checkout 63772fd872af472ced58b009499355f3430c2a86 # or its fork commit cda79ac
corepack enable
pnpm install --frozen-lockfile
A. Coded PoC (deterministic; drives the real column cell)
Create the file apps/portal/src/features/reverse-resolution/components/ReverseResolutionTable/columns.poc.test.tsx with exactly this content:
import type { Row } from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { ReverseResolutionResult } from '../../hooks/useReverseResolution'
import { columns } from './columns'
const L2_COIN_TYPE = 0x8000000a // Optimism ENSIP-11 coin type -> canInheritDefault === true
const forwardMatchColumn = columns.find(
(c) => (c as { accessorKey?: string }).accessorKey === 'forwardMatch',
)!
const renderForwardMatchCell = (original: Partial<ReverseResolutionResult>) => {
const cell = (forwardMatchColumn as { cell: (ctx: unknown) => JSX.Element }).cell
return render(<>{cell({ row: { original } as Row<ReverseResolutionResult> })}</>)
}
describe('P-1: forged forward-match badge for an unverified default.reverse name', () => {
it('renders "True" + "Primary name" for an inherited default.reverse name even though forwardMatch is false', () => {
renderForwardMatchCell({
coinType: L2_COIN_TYPE,
name: '', // this L2 has no own reverse record -> inherits the default
defaultName: 'vitalik.eth', // attacker set their default.reverse to a name they do NOT own
forwardMatch: false, // vitalik.eth's forward addr() does NOT point to the attacker
})
expect(screen.getByText('True')).toBeTruthy()
expect(screen.getByText('Primary name')).toBeTruthy()
})
it('control: a real own-name row with forwardMatch=false correctly shows "False"', () => {
renderForwardMatchCell({ coinType: L2_COIN_TYPE, name: 'bob.eth', defaultName: null, forwardMatch: false })
expect(screen.getByText('False')).toBeTruthy()
})
})
Run:
pnpm --filter portal exec vitest run \
src/features/reverse-resolution/components/ReverseResolutionTable/columns.poc.test.tsx
Result (both pass): the first test proves the cell renders the True / Primary name verification badges for an attacker-controlled, unverified default.reverse name (forwardMatch: false); the control proves the same cell renders False for a genuine row — so the badge is forged only on the inherited-default path.
B. End-to-end reproduction (browser + Sepolia)
Prerequisites: an attacker EOA on Sepolia, and the running Explorer (pnpm --filter portal dev, http://localhost:3001 — no configuration needed per the Resources page). Choose any name the attacker does not own — call it victim-name.eth.
- From the attacker EOA on Sepolia, set the account's default reverse name to
victim-name.ethvia the DefaultReverseRegistrar set-name entrypoint (ENSIP-19, coin type0x80000000). This writes only the attacker's owndefault.reverserecord; it does not touchvictim-name.eth. - Do not set a coin-60
addr.reverserecord, and confirmvictim-name.eth's forwardaddr()does not point to the attacker (it will not — the attacker does not own it). - Open the Explorer's reverse-resolution view for the attacker's address.
- Observe: every L2 row without its own reverse record displays
victim-name.ethwith a green✓ True(Forward match) badge and aPrimary namebadge — asserting a forward confirmation that was never performed and would fail. - For contrast, set a legitimate own name whose forward
addr()matches, and note the identical badge only appears there after the realforwardMatchcheck — confirming the inherited-default path skips it.