91640 w a critical bulk renewal signs a different label than the name displayed so the fee renews an unintended name displayed vs signed mismatch
Submitted on Sep 3rd 2026 at 08:50:47 UTC by @SaiyanVegeta for Audit Competition | ENS
- Report ID: #91640
- 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. In the bulk-renew flow the row and the confirmation dialog display one name, but the renew() calldata carries a different label with a different labelhash. The user approves a renewal of the name they see and pays to extend a name they do not see; their own name is not renewed.
Details (verified at HEAD).
apps/manager/src/features/dashboard/utils.ts:46—resolveDomainLabel = domain.name ?? domain.normalizedName ?? domain.id. The row text (NameRow.tsx) is this raw indexername.apps/manager/src/features/dashboard/components/NamesTable.tsx:83-90— the bulk payload is built asconst name = domain.normalizedName ?? displayName; label = name.replace(/\.eth$/i,''). The code comment on those lines states the display name "may be unnormalized … and would hash to the wrong label" — the developers noted the exact divergence.displayName(raw) is shown;label(normalized) is signed.apps/manager/src/features/bulk-renew/components/NamesBreakdown.tsxshowsrow.displayName;apps/manager/src/features/bulk-renew/hooks/useBulkRenew.tspricesn.label;apps/manager/src/features/bulk-renew/hooks/useBulkRenewSubmit.tsrenewsitem.label.- Contrast:
apps/manager/src/routes/renew/$name.tsx:61androutes/register/$name.tsx:57signparseName(raw).label. So the same on-chain name is renewed under one label via the single-renew route and a different label via bulk renew — the app has no consistent canonical-label policy.
Only transform-class labels diverge (case is absorbed by parseName's lowercase; non-normalizable names have normalizedName: null and fall back to raw in both flows): FE0F stripping, fullwidth/NFKC mapping, and ignorable-code-point removal (soft hyphen, ZWSP). The live indexer confirms this by its own contract — Domain.name is documented "Full domain name (e.g., 'vitalik.eth')" (the raw name) and Domain.normalizedName "ENSIP-15 normalized name (null if normalization fails)" are two separate fields — so for a transform-class label name (displayed) and normalizedName (signed) are non-null and differ (see PoC 3).
Impact
The user approves a transaction whose displayed target (name X) differs from the signed target (label Y, different labelhash). Concretely: the fee extends a third party's registration, and the user's own listed name is silently not renewed and can lapse. This matches the enumerated impact "showing the wrong … transaction detail … on a transaction the user then approves"; QA-03's own text says a displayed-vs-on-chain divergence "would be a new finding."
Risk Breakdown
- Lead scenario (no self-inflicted, no validator dependency) — the gift path. The attacker registers
victim.ethand, separately, the fullwidth twinvictim.eth(both mintable on the v2 registrar — proven on-chain, see PoC 2), then ERC-1155-transfers the twin to the victim (the dashboard lists names by owner). The victim selects "all" and bulk-renews: the dialog showsvictim.eth, the calldata signsrenew('victim')— a permissionless renewal of the attacker'svictim.ethat the victim's expense. The victim's ownvictim.ethis not renewed. This path needs no Manager validator flaw, so it is not EXP-INPUT-005, and the app (not the victim) performs the label swap intoBulkRenewName. - Not EXP-INPUT-005. That issue is scoped to display confusion "rather than incorrect resolution"; here the calldata label differs from the displayed name (a signed-value divergence), and the fix locus is
toBulkRenewName, not any validator. The report does not depend on how the twin came to exist. - No-attacker corollary (supporting evidence): with no twin registered, every bulk renewal of a transform-class row reverts
NameNotRenewablewhile the single-renew route succeeds — the two flows disagree even before an attacker appears, and the app never calls the registrar'sisAvailable/isRenewablebefore submitting. - Precondition — confirmed, not assumed. The two facts the attack rests on are both proven: (a) the indexer returns the raw
namedistinct fromnormalizedName(live schema, PoC 3); (b) a transform-class twin is registrable on the real v2 registrar (on-chain, PoC 2). This closes the "not reproducible on the live deployment" objection. - Severity Medium (honest ceiling). Direct loss is bounded by one renewal fee per event and the attacker pre-positions a twin (cost parity ~1:1 unless the victim renews many years or the twin is 3-char premium). The secondary effect is the stronger harm: the victim's own name is never renewed via bulk and can lapse into release — asset loss, not just fee loss. No connected-wallet tampering and the victim must include the row in a bulk selection, so not High.
Recommendation
Derive the bulk-renew label from the same on-chain source the single-renew route uses (parseName(displayName).label), or display normalizedName so shown and signed match. Refuse rows where name !== normalizedName with an explicit warning. Never substitute a normalized twin for signing.
References
apps/manager/src/features/dashboard/utils.ts:46,apps/manager/src/features/dashboard/components/NamesTable.tsx:83-90(comment admits the labelhash divergence)apps/manager/src/features/bulk-renew/hooks/useBulkRenew.ts,useBulkRenewSubmit.ts,components/NamesBreakdown.tsx- Contrast:
apps/manager/src/routes/renew/$name.tsx:61,routes/register/$name.tsx:57
Proof of Concept
Proof of Concept
PoC 1 — displayed name vs signed label (8/8 pass)
apps/manager/src/audit/tech-displayed-vs-signed-bulk-renew.test.tsx. Renders the real NamesTable with mocked data hooks, toggles the real selection, captures the BulkRenewName handed to the dialog and compares with the single-renew label. Observed:
row "🏳️🌈.eth" → bulk label "🏳🌈" (FE0F stripped), single-route "🏳️🌈", labelhash differs
row "victim.eth" → bulk label "victim", single-route "victim"
row "abc.eth" → bulk label "abc", single-route "abc"
canonical rows → consistent
cd apps/manager && NODE_OPTIONS="--max-old-space-size=8192" npx vitest run src/audit/tech-displayed-vs-signed-bulk-renew.test.tsx
PoC 2 — the v2 registrar accepts the non-normalized twin (on-chain, Sepolia fork)
Same as Report A1 (onchain__fork-label-validity.mjs): against the real deployed v2 ETHRegistrar bytecode, uppercase / fullwidth / soft-hyphen / zero-width-space / FE0F labels all return isAvailable = true and register() succeeds. This proves both the attacker's twin and the victim's transform-class name are mintable on the in-scope deployment.
PoC 3 — the live indexer returns the raw name distinct from the normalized one (read-only introspection)
The attack requires the indexer to expose the raw name alongside normalizedName, so the dashboard can display the raw name while the bulk-renew payload takes the normalized one. A read-only GraphQL introspection of the app's own indexer (https://staging-graphql.ens.dev/, the endpoint in packages/indexer/urql/client.ts) confirms the contract:
Domain.name : String — "Full domain name (e.g., 'vitalik.eth')"
Domain.labelName : String — "Label only (e.g., 'vitalik' for 'vitalik.eth')"
Domain.normalizedName : String — "ENSIP-15 normalized name (null if normalization fails)"
name is the raw full name and normalizedName is a separate, ENSIP-15 field. For a transform-class label they are both non-null and differ, so resolveDomainLabel displays name (raw) while toBulkRenewName signs normalizedName — the divergence surfaces in production data, not just in the harness. (Read-only introspection only; no data mutated.)