29121 - [SC - High] Any rewards sent to the PoolVoter will be undis...

Submitted on Mar 7th 2024 at 18:36:15 UTC by @Trust for Boost | ZeroLend

Report ID: #29121

Report type: Smart Contract

Report severity: High

Target: https://github.com/zerolend/governance

Impacts:

  • Permanent freezing of unclaimed yield

Description

Brief/Intro

The PoolVoter distributes reward tokens to different gauges. Gauges are registered below:

function registerGauge(
    address _asset,
    address _gauge
) external onlyOwner returns (address) {
    if (isPool[_asset]) {
        _pools.push(_asset);
        isPool[_asset] = true;
    }
    bribes[_gauge] = address(0);
    gauges[_asset] = _gauge;
    poolForGauge[_gauge] = _asset;
    _updateFor(_gauge);
    return _gauge;
}

Distribution of rewards other than reward token are done through distributeEx():

function distributeEx(
    address token,
    uint256 start,
    uint256 finish
) public nonReentrant {
    uint256 _balance = IERC20(token).balanceOf(address(this));
    if (_balance > 0 && totalWeight > 0) {
        uint256 _totalWeight = totalWeight;
        for (uint256 x = start; x < finish; x++) {
            uint256 _reward = (_balance * weights[_pools[x]]) /
                _totalWeight;
            if (_reward > 0) {
                address _gauge = gauges[_pools[x]];
                IERC20(token).approve(_gauge, 0); // first set to 0, this helps reset some non-standard tokens
                IERC20(token).approve(_gauge, _reward);
                IGauge(_gauge).notifyRewardAmount(token, _reward); // can return false, will simply not distribute tokens
            }
        }
    }
}

The weights are accessed according to the _pools[] entry populated by registerGauge()

Vulnerability Details

Distribution will always fail because there's wrong logic when registering gauges. Specifically this part will never be executed:

if (isPool[_asset]) {
    _pools.push(_asset);
    isPool[_asset] = true;
}

The intention is to use !isPool[_asset].

This means _pools will never be populated. After funds are sent to the PoolVoter for dispatch, they can never be claimed by any gauge. There is no escape hatch to unfreeze the rewards.

Impact Details

Rewards sent to the PoolVoter will be forever stuck.

Proof of Concept

The POC is implemented in a single file. Simply run DistributorPOC's PoolFailPOC() which shows that _pools stays empty.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.6;

interface IRewardDistributor {
    // used to notify a gauge/bribe of a given reward, this can create griefing attacks by extending rewards
    // TODO: rework to weekly resets, _updatePeriod as per v1 bribes
    function notifyRewardAmount(
        address token,
        uint amount
    ) external returns (bool);
}

interface IRewardBase {
    function incentivesLength() external view returns (uint);

    // returns the last time the reward was modified or periodFinish if the reward has ended
    function lastTimeRewardApplicable(
        address token
    ) external view returns (uint);

    // how to calculate the reward given per token "staked" (or voted for bribes)
    function rewardPerToken(address token) external view returns (uint);

    // how to calculate the total earnings of an address for a given token
    function earned(
        address token,
        address account
    ) external view returns (uint);

    // total amount of rewards returned for the 7 day duration
    function getRewardForDuration(address token) external view returns (uint);

    // allows a user to claim rewards for a given token
    function getReward(address token) external;

    // used to notify a gauge/bribe of a given reward, this can create griefing attacks by extending rewards
    // TODO: rework to weekly resets, _updatePeriod as per v1 bribes
    function notifyRewardAmount(
        address token,
        uint amount
    ) external returns (bool);
}

// Gauges are used to incentivize pools, they emit reward tokens over 7 days for staked LP tokens
// Nuance: getReward must be called at least once for tokens other than incentive[0] to start accrueing rewards
interface IGauge is IRewardBase {
    function rewardPerToken(
        address token
    ) external view override returns (uint);

    // used to update an account internally and externally, since ve decays over times, an address could have 0 balance but still register here
    function kick(address account) external;

    function derivedBalance(address account) external view returns (uint);

    function earned(
        address token,
        address account
    ) external view override returns (uint);

    function deposit(uint amount, address account) external;

    function withdraw() external;

    function withdraw(uint amount) external;

    function exit() external;
}

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

// Gauges are used to incentivize pools, they emit reward tokens over 14 days for staked LP tokens
// Nuance: getReward must be called at least once for tokens other than incentive[0] to start accrueing rewards
contract LendingPoolGauge is IRewardDistributor {
    using SafeERC20 for IERC20;
    IRewardDistributor public supplyGauge;
    IRewardDistributor public borrowGauge;

    constructor(address _supplyGauge, address _borrowGauge) {
        supplyGauge = IRewardDistributor(_supplyGauge);
        borrowGauge = IRewardDistributor(_borrowGauge);
    }

    function notifyRewardAmount(
        address token,
        uint256 amount
    ) external returns (bool) {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);

        // send 1/4 to the supply side
        IERC20(token).approve(address(supplyGauge), amount);
        bool a = supplyGauge.notifyRewardAmount(token, amount / 4);

        // send 3/4th to the borrow side
        IERC20(token).approve(address(borrowGauge), amount);
        bool b = borrowGauge.notifyRewardAmount(token, (amount / 4) * 3);

        return a && b;
    }
}


contract MockIncentiveController {

    uint capacity;

    constructor(uint cap)  {
        capacity = cap;
    }

    function notifyRewardAmount(
    IERC20 token,
    uint256 amount
) external returns (bool) {
    if(token.balanceOf(address(this)) + amount > capacity )
        return false;
    token.transferFrom(msg.sender, address(this), amount);
    return true;
    }
}

contract Distributor {

    constructor(IERC20 rewardToken) {
        reward = rewardToken;
        owner = msg.sender;
    }

    modifier onlyOwner() {
        if (msg.sender != owner)
            revert("Not owner");
        _;
    }

    mapping(address => uint256) public claimable;
    IERC20 public reward;
    address owner;

    address[] internal _pools; // all pools viable for incentives
    mapping(address => address) public gauges; // pool => gauge
    mapping(address => bool) public isPool; // pool => bool
    mapping(address => address) public poolForGauge; // pool => gauge
    mapping(address => address) public bribes; // gauge => bribe
    mapping(address => uint256) public weights; // pool => weight
    mapping(address => mapping(address => uint256)) public votes; // nft => votes
    mapping(address => address[]) public poolVote; // nft => pools
    mapping(address => uint256) public usedWeights; // nft => total voting weight of user
    uint256 public index;
    mapping(address => uint256) public supplyIndex;
    
    function pools() external view returns (address[] memory) {
        return _pools;
    }


    function distribute(address _gauge) public {
        uint256 _claimable = claimable[_gauge];
        claimable[_gauge] = 0;
        IERC20(reward).approve(_gauge, 0); // first set to 0, this helps reset some non-standard tokens
        IERC20(reward).approve(_gauge, _claimable);
        if (!IGauge(_gauge).notifyRewardAmount(address(reward), _claimable)) {
            // can return false, will simply not distribute tokens
            claimable[_gauge] = _claimable;
        }
    }

    function set_claimable(address gauge, uint256 claim) external {
        claimable[gauge] = claim;
    }

    function registerGauge(
        address _asset,
        address _gauge
    ) external onlyOwner returns (address) {
        if (isPool[_asset]) {
            _pools.push(_asset);
            isPool[_asset] = true;
        }
        bribes[_gauge] = address(0);
        gauges[_asset] = _gauge;
        poolForGauge[_gauge] = _asset;
        //_updateFor(_gauge);
        return _gauge;
    }
}

contract Zero is ERC20 {
    constructor() ERC20("Zero","ZRO") {
        _mint(msg.sender, 100_000 * 1e18);
    }
}

contract DistributorPOC {
    MockIncentiveController supply_gauge;
    MockIncentiveController borrow_gauge;
    LendingPoolGauge lending_gauge;
    Zero zero;
    Distributor d;

    constructor() {
        supply_gauge = new MockIncentiveController(10e18);
        borrow_gauge = new MockIncentiveController(10e18);
        lending_gauge = new LendingPoolGauge(address(supply_gauge), address(borrow_gauge));
        zero = new Zero();
        d = new Distributor(zero);
        zero.transfer(address(d), 100e18);
    }

    function attack() external {
        d.set_claimable(address(lending_gauge), 30e18);
        d.distribute(address(lending_gauge));

        require(zero.balanceOf(address(supply_gauge)) == 7.5e18);
        require(zero.balanceOf(address(borrow_gauge)) == 0);

        require(d.claimable(address(lending_gauge)) == 30e18);        
    }

    function PoolFailPOC() external {
        require(d.pools().length == 0);
        d.registerGauge(address(0), address(1));
        require(d.pools().length == 0);

    }
}

Last updated