58636 sc low broken two step admin transfer prevents legitimate admin succession in alchemistcurator
Submitted on Nov 3rd 2025 at 18:14:33 UTC by @pyman for Audit Comp | Alchemix V3
Report ID: #58636
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistCurator.sol
Impacts:
Smart contract unable to operate due to lack of token funds
Description
Brief/Intro
The acceptAdminOwnership() function wrongly restricts calls to the current admin instead of the nominated pendingAdmin, preventing legitimate admin transfers and potentially locking critical contract control indefinitely.
Vulnerability Details
The AlchemistCurator contract uses a two-step process to transfer the admin role via transferAdminOwnerShip() and acceptAdminOwnership(). However, the acceptAdminOwnership() function incorrectly applies the onlyAdmin modifier, restricting calls to the current admin instead of the intended pendingAdmin set by the current admin. As a result, legitimate admin transfers cannot be completed, potentially locking the contract under the control of the current admin. If the current admin loses access to their keys, becomes unresponsive, or acts maliciously, they could deliberately or accidentally prevent the administrative role from being transferred, leading to governance stagnation and a permanent loss of control over critical contract functions.
Problem summary: the acceptAdminOwnership() function uses the wrong access control: it requires the caller to be the current admin (via onlyAdmin) instead of requiring the caller to be the pendingAdmin. This makes completing the two-step transfer impossible.
The admin transfer mechanism in AlchemistCurator.sol consists of two functions:
After transferAdminOwnerShip(newAdmin) is called by the current admin, pendingAdmin becomes newAdmin. But acceptAdminOwnership() is onlyAdmin, so newAdmin (not admin at this point) cannot call it. The accept step can never be executed by the intended new admin.
Only current admin can call
acceptAdminOwnership()due toonlyAdminmodifier.The intended new admin (
pendingAdmin) cannot call the function because they're not yet admin.The transfer can never be completed by the intended new admin.
Root Cause: The access control check is applied to the wrong party. The acceptance should be performed by the recipient, not the sender.
Impact Details
Due to the incorrect access control in acceptAdminOwnership(), the contract’s administrative role can never be transferred, potentially freezing all privileged operations.
This means that if the current admin loses access, becomes unresponsive, or acts maliciously, the protocol loses the ability to update, pause, or recover funds — permanently locking its control layer.
Step-by-Step Exploitation
Scenario 1 – Normal Admin Transfer Attempt
Current admin (
0xAlice) callstransferAdminOwnerShip(0xBob)→pendingAdmin = 0xBob0xBobattempts to callacceptAdminOwnership()but reverts with"PD"due toonlyAdminmodifier.Only
0xAlice(the current admin) can call it — meaning the transfer can never complete.Result: Admin handover is impossible.
Scenario 2 – Admin Key Loss
0xAliceloses private keys.No other entity can assume admin privileges because
acceptAdminOwnership()requires the lost key to succeed.Result: Contract permanently bricked from an operational standpoint.
Scenario 3 – Malicious Admin
0xAliceintentionally callstransferAdminOwnerShip()to mislead others into thinking control will change.The new admin (
0xBob) can never actually assume ownership.Result: Governance remains centralized and deceptive.
References
Found in src/AlchemistCurator.sol at line 31, branch
immunefi_audit: Line: 31
Link to Proof of Concept
https://gist.github.com/m-sabonkudi/3b535c8cdf83edc23f59afb00aa4842b
Proof of Concept
Proof of Concept
The following Foundry test demonstrates that the acceptAdminOwnership() function can only be called by the current admin instead of the pendingAdmin.
Output:
To interpret, in the test_NewAdminCannotAcceptAdminOwnership(), the admin successfully called the AlchemistCurator::transferAdminOwnerShip(newAdmin) which updates AlchemistCurator::pendingAdmin to the newAdmin passed. Subsequently, the newAdmin now calls AlchemistCurator::acceptAdminOwnership(), but it reverts with PD because it has the onlyOwner modifier from the inherited PermissionedProxy contract. The PermissionedProxy::onlyOwner() modifier is what reverted with PD as per require(msg.sender == admin, "PD");.
Recommended Mitigation: Simply remove the onlyOwner modifier and add a check to make sure the caller is the pendingAdmin.
It is also recommended to check that the _newAdmin passed by the current admin is a valid address. On top of that, it is recommended to correct the typo in the function name from transferAdminOwnerShip to transferAdminOwnership.
Was this helpful?