stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: medium
Valid

Attacker can exploit lock update logic on secondary chains to increase the amount of rewards sent to a specific secondary chain

Summary

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).

Vulnerability Details

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:

function _queueLockUpdate(
address _owner,
uint256 _lockId,
uint256 _amount,
uint64 _lockingDuration
) internal onlyLockOwner(_lockId, _owner) {
Lock memory lock = _getQueuedLockState(_lockId);
@> LockUpdate memory lockUpdate = LockUpdate(updateBatchIndex, _updateLock(lock, _amount, _lockingDuration));
queuedLockUpdates[_lockId].push(lockUpdate);
@> queuedRESDLSupplyChange +=
int256(lockUpdate.lock.amount + lockUpdate.lock.boostAmount) -
int256(lock.amount + lock.boostAmount);
...
}

As part of this function call, _updateLock is triggered to perform this update, which is defined as follows:

function _updateLock(
Lock memory _lock,
uint256 _amount,
uint64 _lockingDuration
) internal view returns (Lock memory) {
@> if ((_lock.expiry == 0 || _lock.expiry > block.timestamp) && _lockingDuration < _lock.duration) {
revert InvalidLockingDuration();
}
Lock memory lock = Lock(_lock.amount, _lock.boostAmount, _lock.startTime, _lock.duration, _lock.expiry);
uint256 baseAmount = _lock.amount + _amount;
@> uint256 boostAmount = boostController.getBoostAmount(baseAmount, _lockingDuration);
...
lock.boostAmount = boostAmount;
...
}

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:

...
uint256 numUpdates = queuedLockUpdates[lockId].length;
Lock memory curLockState = locks[lockId];
uint256 j = 0;
while (j < numUpdates) {
if (queuedLockUpdates[lockId][j].updateBatchIndex > finalizedBatchIndex) break;
Lock memory updateLockState = queuedLockUpdates[lockId][j].lock;
int256 baseAmountDiff = int256(updateLockState.amount) - int256(curLockState.amount);
@> int256 boostAmountDiff = int256(updateLockState.boostAmount) - int256(curLockState.boostAmount);
if (baseAmountDiff < 0) {
...
@> } else if (boostAmountDiff < 0) {
@> locks[lockId].expiry = updateLockState.expiry;
@> locks[lockId].boostAmount = 0;
@> emit InitiateUnlock(_owner, lockId, updateLockState.expiry);
} else {
...
}
...
}
...

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:

queuedRESDLSupplyChange +=
int256(lockUpdate.lock.amount + lockUpdate.lock.boostAmount) -
int256(lock.amount + lock.boostAmount);

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.

Impact

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.

Tools Used

Manual review

Recommendations

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.

Updates

Lead Judging Commences

0kage Lead Judge
over 1 year ago
0kage Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

negative boostdiff

negative boost diff caused by lowering max boost or increasing max duration can trigger unlocks

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.