58513 sc low broken access control in alchemistcurator acceptadminownership prevents admin transfer
Submitted on Nov 2nd 2025 at 23:11:41 UTC by @Tomioka for Audit Comp | Alchemix V3
Report ID: #58513
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistCurator.sol
Impacts:
Description
The acceptAdminOwnership function in the AlchemistCurator contract contains a critical access control vulnerability where it uses the onlyAdmin modifier instead of allowing the pendingAdmin to call it. This breaks the standard two step ownership transfer pattern and makes it impossible to transfer admin privileges to a different address, potentially locking administrative functions if the current admin key is compromised or lost.
Vulnerability Details
The AlchemistCurator implements a two step admin transfer process:
Step 1: Propose Transfer (transferAdminOwnerShip):
function transferAdminOwnerShip(address _newAdmin) external onlyAdmin {
pendingAdmin = _newAdmin;
}Step 2: Accept Transfer (acceptAdminOwnership):
function acceptAdminOwnership() external onlyAdmin { // WRONG MODIFIER
admin = pendingAdmin;
pendingAdmin = address(0);
emit AdminChanged(admin);
}The bug is on line 31, the function uses onlyAdmin modifier, which restricts the call to the current admin, not the pending admin. The standard two step ownership transfer pattern requires:
Current admin proposes new admin (
transferAdminOwnerShip)Pending admin accepts the transfer (
acceptAdminOwnership) CORRECT PATTERNOwnership transfers
However, the current implementation requires:
Current admin proposes new admin
Current admin accepts the transfer BROKEN PATTERN
Admin changes to pendingAdmin
Why This is Broken:
The two step pattern exists to prevent accidental transfers to wrong addresses. The pending admin must prove they control the new address by calling accept. With the current bug:
Current admin can transfer to themselves (pointless)
Current admin CANNOT transfer to a different address (the new admin can't accept)
The
pendingAdmincannot callacceptAdminOwnership()due to access control
Complete Attack Scenario:
While this isn't an "attack" in the malicious sense, it creates a critical operational failure:
Current admin wants to transfer to new multisig/address
Admin calls
transferAdminOwnerShip(newAddress)pendingAdminis set tonewAddressNew admin attempts to call
acceptAdminOwnership()Transaction REVERTS - caller is not current admin
Admin transfer is permanently stuck
Real World Impact:
This bug has severe consequences in production:
Safe key rotation impossible: Cannot perform secure admin rotation using the two-step pattern
Multisig migration blocked: Cannot safely upgrade from EOA to multisig
Compromise scenario: Cannot use the intended emergency transfer mechanism
DAO governance broken: Cannot safely transfer to governance contracts
Note: While the two step process is broken, admin transfer is still possible via the unsafe setAdmin() function, but this bypasses all safety mechanisms.
Root Cause:
The bug stems from incorrect modifier application. The correct implementation should check that msg.sender == pendingAdmin, not msg.sender == admin. This is evident from other similar contracts in the ecosystem (OpenZeppelin's Ownable2Step, etc.).
Comparison with Correct Implementation:
OpenZeppelin's Ownable2Step (correct pattern):
AlchemistCurator (broken pattern):
Alternative Path Exists:
While the two step transfer is broken, there IS an alternative path to transfer admin via the setAdmin() function inherited from PermissionedProxy:
This allows the current admin to directly set a new admin without acceptance. However, this defeats the safety purpose of the two step pattern and creates centralized risk.
Impact Details
Primary Impact: Safe Admin Transfer Broken
The intended two step admin transfer pattern fails
pendingAdmincannot accept ownership due to access control restrictionsForces use of unsafe direct transfer method (
setAdmin())
Secondary Impact: Security and Operational Risks
Key compromise: Cannot perform secure admin rotation using two step pattern
Multisig upgrade: Cannot safely migrate from EOA to multisig
DAO transition: Cannot safely decentralize to governance contracts
Centralized risk: Must rely on unsafe direct transfer method
Affected Functionality:
The following critical admin-only functions can only be accessed by the current admin (no safe rotation possible):
transferAdminOwnerShip(line 27) : Setting strategy capssubmitSetStrategy(line 37) : Adding new yield strategiessetStrategy(line 43) : Strategy managementremoveStrategy(line 49) : Emergency strategy removaldecreaseAbsoluteCap(line 73) : Risk managementdecreaseRelativeCap(line 83) : Risk managementincreaseAbsoluteCap(line 117) : Capacity expansionincreaseRelativeCap(line 122) : Capacity expansion
Note: Admin can still be changed via setAdmin(), but this is unsafe and bypasses the two step safety mechanism.
Affected Users:
Protocol administrators: Cannot perform secure admin rotation; must use unsafe direct method
DAO members: Cannot safely transition to decentralized control
Security team: Cannot use intended emergency transfer mechanism
All users: Exposed to centralized risk due to unsafe admin transfer method
References
Vulnerable accept function: src/AlchemistCurator.sol:31-35
Transfer initiation (works correctly): src/AlchemistCurator.sol:27-29
Comparison with OpenZeppelin standard: Ownable2Step.sol
Proof of Concept
Proof of Concept
Run the PoC
Execute this command in your terminal: forge test --match-path test/CuratorAdminAccept.t.sol -vvv
Recommended Mitigation
Replace the onlyAdmin modifier with a proper check for pendingAdmin:
This allows the pendingAdmin to accept the transfer while preventing unauthorized calls.
Was this helpful?