The acceptAdminOwnership() function is incorrectly protected by the onlyAdmin modifier. This prevents the pending admin from claiming ownership, rendering the two-step ownership transfer mechanism broken. As a result:
The current admin is the only one who can call acceptAdminOwnership(). If the current admin loses access (key loss, multisig failure, etc.), no one can ever become the new admin. The contract becomes permanently unmanageable.
Vulnerability Details
Let's take a closer look at what the problem is.
onlyAdmin modifier:
That is, to call a function with this modifier, the caller must have admin status.
Two-step transfer of access rights:
The main problem is that function acceptAdminOwnership is implemented incorrectly. When called, it requires that the caller already be an admin, but he cannot be one because he is still a pendingAdmin.
It's a vicious circle: to call a function and gain admin rights, you need to be an admin, but you can't do that because you're not an admin.
Here's what a proper implementation looks like, for example (openzeppelin-contracts):
Impact Details
Ownership transfer is permanently broken — the two-step admin transfer mechanism fails at the final step. If the current admin becomes unavailable (lost key, team departure, multisig failure), no one can ever assume control.
/**
* @dev Starts the ownership transfer of the contract to a new account. Replaces the pending transfer if there is one.
* Can only be called by the current owner.
*
* Setting `newOwner` to the zero address is allowed; this can be used to cancel an initiated ownership transfer.
*/
function transferOwnership(address newOwner) public virtual override onlyOwner {
_pendingOwner = newOwner;
emit OwnershipTransferStarted(owner(), newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`) and deletes any pending owner.
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual override {
delete _pendingOwner;
super._transferOwnership(newOwner);
}
/**
* @dev The new owner accepts the ownership transfer.
*/
function acceptOwnership() public virtual {
address sender = _msgSender();
if (pendingOwner() != sender) {
revert OwnableUnauthorizedAccount(sender);
}
_transferOwnership(sender);
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {AlchemistCurator} from "../../AlchemistCurator.sol";
contract MockAlchemistCurator is AlchemistCurator {
constructor(address _admin, address _operator)
AlchemistCurator(_admin, _operator)
{}
function getAdmin() external view returns (address) {
return admin;
}
function getPendingAdmin() external view returns (address) {
return pendingAdmin;
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test} from "forge-std/Test.sol";
import {MockAlchemistCurator} from "./mocks/MockAlchemistCurator.sol";
contract AcceptAdminOwnershipTest is Test {
MockAlchemistCurator public curator;
address public admin = address(0xA1);
address public operator = address(0xB2);
address public pending = address(0xB1);
address public rando = address(0xC1);
event AdminChanged(address indexed newAdmin);
function setUp() public {
curator = new MockAlchemistCurator(admin, operator);
}
function test_acceptAdminOwnership_Success() public {
vm.prank(admin);
curator.transferAdminOwnerShip(pending);
vm.prank(pending);
vm.expectEmit(true, true, false, true);
emit AdminChanged(pending);
curator.acceptAdminOwnership();
assertEq(curator.getAdmin(), pending);
assertEq(curator.getPendingAdmin(), address(0));
}
}