Platform: Code4rena
Start Date: 09/12/2021
Pot Size: $25,000 USDC
Total HM: 12
Participants: 25
Period: 4 days
Judge: LSDan
Total Solo HM: 4
Id: 64
League: ETH
Rank: 5/25
Findings: 6
Award: $1,071.87
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: gpersoon
Also found by: 0xabcc, csanuragjain, harleythedog, kenzo, leastwood
kenzo
There is no check that the user supplied epochIds are within the amount of epochs the promotion defines.
Loss of user funds. After promotion has concluded, user can claim illegitimate rewards for later epochs which are not part of the promotion.
In claimRewards
, The only validation done on the user supplied epoch is that it has not been claimed. (Code Ref)
In _calculateRewardAmount
, there is no validation either - only checking that the supplied epoch has finished: (Code ref)
require(block.timestamp > _epochEndTimestamp, "TwabRewards/epoch-not-over");
This means that if the promotion was only configured for 1 epoch, if user has supplied the invalid epoch #2 (after it "ended"), it will pass all the checks and contract will send to the user funds according to his TWAB - although these funds belong to earlier valid epochs or to other promotions.
In claimRewards
, add inside the loop:
require(_epochId < _promotion.numberOfEpochs,"TwabRewards/invalid-epochId");
This solution will also prevent funkiness around creating a promotion with numberOfEpochs=0. I think I won't submit the creation of a promotion with numberOfEpochs=0 as a seperate issue because (as I see at the moment) by itself it's not harmful, it's harm comes from the issue I outlined here.
#0 - PierrickGT
2021-12-11T00:02:56Z
kenzo
cancelPromotion
deletes the promotion from the promotions data.
This is premature, and means that users wouldn't be able to claim rewards for epochs that have already been finished and not yet claimed.
Loss of funds. The users wouldn't be able to claim their rewards, and the rewards will remain locked in the contract.
cancelPromotion
deletes the promotion from the promotion list: (Code ref)
delete _promotions[_promotionId];
If a user will then rightfully try to claim a reward for an epoch that has finished from the promotion, claimRewards
will grab the promotion's data from _promotions
. (Code ref #1, #2)
But as we've seen, that data has been emptied. So when _calculateRewardAmount
will try to calculate the reward for the finished epoch, all promotion data (for example including tokensPerEpoch
) will be 0. I believe that anyway TwabLib
will return 0 for anything as _epochStartTimestamp == _epochEndTimestamp
and the function will return 0. (Code ref #1, #2)
Do not delete the promotion data. You can set a boolean that marks whether the promotion was deleted or not.
#0 - PierrickGT
2021-12-10T17:24:44Z
🌟 Selected for report: johnnycash
Also found by: WatchPug, csanuragjain, gpersoon, gzeon, harleythedog, kemmio, kenzo, leastwood, pauliax
kenzo
An attacker can supply createPromotion
with his own malicious Ticket contract.
TWAB values will then be read from this malicious contract.
Total drainage of TwabRewards.
As _calculateRewardAmount
is calculating the reward to send using the TWAB values from the ticket, the malicious ticket could return values that will send all the TwabRewards balance to the attacker, including rewards from other promotions.
When creating a promotion, the promotion creator supplies the ticket, and the only validation is _requireTicket(_ticket)
. (Code ref)
_requireTicket
can be passed easily by a malicious contract - the contract would just need to have a controller
function which returns an address which is not address 0. (Code ref)
Then when calculating the reward amount in _calculateRewardAmount
, the contract calls the user supplied ticket and calculates the amount to send using the results from the ticket: (Code ref)
ITicket _ticket = ITicket(_promotion.ticket); uint256 _averageBalance = _ticket.getAverageBalanceBetween(_user,...) uint256[] memory _averageTotalSupplies = _ticket.getAverageTotalSuppliesBetween(...) return (_promotion.tokensPerEpoch * _averageBalance) / _averageTotalSupplies[0];
Therefore, the malicious ticket could engineer his malicious ticket to return values that will send the whole of TwabRewards' token balance to the attacker.
If only 1 ticket is valid, save that ticket in the contract. Otherwise, save a registry of valid tickets.
#0 - PierrickGT
2021-12-10T17:26:00Z
🌟 Selected for report: johnnycash
Also found by: 0x421f, cmichel, gpersoon, gzeon, harleythedog, kemmio, kenzo, sirhashalot
kenzo
The contract only supports 255 epochs per promotion. However, this is only briefly mentioned in one comment and not enforced during creation/extension of promotions.
Loss of user funds. If a promotion creator (legitimate or malicious) will create or extend a promotion to have more than 255 epochs, the contract won't keep track of which later epochs have been claimed, and a user could repeatedly claim rewards for an epoch and drain the funds.
The only mention I see for the 255 epochs limit of the contract is in this line: [(Code ref])(https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L34)
/// @dev We pack epochs claimed by a user into a uint256. So we can't store more than 255 epochs.
This is not highly visible and there is no mention in createPromotion
or extendPromotion
that _numberOfEpochs
should be less than 256.
If a promotion creator does create/extend a promotion with more than 255 epochs, when a user will claim an epochId > 255, the contract couldn't record that the epoch has been claimed, because that info is saved in uint256 which has only 256 bits. [(Code ref])(https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L355)
function _updateClaimedEpoch(uint256 _userClaimedEpochs, uint256 _epochId) internal pure returns (uint256) { return _userClaimedEpochs | (uint256(1) << _epochId); }
As this claim wouldn't get saved, the user could repeatedly claim the same epoch, thereby taking other users and promotions' funds.
Add a check to createPromotion
and extendPromotion
that the new promotion's number of epochs is < 256.
#0 - PierrickGT
2021-12-11T00:03:45Z
kenzo
If a promotion creator has created a promotion for the future, and decides to cancel it, he can not do so due to an underflow in a calculation.
Promotion creator funds will be locked until promotion begins.
Promotion's startTimestamp
is supplied in createPromotion
, and can be set to the future as there is no validation on it. [(Code ref])(https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L92)
cancelPromotion
will call _getRemainingRewards
to calculate the awards to refund, and _getRemainingRewards
will call _getCurrentEpochId
to calculate how many epochs are remaining: [(Code ref])(https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L333:#L335)
(_promotion.numberOfEpochs - _getCurrentEpochId(_promotion))
_getCurrentEpochId
runs the following code which will underflow if the promotion's startTimestamp is in the future. [(Code ref])(https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L278)
return (block.timestamp - _promotion.startTimestamp) / _promotion.epochDuration;
In _getRemainingRewards
, if the promotion has not started, return all the rewards.
#0 - PierrickGT
2021-12-13T17:01:35Z
75.6973 USDC - $75.70
kenzo
User supplied values are not checked and can lead to unexpected behavior (such as division by 0, underflows...)
I believe the high risk impact has been detailed and mitigated in other findings. However, for cleanliness and preventive measures, I suggest not allowing illogical inputs.
There is no validation on the user supplied promotion inputs. (Code ref) Therefore for example, a user can supply _numberOfEpochs = 0, _epochDuration = 0, _tokensPerEpoch = 0. This leads to garbage values in the contract. A user can create a promotion without paying any tokens (if _numberOfEpochs or _tokensPerEpoch = 0). These may confuse front ends, or compound to lead to more serious errors.
Add sanity checks (such as inputs > 0) to createPromotion
and extendPromotion
.