29120 - [SC - High] Bug in reward distribution logic leads to theft...

Submitted on Mar 7th 2024 at 17:51:17 UTC by @Trust for Boost | ZeroLend

Report ID: #29120

Report type: Smart Contract

Report severity: High

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

Impacts:

  • Theft of unclaimed yield

  • Permanent freezing of unclaimed yield

Description

Brief/Intro

The PoolVoter stores and distributes rewards for gauges based on their weights. The function below does distribution:

function distribute(address _gauge) public nonReentrant {
    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;
    }
}

One supported type of gauge is LendingPoolGauge. It splits rewards in 1/4, 3/4 ratio between supply and borrow gauge.

Note that each GaugeIncetiveController (Supply / borrow gauge) has their own notifyRewardAmount(). It's logic states that if the new reward is smaller than the current reward, it does not pick up the reward and returns false.

Vulnerability Details

The issue is in the mishandling of the response in distribute() when notifying the LendingPoolGauge of rewards. When notifyRewardAmount() returns false, we treat the entire notified amount as not sent. This is correct for most gauges, but for LendingPoolGauge it could be that one of the sub-gauges received the funds succesfully and one didn't. When that happens, notifyRewardAmount() returns false:

This breaks the invariant that there's always enough rewards in PoolVoter to satisfy reward dispatch - since some are sent but claimable remains unchanged, it follows that the reward mechanism is insolvent. It can also be abused by attackers to collect rewards over and over again, for free.

Impact Details

Rewards can be misappropriated by an attacker or through natural sequence of events. Users will lose access to unclaimed yield.

Proof of Concept

The POC is implemented as a standalone file. Simply run attack() on DistributorPOC which shows that funds were sent to the supply gauge, but the claimable for LendingPoolGauge remains unchanged.

Last updated

Was this helpful?