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: 2/25
Findings: 6
Award: $2,155.15
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: csanuragjain
leastwood
The createPromotion
function is called by a creator
account (denoted as msg.sender
) to fund a promotion with tokens allocated on a per epoch basis across a set epochs. However, the function does not perform the necessary checks on function inputs to ensure the promotion is able to function as intended.
In may be useful to ensure the inputs are within bounded ranges, i.e. _startTimestamp > block.timestamp
and _epochDuration
spans some minimum amount.
function createPromotion( address _ticket, IERC20 _token, uint216 _tokensPerEpoch, uint32 _startTimestamp, uint32 _epochDuration, uint8 _numberOfEpochs ) external override returns (uint256) { _requireTicket(_ticket); uint256 _nextPromotionId = _latestPromotionId + 1; _latestPromotionId = _nextPromotionId; _promotions[_nextPromotionId] = Promotion( msg.sender, _ticket, _token, _tokensPerEpoch, _startTimestamp, _epochDuration, _numberOfEpochs ); _token.safeTransferFrom(msg.sender, address(this), _tokensPerEpoch * _numberOfEpochs); emit PromotionCreated(_nextPromotionId); return _nextPromotionId; }
Manual code review.
Consider adding necessary input validation to the createPromotion
function.
#0 - PierrickGT
2021-12-13T16:02:27Z
🌟 Selected for report: gpersoon
Also found by: 0xabcc, csanuragjain, harleythedog, kenzo, leastwood
leastwood
claimRewards
allows a user to collect their TWAB calculated rewards for a provided set of epochIds
. The contract utilises a _claimedEpochs
mapping which tracks claimed rewards per user. Each claimed epoch is represented by a single bit within a uint256
variable. When an epoch for a promotion has been claimed, the epoch's allotted bit is updated from 0
to 1
. As the numberOfEpochs
variable is restricted to a uint8
type, the maximum number of epochs a promotion can have is 255, which fits within the available bits of a uint256
type.
However, if block.timestamp
exceeds a promotion's last epoch, users can continue to call claimRewards
on an epochId
which exceeds the last epoch. Hence, rewards will continue to be paid out even after a promotion has become inactive.
This severely impacts the correct distribution of rewards between users, potentially leading to a loss of funds for users who don't actively claim after a promotion has ended.
The POC consists of a series of steps an attacker must follow in order to exploit this contract:
createPromotion
using valid inputs._numberOfEpochs
was set to 10
and _tokensPerEpoch
was set to 10
. Hence 100
tokens was transferred to the TwabRewards
contract.100
tokens from another active promotion.claimRewards
is called on a given epoch, the user receives the entire allotted amount which is 10
tokens.10
epochs pass and the user calls claimRewards
on all the unclaimed epochs, receiving a total of 100
tokens in return.claimRewards
on epochId
of 11
._isClaimedEpoch(_userClaimedEpochs, _epochId)
returns false
as the user has not claimed this epoch. _calculateRewardAmount
proceeds to calculate that another 10
tokens is to be rewarded to the user.claimRewards
. This can be done up until epochId = 255
.As is outlined, it can be seen that any user could abuse this and continue to claim rewards even though the promotion in which they are a ticket holder of, has ended.
Manual code review.
Consider performing a check in _calculateRewardAmount
to ensure _epochEndTimestamp
does not exceed the final epoch's timestamp for a given promotion. This should ensure users cannot unfairly call claimRewards
and either steal tokens from other promotions or steal tokens from other users who should be receiving their fair share of the promotion's reward tokens.
The following code in _calculateRewardAmount
can be updated as per below to prevent the discussed issue:
uint256 _epochDuration = _promotion.epochDuration; uint256 _epochStartTimestamp = _promotion.startTimestamp + (_epochDuration * _epochId); uint256 _epochEndTimestamp = _epochStartTimestamp + _epochDuration; uint256 _promotionEndTimestamp = _promotion.startTimestamp + (_promotion.epochDuration * _promotion.numberOfEpochs); require( _promotionEndTimestamp > 0 && _promotionEndTimestamp >= _epochEndTimestamp, "TwabRewards/promotion-not-active" ); require(block.timestamp > _epochEndTimestamp, "TwabRewards/epoch-not-over");
#0 - PierrickGT
2021-12-13T16:26:42Z
🌟 Selected for report: johnnycash
Also found by: WatchPug, csanuragjain, gpersoon, gzeon, harleythedog, kemmio, kenzo, leastwood, pauliax
leastwood
The createPromotion
allows any user to create and fund promotions for a specific number of epochs. Ticket holders are entitled to a percentage of the rewards based on their TWAB. createPromotion
references a _ticket
address which can be controlled by the creator
account. The requireTicket
function is meant to validate the _ticket
address, however, this can be bypassed by setting _ticket
to a user controlled contract which returns valid values. Consequently, the malicious user can alter the result returned from _calculateRewardAmount
and effectively drain the balance of ANY target token.
The affected function, claimRewards
, does not check that the _rewardsAmount
sent to a user is <=
to to total rewards declared when creating a promotion. As a result, there is a risk that users can lose funds, hence this issue should be of high severity.
The POC consists of a series of steps an attacker must follow in order to exploit this contract and drain it of its token balance.
_requireTicket
function. This contract must simply return a non-zero controllerAddress
when a staticcall
is made on the controller.selector
.createPromotion
function are provided, including a non-zero amount _tokensPerEpoch
and target token _token
. The function will transfer some small non-zero amount from the attacker.epoch
passes and the creator of the promotion (who is also a ticket holder), calls claimRewards
on the epochId
which has just passed.claimRewards
function iterates through the single epochId
and makes an internal call to _calculateRewardAmount
_ticket
contract, which allows them to control the return values for the two functions _ticket.getAverageTotalSuppliesBetween
and _ticket.getAverageBalanceBetween
in _calculateRewardAmount
. The following calculation dictates the returned reward amount.return (_promotion.tokensPerEpoch * _averageBalance) / _averageTotalSupplies[0];
_rewardsAmount
is <=
to the number of tokens provided to the user's promotion, the attacker can control this value and set it to an amount larger than their provided token amount. Therefore, the attacker can effectively withdraw ANY amount of tokens from the TwabRewards
contract, belonging to target token _token
._token
arguments to drain the contract balance for ALL tokens.Manual code review.
Consider whitelisting _ticket
contracts within TwabRewards
. Alternatively, a more succinct solution could be to track the total rewards claimed for a given promotion. This should prevent more tokens from being sent out than provider by the promotion's creator.
#0 - PierrickGT
2021-12-13T16:07:08Z
378.4865 USDC - $378.49
leastwood
Users who have a small claim on rewards for various promotions, may not feasibly be able to claim these rewards as gas costs could outweigh the sum they receive in return. Hence, it is likely that a dust balance accrues overtime for tokens allocated for various promotions. Additionally, the _calculateRewardAmount
calculation may result in truncated results, leading to further accrual of a dust balance. Therefore, it is useful that these funds do not go to waste.
Manual code review.
Consider allowing an admin
account to skim a promotion's tokens if it has been inactive for a certain length of time. There are several potential implementations, in varying degrees of complexity. However, the solution should attempt to maximise simplicity while minimising the accrual of dust balances.
🌟 Selected for report: leastwood
841.0811 USDC - $841.08
leastwood
The claimRewards
function is called upon by ticket holders who parse a set of _epochIds
they wish to claim rewards on. An internal call is made to _calculateRewardAmount
to calculate the correct reward amount owed to the user. Subsequently, the _updateClaimedEpoch
function will set the epoch bit of the tracked _claimedEpochs
mapping, ensuring an epochId
cannot be claimed twice for a given promotion.
However, there may be inaccuracies in the _calculateRewardAmount
function, which results in more tokens being sent out than allocated by a promotion creator. This severely impacts the ability for users to claim their owed tokens on other promotions.
function claimRewards( address _user, uint256 _promotionId, uint256[] calldata _epochIds ) external override returns (uint256) { Promotion memory _promotion = _getPromotion(_promotionId); uint256 _rewardsAmount; uint256 _userClaimedEpochs = _claimedEpochs[_promotionId][_user]; for (uint256 index = 0; index < _epochIds.length; index++) { uint256 _epochId = _epochIds[index]; require( !_isClaimedEpoch(_userClaimedEpochs, _epochId), "TwabRewards/rewards-already-claimed" ); _rewardsAmount += _calculateRewardAmount(_user, _promotion, _epochId); _userClaimedEpochs = _updateClaimedEpoch(_userClaimedEpochs, _epochId); } _claimedEpochs[_promotionId][_user] = _userClaimedEpochs; _promotion.token.safeTransfer(_user, _rewardsAmount); emit RewardsClaimed(_promotionId, _epochIds, _user, _rewardsAmount); return _rewardsAmount; }
Manual code review.
Consider checking that the total rewards claimed for a given promotion is strictly <=
than the total allotted balance provided by the promotion creator. This should help prevent a single promotion from affecting the rewards claimable from other promotions.
#0 - PierrickGT
2021-12-16T00:07:11Z
This check seems redundant, especially now that we have restricted the contract to only support one ticket (in this PR), this will avoid a promotion with a fake ticket to manipulate the amount of rewards claimable and an attacker won't be able to drain funds from a promotion. So for this reason, I've acknowledged the issue but we won't implement this check.
🌟 Selected for report: robee
Also found by: 0x0x0x, defsec, leastwood, pmerkleplant
13.1075 USDC - $13.11
leastwood
Gas savings can be generated by storing epochIds.length
outside of the for
loop, as the index < epochIds.length
comparison is done more efficiently.
for (uint256 index = 0; index < _epochIds.length; index++) { uint256 _epochId = _epochIds[index]; require( !_isClaimedEpoch(_userClaimedEpochs, _epochId), "TwabRewards/rewards-already-claimed" ); _rewardsAmount += _calculateRewardAmount(_user, _promotion, _epochId); _userClaimedEpochs = _updateClaimedEpoch(_userClaimedEpochs, _epochId); }
Manual code review.
Consider storing epochIds.length
in a state variable defined outside of the for
loop. This should generate gas savings on each iteration of the for
loop.
#0 - PierrickGT
2021-12-13T16:09:37Z
26.9701 USDC - $26.97
leastwood
Structs utilise slots sized at 256 bits when storing data. Efficiently packing these slots facilitates cheaper struct storage read and writes.
struct Promotion { address creator; address ticket; IERC20 token; uint216 tokensPerEpoch; uint32 startTimestamp; uint32 epochDuration; uint8 numberOfEpochs; }
Can be changed to
struct Promotion { address creator; address ticket; IERC20 token; uint184 tokensPerEpoch; uint32 startTimestamp; uint32 epochDuration; uint8 numberOfEpochs; }
For more efficient struct packing.
Manual code review.
Consider implementing the suggestion above.
#0 - PierrickGT
2021-12-13T16:07:58Z