Immunefi Audit Competitions
ENS | Audit Competition

92877 w a critical ens explorer s remove user action can leave global resolver permissions active

Submitted on Sep 13th 2026 at 01:26:16 UTC by @AvidApex for Audit Competition | ENS

  • Report ID: #92877
  • Report Type: Websites & Apps
  • Report severity: Critical
  • Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
  • Impacts:
    • Malicious interactions with an already-connected wallet, such as: Modifying transaction arguments or parameters Substituting contract addresses Submitting malicious transactions Signing or submitting a transaction against the wrong chain (for example when the request's target chain is not validated) Inducing a blanket token approval (for example setApprovalForAll on the registry or NameWrapper) to an attacker-controlled address

Description

Brief/Intro

This report is related to my submission #92755. That report concerns an unresolved resource being interpreted as resolver-root scope during an edit. This report concerns incomplete revocation across multiple, correctly resolved resources. It does not depend on the missing-name fallback rejecting unmapped resources alone would not fix this removal flow.

ENS Explorer combines a delegate's global resolver permissions and permissions for individual names into one row, with a single Remove user action. The confirmation promises to remove the user from all roles, but Explorer submits a revocation for only one name. A global permission shown in the same row can remain active after the transaction succeeds.

The deployed explorer.ens.dev workflow was reproduced end to end using Firefox, MetaMask and a controlled Sepolia resolver. Explorer generated the following MetaMask request:

authorizeNameRoles(
  0x067465737462620365746800,              // testbb.eth
  17,                                      // Set Address | Set Text
  0x1546f7b65840e922d83a75caD474B8Cc34C1F5A5,
  false
)

The administrator approved the request. Transaction 0x5c458b…a409 removed the testbb.eth Set Text role but left the delegate's root Set Address role active. The delegate then used that surviving permission to change the controlled node's address in transaction 0xa02e22…23c1.

Vulnerability Details

The resolver-role model stores permissions as (account, resource, roleBitmap) records. Explorer groups those records by account, combining grants from different resources into one row. groupRolesByAccount retains the original grants while merging their resolved names and decoded roles:

const account = role.account.toLowerCase()
const existing = grouped.get(account)

if (existing) {
  existing.roles.push(role)
  existing.decodedRoles.push(...decoded)
  if (resolvedName) existing.resolvedNames.add(resolvedName)
}

For a row containing root Set Address and testbb.eth Set Text, the result is:

resolvedNames = ["(root)", "testbb.eth"]
decodedRoles = ["ROLE_SET_ADDR", "ROLE_SET_TEXT"]

The sidebar then selects only the first non-root name:

const decodedRoles = row?.original.decodedRoles ?? []
const resolvedNames = row?.original.resolvedNames ?? []
const roleName = resolvedNames.find((n) => n !== '(root)') ?? ''

handleRemoveUser passes that one name together with the combined roles from the row:

setPendingAction({
  type: 'remove',
  name: roleName,
  account: selectedAccount,
  roles: decodedRoles,
})

The transaction builder then chooses a single scope. Because roleName is testbb.eth, it builds a name-scoped request and omits the separate revokeRootRoles(1, delegate) call needed to remove the root grant:

name === ''
  ? { resolverAddress, targetAccount: account, scope: 'root', roles }
  : { resolverAddress, targetAccount: account, scope: 'name', name, roles }

The interface describes a broader removal. The row contains both testbb.eth and (root), the sidebar labels it Global roles (all names), and the confirmation says:

Are you sure you want to remove this user from all roles? This action cannot be undone.

The administrator is therefore asked to sign a transaction that removes less access than the confirmation promises.

Impact Details

The administrator is asked to revoke the delegate's full access, but the transaction removes access for only one name. The effect persists on-chain: the request generated by explorer.ens.dev was signed and mined, and the delegate then completed a protected write using the surviving root permission. This can undermine delegate offboarding or incident response.

The surviving root Set Address role applies across the resolver. A delegate being removed can continue changing address records for names handled by that resolver without further administrator interaction. For production names bound to the affected resolver, those changes could cause users and integrations to resolve an attacker-controlled address.

The demonstrated impact is continued control of resolver records. The controlled testbb.eth fixture was not bound to this resolver through the normal ENS registry during the run. Payment redirection, direct theft and funds loss were not demonstrated.

The administrator must approve the wallet request, and the delegate must already hold the root permission that Explorer fails to revoke.

Preserve resource identity throughout grouping, confirmation and transaction construction.

  • Build removal operations from row.original.roles, retaining every original (resource, roleBitmap) pair.
  • Submit and await a revocation for each represented resource, including a distinct root revocation.
  • Show the exact root and name scopes in the confirmation and wallet transaction list.
  • After mining, read authoritative role state and report partial removal if any represented grant survives.
  • Add regression tests for root plus name, multiple names, overlapping role bits and partial transaction failure.

If removal is intended to affect only one resource, group rows by account and resource and identify that scope in the confirmation. The current all roles and Global roles (all names) wording does not describe a transaction limited to one name.

References

Proof of Concept

Proof of Concept

The Vitest script calls the production grouping and transaction-preparation functions and applies the sidebar's name-selection expression.

The test checks that:

  1. Root Set Address (1) and testbb.eth Set Text (16) are merged into one row.
  2. The exact sidebar expression selects testbb.eth.
  3. The production removal builder encodes authorizeNameRoles(dns(testbb.eth), 17, delegate, false).
  4. No revokeRootRoles selector exists in the vulnerable transaction.
  5. A negative control verifies that the correct root operation would be revokeRootRoles(1, delegate).
import {
  type Address,
  bytesToHex,
  decodeFunctionData,
  parseAbi,
  toFunctionSelector,
} from 'viem'
import { sepolia } from 'viem/chains'
import { packetToBytes } from 'viem/ens'
import { describe, expect, it } from 'vitest'
import {
  buildResourceToNameMap,
  computeNameResource,
  groupRolesByAccount,
  type ResolverRoleKey,
  resolverRoles,
} from '@/lib/roles/resolverRoles'
import { prepareRevokeResolverRolesTransaction } from './revokeResolverRoles'

const ADMIN: Address = '0x48C6F96984825608aFb01D18a922e1F44d96912e'
const DELEGATE: Address = '0x1546f7b65840e922d83a75caD474B8Cc34C1F5A5'
const RESOLVER: Address = '0xd851025131C6cFf1EE602ffc8eFeA918Dc5b4c6C'
const NAME = 'testbb.eth'
const ROOT_RESOURCE = `0x${'00'.repeat(32)}`

const resolverRoleAbi = parseAbi([
  'function authorizeNameRoles(bytes name, uint256 roleBitmap, address account, bool grant)',
  'function revokeRootRoles(uint256 roleBitmap, address account)',
])

// Only account and chain are read while preparing the transaction.
const walletClient = {
  account: { address: ADMIN, type: 'json-rpc' },
  chain: sepolia,
} as never

describe('PoC: Explorer Remove user loses resolver resource scope', () => {
  it('merges root + name grants, then encodes only one name-scoped revocation', () => {
    // This is the state displayed in the validated Explorer row:
    //   root       -> ROLE_SET_ADDR (bitmap 1)
    //   testbb.eth -> ROLE_SET_TEXT (bitmap 16)
    const [row] = groupRolesByAccount(
      [
        {
          account: DELEGATE,
          resource: ROOT_RESOURCE,
          roleBitmap: `0x${resolverRoles.ROLE_SET_ADDR.toString(16)}`,
        },
        {
          account: DELEGATE,
          resource: computeNameResource(NAME),
          roleBitmap: `0x${resolverRoles.ROLE_SET_TEXT.toString(16)}`,
        },
      ],
      buildResourceToNameMap([{ name: NAME }]),
    )

    // The production grouping code creates one row for both resources.
    expect(row.roles).toHaveLength(2)
    expect(row.resolvedNames).toEqual(['(root)', NAME])
    expect(row.decodedRoles).toEqual(['ROLE_SET_ADDR', 'ROLE_SET_TEXT'])

    // Exact selection performed by ResolverRolesSidebar.tsx.
    const roleName = row.resolvedNames.find((name) => name !== '(root)') ?? ''
    expect(roleName).toBe(NAME)

    // Exact transaction builder invoked by the Remove user action.
    const vulnerableIntent = prepareRevokeResolverRolesTransaction({
      resolverAddress: RESOLVER,
      name: roleName,
      account: DELEGATE,
      roles: row.decodedRoles as ResolverRoleKey[],
      walletClient,
      chainId: sepolia.id,
    })

    expect(vulnerableIntent.request.type).toBe('eoa')
    if (vulnerableIntent.request.type !== 'eoa') {
      throw new Error('Expected an EOA transaction request')
    }
    if (!vulnerableIntent.request.data) {
      throw new Error('Expected resolver calldata')
    }

    const decodedVulnerableCall = decodeFunctionData({
      abi: resolverRoleAbi,
      data: vulnerableIntent.request.data,
    })

    // BUG: the unioned bitmap (1 | 16 = 17) is revoked only for testbb.eth.
    // No revokeRootRoles call is present, so global ROLE_SET_ADDR survives.
    expect(decodedVulnerableCall).toEqual({
      functionName: 'authorizeNameRoles',
      args: [bytesToHex(packetToBytes(NAME)), 17n, DELEGATE, false],
    })
    expect(vulnerableIntent.request.data.slice(0, 10)).not.toBe(
      toFunctionSelector('revokeRootRoles(uint256,address)'),
    )

    // Negative control: the transaction builder correctly revokes root scope
    // when the UI passes an empty name and only the root role.
    const expectedRootIntent = prepareRevokeResolverRolesTransaction({
      resolverAddress: RESOLVER,
      name: '',
      account: DELEGATE,
      roles: ['ROLE_SET_ADDR'],
      walletClient,
      chainId: sepolia.id,
    })

    expect(expectedRootIntent.request.type).toBe('eoa')
    if (expectedRootIntent.request.type !== 'eoa') {
      throw new Error('Expected an EOA transaction request')
    }
    if (!expectedRootIntent.request.data) {
      throw new Error('Expected resolver calldata')
    }

    expect(
      decodeFunctionData({
        abi: resolverRoleAbi,
        data: expectedRootIntent.request.data,
      }),
    ).toEqual({
      functionName: 'revokeRootRoles',
      args: [1n, DELEGATE],
    })
  })
})