Users with existing reSDL NFTs on secondary chains (prior to a decrease in maxBoost) are able to increase queuedRESDLSupplyChange by a greater amount than should be possible given the current maxBoost value, which then allows them to funnel more rewards to their secondary chain (as queuedRESDLSupplyChange maps to reSDLSupplyByChain[...], which is used to calculate the rewards distributed to each secondary chain).
Consider the scenario in which the stake.link team is decreasing the maxBoost value of the LinearBoostController so that newer depositors will get less rewards than OG depositors. This will allow an attacker on a secondary chain to perform the following attack to fraudulently increase the amount of rewards sent to their chain:
We will assume for simplicity that the starting values for the LinearBoostController contract is a maxBoost=10 and maxLockingDuration = 10_000 seconds. The attacker starts with a single (for simplicity) reSDL NFT on a secondary chain which has amount=100_000 and lockingDuration= 5_000 seconds, meaning their boost is calculated to be: 100_000 * 10 * 5_000/10_000 = 500_000.
Then, the stake.link team decreases maxBoost to 5. Following this, the attacker will first call SDLPoolSecondary:extendLockDuration with a _lockingDuration of 9_999, which then calls the internal _queueLockUpdate, which is defined as follows:
As part of this function call, _updateLock is triggered to perform this update, which is defined as follows:
Most important to note here is that (1) since the _lockingDuration of 9_999 is greater than the existing duration of 5_000, this call will succeed, and (2) the boostAmount is recalculated now using the new maxBoost value of 5. We can calculate the new attacker's boostAmount to be: 100_000 * 5 * 9_9999/10_000 = 499_950. Since this value is less than the previous 500_000, queuedRESDLSupplyChange in the _queueLockUpdate call will be decremented by 50.
After the SDLPoolSecondary:extendLockDuration function call is complete, this update will be queued. At some point an update to this secondary SDL pool will be triggered & once that's complete, the attacker will then be able to execute this update. To do so, the attacker calls executeQueuedOperations, specifying their reNFT, which then triggers _executeQueuedLockUpdates which has the following logic:
Recall that the attacker only has a single update, with the only difference being the decrease of 50 for the boostAmount. This will trigger the logic based on the boostAmountDiff < 0 statement which will set locks[lockId].boostAmount = 0. This is clearly incorrect logic & will allow the attacker to then fraudulently increase queuedRESDLSupplyChange, which will ultimately lead to more rewards going to this secondary chain.
Continuing this attack, the attacker will again call SDLPoolSecondary:extendLockDuration, but this time with a _lockingDuration of 10_000. Referencing the same code snippet as earlier, in _updateLock, boostAmount is now being calculated as: 100_000 * 5 * 10_000/10_000 = 500_000. In _queueLockUpdate, queuedRESDLSupplyChange is calculated to be: (100_000 + 500_000) - (100_000 + 0) = 500_000, based on this equation:
Recall that this value of 0 comes from the improper logic in the _executeQueuedLockUpdates function call. Ultimately, in aggregate, queuedRESDLSupplyChange has been increased by 500_000 - 50 = 499_950. Had the attacker simply increased their locking duration to the max value of 10_000 after the update, there would be 0 change in the queuedRESDLSupplyChange.
The fundamental bug here is that post a decrease in maxBoost, the update logic allows all existing reSDL NFTs to be able to increase queuedRESDLSupplyChange more than should be possible, & queuedRESDLSupplyChange is a major factor in terms of the percentage of rewards going to a given secondary chain.
Users with existing reSDL NFTs on secondary chains (prior to a decrease in the maxBoost) are able to increase queuedRESDLSupplyChange by a greater amount than should be possible given the current maxBoost value, which then allows them to funnel more rewards to their secondary chain.
Manual review
The _executeQueuedLockUpdates function implicitly assumes if there's a decrease in boostAmountDiff then the lock update comes from calling initiateUnlock. There needs to be an additional case to handle this scenario due to a decrease in the maxBoost.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.