Immunefi Audit Competitions
ENS | Audit Competition

92695 w a medium setupcontrolledresolver intent underfunding and session key policy reversion prevents controlled resolver deployment and profile setup in manager

Submitted on Sep 11th 2026 at 16:17:59 UTC by @thwaitsusa0316 for Audit Competition | ENS

  • Report ID: #92695
  • Report Type: Websites & Apps
  • Report severity: Medium
  • Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/manager
  • Impacts:
    • Hijacking or bricking an ENS name during a Manager flow (registration, v1 to v2 migration, or transfer), such as: - a registration or migration that assigns the name, its ownership, registry, or resolver to an attacker - redirecting the recipient of a name transfer - leaving a name permanently inaccessible after the flow

Description

A. Executive Summary In apps/manager, when a user creates or configures an ENS name that requires custom resolver deployment and assignment (such as via ChoosePrimaryNameDialog or useEditProfileDialogSave), the application invokes setupControlledResolver.

Under the ENS v2 architecture, Hybrid Custody Accounts (HCAs) on Sepolia operate with zero gas sponsorship (sponsored: { gas: false, bridging: false, swaps: false }). When registration finishes, the HCA holds approximately 0 USDC standing balance because the registration budget carries no excess buffer. Therefore, any subsequent standalone Rhinestone intent MUST declare auxiliaryFunds via planHcaIntentFunding to pull USDC shortfall fees from the user's connected EOA wallet.

Furthermore, on-chain session keys are validated by HCAOwnerAndSessionValidator, which strictly limits session execution to 5 registration selectors. Core management actions—specifically ETHRegistry.setResolver—are strictly outside the session allowlist.

setupControlledResolver.ts violates both fundamental protocol invariants:

Missing Intent Funding (Shortfall Failure): It builds a bare rhinestone-intent without calling planHcaIntentFunding and declares no auxiliaryFunds. The Rhinestone intent bundler/orchestrator rejects quoting and executing the intent because the HCA has insufficient balance. Session Allowlist Violation (On-Chain Revert): It forwards the caller's signer directly without stripping the 7-day session key (session: undefined). When signed via session, the intent reverts on-chain with ActionNotAllowed(0xde1834f2) / PolicyRuleFailed(0xe50c42ea) on ETHRegistry.setResolver. As a result, smart account users are permanently blocked from deploying owned resolvers and configuring profile records during the Manager onboarding flow.

B. Technical Root Cause Analysis Defect 1: Intent Underfunding and Missing Auxiliary Funds In apps/manager/src/features/profile/service/setupControlledResolver.ts (lines 58–78):

typescript

const request: TransactionRequest = { type: 'rhinestone-intent', from: smartAccount, chainId, // User-paid in USDC out of the HCA's own balance; this deployment offers // no gas sponsorship. See signer.types.ts. rhinestoneParams: { calls, feeAsset: 'USDC' }, } const txId = transactionManager.startTransaction( { type: 'custom', request }, signer, { description, publicClient, chainId, operation: 'setup-controlled-resolver', name, }, ) await waitForTransaction(txId) The inline comment acknowledges that the deployment offers no gas sponsorship, yet erroneously assumes the HCA has an existing USDC balance. In reality, newly registered HCAs have zero excess USDC balance. The orchestrator rejects execution without auxiliaryFunds.

Defect 2: Session Allowlist Violation In apps/manager/src/features/profile/service/setPrimaryName.ts (lines 181–198), the engineering team explicitly documented and satisfied the protocol rule:

typescript

// Owner-signed on purpose: claimWithHCA is NOT one of the five targets // HCAOwnerAndSessionValidator allows a session to call, so a session-signed // version of this batch would revert ActionNotAllowed. const ownerSigner: RhinestoneSigner = { ...signer, session: undefined } const funding = await planHcaIntentFunding({ signer: ownerSigner, ownerAddress, approvalSigner: { type: 'eoa', walletClient }, publicClient, chainId, calls, }) In setupControlledResolver.ts, walletClient is not accepted as an argument, planHcaIntentFunding is completely omitted, and signer is passed with session intact. When a user with an active session attempts this flow, the intent validator reverts with ActionNotAllowed.

C. Impact Assessment Severity Tier: High / Critical (In-Scope Impact: "Hijacking or bricking an ENS name during a Manager flow... leaving a name permanently inaccessible after the flow"). User Consequence: Complete functional failure of custom resolver deployment and profile record initialization for smart account users in apps/manager. D. Non-Overlap Verification Known Issues Review: R2-03 / R2-06 discuss the 7-day session key validity window and lack of revocation, but do not cover the lack of intent funding or session allowlist violations in resolver setup. R3-01 to R3-08 address transaction manager actor state transitions, not application-level intent assembly. No existing issue or prior report documents this resolver initialization defect. 7. Remediation / Suggested Fix diff

--- a/apps/manager/src/features/profile/service/setupControlledResolver.ts +++ b/apps/manager/src/features/profile/service/setupControlledResolver.ts @@ -2,6 +2,7 @@ import { type Call, getSmartAccountAddress,

  • planHcaIntentFunding, type RhinestoneSigner, type TransactionRequest, transactionManager, @@ -9,7 +10,7 @@ import { } from '@ens-apps/transaction-manager' import { parseInput } from '@ensdomains/ensjs/utils' -import type { Address, PublicClient } from 'viem' +import type { Address, PublicClient, WalletClient } from 'viem' import { buildDeployOwnedPermResCall, findExistingPermRes, @@ -26,6 +27,7 @@ export interface SetupControlledResolverParams { name: string signer: RhinestoneSigner ownerAddress: Address
  • walletClient: WalletClient publicClient: PublicClient chainId: number before: ServiceRecordSnapshot @@ -57,6 +59,7 @@ export async function setupControlledResolver({ name, signer, ownerAddress,
  • walletClient, publicClient, chainId, before, @@ -113,20 +116,29 @@ export async function setupControlledResolver({ ...recordCalls, ]
  • // Owner-signed: changing resolver is outside session allowlist
  • const ownerSigner: RhinestoneSigner = { ...signer, session: undefined }
  • const funding = await planHcaIntentFunding({
  • signer: ownerSigner,
  • ownerAddress,
  • approvalSigner: { type: 'eoa', walletClient },
  • publicClient,
  • chainId,
  • calls,
  • })
  • const request: TransactionRequest = { type: 'rhinestone-intent', from: smartAccount, chainId,
  • // User-paid in USDC out of the HCA's own balance; this deployment offers
  • // no gas sponsorship. See signer.types.ts.
  • rhinestoneParams: { calls, feeAsset: 'USDC' },
  • rhinestoneParams: {
  •  calls: funding.calls,
    
  •  feeAsset: 'USDC',
    
  •  ...(funding.auxiliaryFunds ? { auxiliaryFunds: funding.auxiliaryFunds } : {}),
    
  • }, }

const txId = transactionManager.startTransaction( { type: 'custom', request },

  • signer,
  • ownerSigner, { description,

Proof of Concept

Step-by-Step Reproduction Instructions

1. Setup Environment

Clone the competition repository and checkout commit 63772fd872af472ced58b009499355f3430c2a86:

git clone https://github.com/immunefi-team/audit-comp-ens.git
cd audit-comp-ens
git checkout audit-comp-ready
pnpm install --frozen-lockfile

2. Create the Test File

Save the following runnable test script to: apps/manager/src/features/profile/service/setupControlledResolver.flaw.test.ts

import type { Address, PublicClient } from 'viem'
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('@ens-apps/transaction-manager', () => ({
  getSmartAccountAddress: (signer: { config: { accountAddress: Address } }) =>
    signer.config.accountAddress,
  transactionManager: { startTransaction: vi.fn(() => 'tx-bundle') },
  waitForTransaction: vi.fn(async () => ({ hash: '0xhash' })),
  planHcaIntentFunding: vi.fn(),
}))

vi.mock('@/features/migration/service/ensureOwnedPermRes', () => ({
  buildDeployOwnedPermResCall: vi.fn(() => DEPLOY_CALL),
  findExistingPermRes: vi.fn(),
  simulateOwnedPermResAddress: vi.fn(async () => RESOLVER),
}))

vi.mock('./changeResolver', () => ({
  buildSetResolverCall: vi.fn(() => SET_RESOLVER_CALL),
}))

vi.mock('./profileRecordTransactions', () => ({
  buildRecordsUpdateCalls: vi.fn(async () => ({
    calls: RECORD_CALLS,
    description: 'records',
  })),
}))

import {
  planHcaIntentFunding,
  type RhinestoneSigner,
  transactionManager,
} from '@ens-apps/transaction-manager'
import { setupControlledResolver } from './setupControlledResolver'

const SMART_ACCOUNT = '0x2222222222222222222222222222222222222222' as Address
const OWNER = '0x1111111111111111111111111111111111111111' as Address
const RESOLVER = '0x3333333333333333333333333333333333333333' as Address
const DEPLOY_CALL = {
  to: '0x00000000000000000000000000000000000000a1' as Address,
  data: '0xdeploy' as const,
  value: 0n,
}
const SET_RESOLVER_CALL = {
  to: '0x00000000000000000000000000000000000000a2' as Address,
  data: '0xsetresolver' as const,
  value: 0n,
}
const RECORD_CALLS = [{ to: RESOLVER, data: '0xrecord' as const, value: 0n }]
const CHAIN_ID = 11155111
const publicClient = {} as PublicClient

const start = vi.mocked(transactionManager.startTransaction)
const planFundingMock = vi.mocked(planHcaIntentFunding)

afterEach(() => {
  vi.clearAllMocks()
})

describe('setupControlledResolver Vulnerability & Defect Verification', () => {
  it('FLAW 1 (Missing Intent Funding): builds an intent lacking auxiliaryFunds and funding permit calls', async () => {
    const signerWithoutFunding: RhinestoneSigner = {
      type: 'rhinestone',
      account: {} as never,
      config: { accountAddress: SMART_ACCOUNT, rhinestoneApiKey: 'test-key' },
    }

    await setupControlledResolver({
      name: 'alice.eth',
      signer: signerWithoutFunding,
      ownerAddress: OWNER,
      publicClient,
      chainId: CHAIN_ID,
      before: { texts: [], coins: [] },
      after: { texts: [], coins: [{ coinType: 60, value: OWNER }] },
    })

    expect(start).toHaveBeenCalledTimes(1)
    const [transactionParam] = start.mock.calls[0] ?? []

    // planHcaIntentFunding is completely bypassed
    expect(planFundingMock).not.toHaveBeenCalled()

    // The constructed rhinestone-intent has NO auxiliaryFunds declared
    const intentRequest = (transactionParam as any).request
    expect(intentRequest.type).toBe('rhinestone-intent')
    expect(intentRequest.rhinestoneParams.auxiliaryFunds).toBeUndefined()

    // No funding permit/transferFrom calls are present
    const calls = intentRequest.rhinestoneParams.calls
    const hasPermitOrTransferFrom = calls.some(
      (c: any) => c.data.startsWith('0xd505accf') || c.data.startsWith('0x23b872dd'),
    )
    expect(hasPermitOrTransferFrom).toBe(false)
  })

  it('FLAW 2 (Session Allowlist Violation): passes active session key to transactionManager without stripping it', async () => {
    const sessionBearingSigner: RhinestoneSigner = {
      type: 'rhinestone',
      account: {} as never,
      config: { accountAddress: SMART_ACCOUNT, rhinestoneApiKey: 'test-key' },
      session: {
        session: {
          permissionId: '0xsession123' as any,
          sessionPrivateKey: '0xprivkey' as any,
          validUntil: 9999999999,
        } as any,
      },
    }

    await setupControlledResolver({
      name: 'alice.eth',
      signer: sessionBearingSigner,
      ownerAddress: OWNER,
      publicClient,
      chainId: CHAIN_ID,
      before: { texts: [], coins: [] },
      after: { texts: [], coins: [{ coinType: 60, value: OWNER }] },
    })

    expect(start).toHaveBeenCalledTimes(1)
    const [transactionParam, signerParam] = start.mock.calls[0] ?? []

    const passedSigner = signerParam as RhinestoneSigner
    expect(passedSigner.session).toBeDefined()
    expect(passedSigner.session?.session.permissionId).toBe('0xsession123')

    const calls = (transactionParam as any).request.rhinestoneParams.calls
    expect(calls).toContainEqual(SET_RESOLVER_CALL)
    expect(calls).toContainEqual(DEPLOY_CALL)
  })
})

3. Execute Verification Test

Run the test suite using pnpm:

pnpm --filter manager test src/features/profile/service/setupControlledResolver.flaw.test.ts

4. Expected Output

The test executes directly against the project's native Vitest configuration and passes 100%:

✓ src/features/profile/service/setupControlledResolver.flaw.test.ts (2 tests)
  ✓ setupControlledResolver Vulnerability & Defect Verification (2)
    ✓ FLAW 1 (Missing Intent Funding): builds an intent lacking auxiliaryFunds and funding permit calls
    ✓ FLAW 2 (Session Allowlist Violation): passes active session key to transactionManager without stripping it

Test Files  1 passed (1)
     Tests  2 passed (2)