For boostAmount, the calculation result changes when the values of maxBoost and maxLockingDuration change. By monitoring the changes and adjusting boostAmount by updating the lock, it is possible to withdraw from the protocol (delete the lock) with effectiveBalances[user] remaining.
This allows users to receive rewards permanently without depositing any assets.
Let's look at a specific example
First, we try to create a lock with the following parameters.
At this time, the parameter of getBoostAmount is assumed to be the following.
This will create the following lock.
(Assuming that the lock is executed after queue)
effectiveBalances will be changed as follows.
effectiveBalances[_owner] = 300 (100 + boost:200)
maxBoost or maxLockingDuration updates by protocol. If advantageous, perform the update.Suppose the protocol changes the maxLockingDuration to 5 year.
Then, update the lock as follows.
Since this is a secondary chain, the process goes into queue and the storage lock is not updated yet.
At this time, boostAmount is decreasing while duration is increasing. This phenomenon is very important.
In _executeQueuedLockUpdates, the process is classified by the difference from the status of one previous lock.
int256 baseAmountDiff = int256(updateLockState.amount) - int256(curLockState.amount);
int256 boostAmountDiff = int256(updateLockState.boostAmount) - int256(curLockState.boostAmount);
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L465-L466
The actual values are as follows
Then, since baseAmountDiff == 0 and boostAmountDiff < 0, it is classified as the following process.
} else if (boostAmountDiff < 0) {
locks[lockId].expiry = updateLockState.expiry;
locks[lockId].boostAmount = 0;
emit InitiateUnlock(_owner, lockId, updateLockState.expiry);
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L480-L483
The update process should have been performed, but InitiateUnlock was performed.
Therefore, locks[lockId].boostAmount = 0, but effectiveBalances[_owner] is not changed.(In truth, it should be -200).
Also, updateLockState.expiry = 0, so locks[lockId].expiry = 0.
After this process, locks[lockId] becomes the following.
(From the initial state, only the expiry and boostAmount are changed.)
effectiveBalances did not change, so they remain initial.
effectiveBalances[_owner] = 300 (100 + boost:200)
locks[lockId].expiry = 0, so we need to initiateUnlock.(Curiously, this would be initiateUnlock after InitiateUnlock).
There is a process to reduce effectiveBalances, but since lock.boostAmount is already 0, there is no change.
uint64 expiry = uint64(block.timestamp) + halfDuration;
lock.expiry = expiry;
uint256 boostAmount = lock.boostAmount;
lock.boostAmount = 0;
effectiveBalances[msg.sender] -= boostAmount;
totalEffectiveBalance -= boostAmount;
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L187-L193
The state of the lock at this time is as follows (still in the queue stage)
When this is executed, the following process is performed because baseAmountDiff==0 and boostAmountDiff==0.
Interestingly, despite the processing of initiateUnlock, the UpdateLock process is now being executed.
} else {
locks[lockId] = updateLockState;
uint256 totalDiff = uint256(baseAmountDiff + boostAmountDiff);
effectiveBalances[_owner] += totalDiff;
totalEffectiveBalance += totalDiff;
emit UpdateLock(
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L484-L489
Since totalDiff is zero, effectiveBalances[_owner] does not change.
In other words, it remains as below.
effectiveBalances[_owner] = 300 (100 + boost:200)
All that's left is to wait for the lock period to pass and withdraw it.
At this time, effectiveBalances[msg.sender] finally decreases.
However, it is only by the amount of lock.amount(=100).
uint256 baseAmount = lock.amount;
if (_amount > baseAmount) revert InsufficientBalance();
lock.amount = baseAmount - _amount;
effectiveBalances[msg.sender] -= _amount;
totalEffectiveBalance -= _amount;
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L227-L232
In other words, effectiveBalances are as follows.
effectiveBalances[_owner] = 200 (0 + boost:200).
When withdraw is executed, the lock is removed and the sdlToken is returned.
if (baseAmountDiff < 0) {
emit Withdraw(_owner, lockId, uint256(-1 * baseAmountDiff));
if (updateLockState.amount == 0) {
delete locks[lockId];
delete lockOwners[lockId];
balances[_owner] -= 1;
if (tokenApprovals[lockId] != address(0)) delete tokenApprovals[lockId];
emit Transfer(_owner, address(0), lockId);
} else {
locks[lockId].amount = updateLockState.amount;
}
sdlToken.safeTransfer(_owner, uint256(-1 * baseAmountDiff));
https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L468-L479
As a result, effectiveBalances[_owner] = 200 will remain forever.
In this example, the amount is small for the sake of clarity, but it is very dangerous because if the amount is large, the owner can receive a very large reward as much as he/she wants.
Receive large rewards forever without risk.
Manual
This complex attack path is a combination of several characteristics.
The protocol's configurable values that reduce boostAmount.
There are solutions such as fixing maxLockingDuration and maxBoost.
It is possible to extend duration by 1 sec.
There is no use for this except for abuse. There is a way for the protocol to provide several fixed durations and let the user choose from them.
The action that the user puts in the queue and the action that is executed are different.
Instead of classifying execute actions by increasing/decreasing amount, there is a way to manage them by initiateUnlock flag, update flag, etc.
Timing of increase/decrease of effectiveBalances depends on the operation.
effectiveBalances increase/decrease timing is divided into two types: initiateUnlock when queued and update when executed. Either of them should be unified.
A user can delete the lock even though the effectiveBalances have not been sufficiently reduced.
effectiveBalances added by lock should be reduced by that amount before lock can be deleted.
In addition to managing by effectiveBalances[user], it is also possible to manage by lockEffectiveBalance[lockId], etc.
The solutions are manifold, but in any case, we must be checked after modification to ensure that this deadly attack path does not remain.
negative boost diff caused by lowering max boost or increasing max duration can trigger unlocks
negative boost diff caused by lowering max boost or increasing max duration can trigger unlocks
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.