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.