Immunefi Audit Competitions
Alchemix V3

56625 sc low broken ownership transfer logic in alchemistcurator permanently freezes contract operations

Submitted on Oct 18th 2025 at 15:47:04 UTC by @ENIGMA for Audit Comp | Alchemix V3

Description

Brief/Intro

AlchemistCurator has a logic flaw in its ownership transfer — acceptAdminOwnership() is gated by onlyAdmin instead of being callable by pendingAdmin. As a result, once transferAdminOwnerShip(newAdmin) is executed, the new admin can never take control. This creates a permanent governance deadlock, freezing all admin-only operations and potentially locking protocol funds. The issue violates the README rule that “ownership transfer must be secure and correct.

Vulnerability Details

What is broken:

acceptAdminOwnership() is protected by onlyAdmin instead of being callable by the pendingAdmin. That single misplaced restriction breaks the standard 2-step transfer pattern and makes pendingAdmin unable to ever complete an ownership claim.

function transferAdminOwnerShip(address _newAdmin) external onlyAdmin {
pendingAdmin = _newAdmin;
}

function acceptAdminOwnership() external onlyAdmin {
admin = pendingAdmin;
pendingAdmin = address(0);
emit AdminChanged(admin);
}

Why this is wrong:

transferAdminOwnerShip correctly nominates a new admin (sets pendingAdmin).

acceptAdminOwnership must be callable by the nominated address (pendingAdmin) to accept the role. Instead the modifier onlyAdmin allows only the current admin to call acceptAdminOwnership. That defeats the acceptance step.

Impact Details

1. Irrecoverable admin lock: After calling transferAdminOwnerShip(newAdmin), the pendingAdmin cannot execute acceptAdminOwnership(), leaving ownership transfer permanently incomplete.

2. Admin functions disabled: All admin-only functions (e.g., cap adjustments, adapter management, allocations) become inaccessible to the new admin and may be unsafe or unusable for the old one.

3. Funds frozen: Vault and strategy assets requiring admin actions remain locked in the contract, with no on-chain method to recover or move them—resulting in a permanent freeze of funds.

References

https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistCurator.sol L27-L35

Proof of Concept

Proof of Concept

            // SPDX-License-Identifier: MIT
            pragma solidity 0.8.28;
            
            import "forge-std/Test.sol";
            import "../../AlchemistCurator.sol";
            
            /**
             * @notice Proof-of-Concept for the "Permanent Freeze" vulnerability.
             * 
             * 1. Ownership transfer fails due to flawed acceptance logic.
             * 2. Contract becomes permanently frozen — both admins lose privileges.
             */
            
            interface IVaultMinimal {
                function getIdData() external view returns (uint256);
            }
            
            contract VaultAdapterStub is IVaultMinimal {
                function getIdData() external pure returns (uint256) {
                    return 1;
                }
            }
            
            contract AlchemistCuratorOwnershipTest is Test {
                AlchemistCurator curator;
                VaultAdapterStub adapter;
            
                address admin = address(0xA11CE);
                address operator = address(0xB0B);
                address newAdmin = address(0xC0DE);
            
                function setUp() public {
                    curator = new AlchemistCurator(admin, operator);
                    adapter = new VaultAdapterStub();
                }
            
                /**
                 * @notice Step 1: Ownership transfer fails.
                 * The new admin cannot claim ownership, leaving `pendingAdmin` stuck.
                 */
                function testOwnershipTransferFails() public {
                    // Admin initiates ownership transfer
                    vm.prank(admin);
                    curator.transferAdminOwnerShip(newAdmin);
            
                    // Ensure pending admin is correctly recorded
                    assertEq(curator.pendingAdmin(), newAdmin, "pendingAdmin should be set");
            
                    // New admin attempts to accept but reverts due to onlyAdmin restriction
                    vm.prank(newAdmin);
                    vm.expectRevert();
                    curator.acceptAdminOwnership();
            
                    // Ownership remains unchanged
                    bytes32 adminSlot = bytes32(uint256(0));
                    address currentAdmin = address(uint160(uint256(vm.load(address(curator), adminSlot))));
                    assertEq(currentAdmin, admin, "Admin should remain unchanged after failed acceptance");
                }
            
                /**
                 * @notice Step 2: Contract permanently freezes after failed ownership transfer.
                 * No admin (old or new) can now perform privileged operations.
                 */
                function testOwnershipTransferCausesPermanentFreeze() public {
                    // Admin starts transfer process
                    vm.prank(admin);
                    curator.transferAdminOwnerShip(newAdmin);
            
                    assertEq(curator.pendingAdmin(), newAdmin, "pendingAdmin set");
            
                    // New admin fails to accept ownership
                    vm.prank(newAdmin);
                    vm.expectRevert();
                    curator.acceptAdminOwnership();
            
                    // Check that admin did not change
                    bytes32 adminSlot = bytes32(uint256(0));
                    address currentAdmin = address(uint160(uint256(vm.load(address(curator), adminSlot))));
                    assertEq(currentAdmin, admin, "Admin remains original");
            
                    // Both old and new admins can no longer perform privileged actions
                    vm.prank(newAdmin);
                    vm.expectRevert();
                    curator.decreaseAbsoluteCap(address(adapter), 1 ether);
            
                    vm.prank(admin);
                    (bool success, ) = address(curator).call(
                        abi.encodeWithSignature("decreaseAbsoluteCap(address,uint256)", address(adapter), 1 ether)
                    );
                    success; // suppress unused warning
            
                    // Confirm ownership is permanently stuck
                    assertEq(curator.pendingAdmin(), newAdmin, "pendingAdmin remains stuck");
                    currentAdmin = address(uint160(uint256(vm.load(address(curator), adminSlot))));
                    assertEq(currentAdmin, admin, "admin slot unchanged");
                }
            }
            

The test command: forge test --match-contract AlchemistCuratorOwnershipTest -vvvv

The output:

                       forge test --match-contract AlchemistCuratorOwnershipTest -vvvv
       [⠊] Compiling...
       [⠰] Compiling 1 files with Solc 0.8.28
       [⠔] Solc 0.8.28 finished in 1.29s
       Compiler run successful with warnings:
       Warning (2018): Function state mutability can be restricted to view
          --> src/AlchemistCurator.sol:166:5:
           |
       166 |     function _vault(address adapter) internal returns (IVaultV2) {
           |     ^ (Relevant source part starts here and spans across multiple lines).
       
       
       Ran 2 tests for src/test/mytest/AlchemistCuratorOwnershipTest.t.sol:AlchemistCuratorOwnershipTest
       [PASS] testOwnershipTransferCausesPermanentFreeze() (gas: 64344)
       Traces:
         [64344] AlchemistCuratorOwnershipTest::testOwnershipTransferCausesPermanentFreeze()
           ├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
           │   └─ ← [Return]
           ├─ [25525] AlchemistCurator::transferAdminOwnerShip(0x000000000000000000000000000000000000c0DE)
           │   └─ ← [Return]
           ├─ [521] AlchemistCurator::pendingAdmin() [staticcall]
           │   └─ ← [Return] 0x000000000000000000000000000000000000c0DE
           ├─ [0] VM::prank(0x000000000000000000000000000000000000c0DE)
           │   └─ ← [Return]
           ├─ [0] VM::expectRevert(custom error 0xf4844814)
           │   └─ ← [Return]
           ├─ [782] AlchemistCurator::acceptAdminOwnership()
           │   └─ ← [Revert] PD
           ├─ [0] VM::load(AlchemistCurator: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall]
           │   └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000a11ce
           ├─ [0] VM::prank(0x000000000000000000000000000000000000c0DE)
           │   └─ ← [Return]
           ├─ [0] VM::expectRevert(custom error 0xf4844814)
           │   └─ ← [Return]
           ├─ [848] AlchemistCurator::decreaseAbsoluteCap(VaultAdapterStub: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000 [1e18])
           │   └─ ← [Revert] PD
           ├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
           │   └─ ← [Return]
           ├─ [4416] AlchemistCurator::decreaseAbsoluteCap(VaultAdapterStub: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 1000000000000000000 [1e18])
           │   ├─ [318] VaultAdapterStub::getIdData() [staticcall]
           │   │   └─ ← [Return] 1
           │   └─ ← [Revert] EvmError: Revert
           ├─ [521] AlchemistCurator::pendingAdmin() [staticcall]
           │   └─ ← [Return] 0x000000000000000000000000000000000000c0DE
           ├─ [0] VM::load(AlchemistCurator: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall]
           │   └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000a11ce
           └─ ← [Return]
       
       [PASS] testOwnershipTransferFails() (gas: 47236)
       Traces:
         [47236] AlchemistCuratorOwnershipTest::testOwnershipTransferFails()
           ├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
           │   └─ ← [Return]
           ├─ [25525] AlchemistCurator::transferAdminOwnerShip(0x000000000000000000000000000000000000c0DE)
           │   └─ ← [Return]
           ├─ [521] AlchemistCurator::pendingAdmin() [staticcall]
           │   └─ ← [Return] 0x000000000000000000000000000000000000c0DE
           ├─ [0] VM::prank(0x000000000000000000000000000000000000c0DE)
           │   └─ ← [Return]
           ├─ [0] VM::expectRevert(custom error 0xf4844814)
           │   └─ ← [Return]
           ├─ [782] AlchemistCurator::acceptAdminOwnership()
           │   └─ ← [Revert] PD
           ├─ [0] VM::load(AlchemistCurator: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall]
           │   └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000a11ce
           └─ ← [Return]
       
       Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 20.88ms (12.34ms CPU time)
       
       Ran 1 test suite in 264.95ms (20.88ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)