vielite's blog

Admin Invariant Bypass via grantRole

April 7, 2026
6 min read
Table of Contents
admin-invariant-bypass-via-grantrole

Summary

AviBridge.sol and AviERC721Bridge.sol track administrator count with a manual _numAdmins invariant intended to guarantee that at least one admin always exists. The contracts override revokeRole and renounceRole to keep that counter in sync, but they do not override grantRole.

Because of that gap, DEFAULT_ADMIN_ROLE can be granted through the standard OpenZeppelin path without incrementing _numAdmins.

Vulnerability details

In AviBridge.sol, the manual counter is only updated in the wrapper functions:

skybridge-public/src/universal/AviBridge.sol
/// @notice Add a new admin address to the list of admins.
/// @param _admin New admin address.
function addAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(!hasRole(DEFAULT_ADMIN_ROLE, _admin), "Admin already added.");
_grantRole(DEFAULT_ADMIN_ROLE, _admin);
_numAdmins++;
}
/// @notice Remove an admin from the list of admins.
/// @param _admin Address to remove.
function removeAdmin(address _admin) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(hasRole(DEFAULT_ADMIN_ROLE, _admin), "Address is not a recognized admin.");
require (_numAdmins > 1, "Cannot remove the only admin.");
_revokeRole(DEFAULT_ADMIN_ROLE, _admin);
_numAdmins--;
}

The issue is that the contracts disable revokeRole and renounceRole, but they do not override grantRole, so the standard AccessControl path can still add an admin without touching _numAdmins.

Impact

The admin counter can permanently desynchronize from the actual role set:

  • a new admin is added with grantRole(DEFAULT_ADMIN_ROLE, alice)
  • _numAdmins stays unchanged
  • later removal paths rely on the stale counter rather than the real number of admins

Once the counter reaches 1 while multiple admins still exist, the role-management invariant is broken and administrative cleanup can become stuck or behave incorrectly.

Reproduction

  1. Deploy AviBridge.sol; the deployer starts with DEFAULT_ADMIN_ROLE.
  2. _numAdmins is initialized to 1.
  3. Call grantRole(DEFAULT_ADMIN_ROLE, alice).
  4. alice now has the admin role, but _numAdmins is still 1.
  5. Subsequent admin-removal flows now operate on stale accounting.

Recommendation

Override grantRole for the admin role and update _numAdmins whenever a new admin is added. Alternatively, remove the manual counter and derive the invariant from role membership directly.