92967 w a critical the manager checks name ownership on one node and writes to another
Submitted on Sep 13th 2026 at 14:35:56 UTC by @nomorecaffeine for Audit Competition | ENS
- Report ID: #92967
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/manager
- Impacts:
- 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
Bug Description
The profile route takes a name from the URL and never normalizes it:
export const Route = createFileRoute('/$name')({
params: {
parse: ({ name }) => {
if (name.length < 3 || !name.includes('.')) {
throw new Error('Invalid ENS name')
}
return {
name,
}
},
The page then resolves that name twice, under two different normalizations.
The ownership half normalizes first:
const ethName = normalizeEthName(params.name)
...
const v2Owner = yield* fromPromise(
ensjsv2_getOwner(client, { name: ethName.name }),
The records half does not. profileRecords.ts#L49 queries the indexer with { id: name }, and the on-chain read takes the same raw string into getRecords(client, { name, ... }). ensjs performs no normalization of its own: getRecords passes name straight into getTextParameters, getAbiParameters and getContentHashParameters.
An ENS label is an arbitrary string on chain. Normalization is a client-side convention, not a registrar constraint, so the two halves address two different registrations, with two different owners, whenever the URL carries a label that normalization would change:
- Alice owns
café.eth, whose label is NFC (éis U+00E9). - Mallory registers the same label in NFD (
efollowed by U+0301). It is a different string, a different namehash and a different registration. Both render ascafé.eth. - Mallory points the NFD name's
addr(60)record at her own address. - Mallory sends Bob a link to
https://<manager>/café.eth, NFD form. Bob's URL bar and the page header showcafé.eth. - The Owner field is resolved from the normalized name, so it shows Alice's real address.
- The ETH address record is resolved from the raw name, so it shows Mallory's address.
The split also decides whether the user may act. isOwner is computed from the ownership half:
const owner = ownerData?.owner as Address | undefined
...
const isOwner = useIsOwner({ owner })
isOwner gates the Edit Profile action (ProfileActions.tsx#L107), and the save that action performs is addressed to the resolver the records half found:
profileRecordTransactions.ts#L455
calls: [{ to: resolverAddress, data, value: 0n }],
resolverAddress reaches it from savedRecords.resolverAddress (useEditProfileDialogSave.ts#L216), which is the raw name's resolver.
Measured on live Sepolia through the app's own functions, for a name already indexed in NFD:
==== ébf-f51f02a5.eth ====
codepoints raw = U+0065 U+0301 U+0062 U+0066 U+002D U+0066 U+0035 U+0031 U+0066 U+0030 U+0032 U+0061 U+0035 U+002E U+0065 U+0074 U+0068
normalizeEthName = ébf-f51f02a5.eth
renders identically= true
namehash(raw) = 0x606b2ef1050de96f19a4d4ed6f15028d3d1689b1a28dd933bd7dedcddef9e9af
namehash(normal) = 0xc22d8d028e8f3410a24715c68d8f24c408a493a620a4126a0f593cf14aef39d6
OWNER half (normalized) -> 0x9aDaD7ECDB9a481f66abcCa385d6E0B5E5E3FC3E
RECORDS half (RAW) -> addr60 0x13D7d7267154D7712aF147F964226BC8D6B3D951 resolver 0xe25379bFf197F110aCE1E686fc1584fb342d2ABC
RECORDS control (norm) -> addr60 0x9aDaD7ECDB9a481f66abcCa385d6E0B5E5E3FC3E resolver 0xF4609d9DA07A6Af80b94aFfE9e29E9eB74a5F58F
The first two lines are not a repetition: the input is e + U+0301, the normalized form is U+00E9, and they hash to the two nodes shown beneath.
The split is in the keying, not in any character filter. It needs no deceptive character at all: the PoC reproduces the same divergence on CRYPTO.eth, plain ASCII in the wrong case, and no validator runs on this route either way.
Impact
The app decides a connected user owns the name from one node and then builds the record write against the other. The to: of the transaction they sign is therefore a contract chosen by whoever owns the twin, and the ownership check that unlocked the action never looked at the node being written.
The page gives the user little to catch it with, because Owner, Registered and Expires are resolved from the normalized name (profileRegistration.ts#L66, and profileExpiry.ts:77 does the same) and therefore describe the genuine registration, wrapping a node the user does not own in the real name's ownership and dates.
The substituted target receives a setText or setAddr call with value: 0, so nothing is drained by the call itself; what the user loses is the write, which lands on a contract the attacker controls while the interface reports success. The victim must arrive by a link, because no in-app navigation produces an unnormalized URL.
Attacker prerequisites
Register the twin of a name the victim owns, an ordinary registration at ordinary cost, and point it at a resolver that permits anyone to write. The permissive resolver matters: the edit dialog probes write access first (useEditProfileDialogSave.ts#L219-L226) and routes to resolver setup when the probe fails. Then get one link opened. No role, no approval, no prior relationship, and no interaction by the name's real owner.
A twin with no resolver at all is not enough: in the same run, Crypto.eth makes the records query reject, and the route renders its error component instead.
Recommendation
Derive one canonical key at the route boundary, so every consumer downstream receives the same string:
parse: ({ name }) => {
if (name.length < 3 || !name.includes('.')) {
throw new Error('Invalid ENS name')
}
return {
- name,
+ name: normalize(name),
}
},
To preserve the typed spelling in the UI, keep the raw string for display only and pass the normalized one to every query and every write, which is the split profileOwner.ts already makes and profileRecords.ts does not.
Link to Proof of Concept
https://gist.github.com/nguyen-xuan-quoc/d316c21536bc6b1e625c336dfc038d97
Proof of Concept
Proof of Concept
Three tests against live Sepolia, 3 of 3 passing. Each reads both halves for one URL: the ownership half through the app's own normalizeEthName then ensjs getOwner, the records half through ensjs getRecords on the raw parameter. The app's function is imported rather than reimplemented, so what is measured is its own normalization.
a URL that renders identically to the real name reads a different nodeasserts the input and its normalized form are different strings with the same glyphs, that they namehash to different nodes, thataddr(60)differs between the halves, and that the normalized half'saddr(60)equals the owner. That last assertion shows the canonical name is internally coherent, so only the raw half describes something else.the same split occurs with nothing but a capital letterrepeats it onCRYPTO.eth, plain ASCII.control: a name already in normalized form reads one node on both halvesrunscrypto.ethand asserts both halves agree on node, address and resolver.
resolverAddress is the assertion that carries the claimed impact, because that value becomes the transaction's to:.
https://gist.github.com/nguyen-xuan-quoc/d316c21536bc6b1e625c336dfc038d97
The gist also contains the output of the run, so the results can be compared without executing anything. To run it:
- Clone the audited revision and install:
git clone https://github.com/immunefi-team/audit-comp-ens
cd audit-comp-ens && git checkout audit-comp-ready
pnpm install --frozen-lockfile
- Copy the test into
apps/manager/src/poc/. - Run it:
cd apps/manager
npx vitest run src/poc/01-poc-manager-name-key-divergence.test.ts --reporter=verbose --disable-console-intercept
One reproduction note. The indistinguishable input is written in the test as '\u0065\u0301bf-f51f02a5.eth', using escapes. Typing it as a literal accented character instead silently composes it to NFC, which makes the input equal to its own normalized form, and the first test then fails on its own assertion. The escapes are deliberate.
The control is what makes the run evidence rather than illustration: on an already-normalized name the two halves collapse onto one node and one resolver, so the divergence tracks the normalization difference and nothing else.
==== control crypto.eth ====
namehash(raw) == namehash(normal) = true
resolver raw / normalized = 0x75C7495CfD896FB78C4F6eF9f49Aa291232Cd116 / 0x75C7495CfD896FB78C4F6eF9f49Aa291232Cd116
No transaction is sent to any network and no shipped file is modified. The hops from isOwner to the Edit Profile gate to savedRecords.resolverAddress to the to: field are traced from the files linked above rather than driven in a browser.