Open c4-bot-8 opened 11 months ago
DadeKuma marked the issue as duplicate of #241
DadeKuma marked the issue as duplicate of #406
DadeKuma marked the issue as sufficient quality report
0xean marked the issue as satisfactory
0xean changed the severity to 2 (Med Risk)
Two of my issues have been set as duplicate of 406 which seems to be a mistake. One issue is clearly a duplciate (270), so that's good, but not the current submission, which is addressing a different gas issue (see impacts), so when moving wZETA from an external chain to another external chain (or the same external chain as the smoketest TestMessagePassingRevertSuccess
do) using the ZetaChain, while 406
is when sending ZETA from ZetaChain itself to an external chain, the flow, impacts and the issue are different
.
The current submission expose how the refund mechanism in the indicated scenario will be using the full amount of gas limit specified when calling the send
function originally, which can be very high, and could cause a fund user loss
(in case the amount of gas is greater than the ZETA value being transfered) or pay excessive gas (in case ZETA value is greater then the amount of gas). This happen where the devs believe a code path is being dead
, while it's not the case as proven by my PoC, but also with the following smoketest.
I did run the smoketest TestMessagePassingRevertSuccess
which is exploiting this code path with 3 scenarios (250k, 1M and 20M gas limit).
1) I've modified source code of TestDApp::sendHelloWorld
to use 1M and 20M as destinationGasLimit
and recompiled it
function sendHelloWorld(address destinationAddress, uint256 destinationChainId, uint256 value, bool doRevert) external payable {
bool success1 = IERC20(zeta).approve(address(connector), value);
bool success2 = IERC20(zeta).transferFrom(msg.sender, address(this), value);
if (!(success1 && success2)) revert ErrorTransferringZeta();
ZetaConnector(connector).send(
ZetaInterfaces.SendInput({
destinationChainId: destinationChainId,
destinationAddress: abi.encodePacked(destinationAddress),
- destinationGasLimit: 250000,
+ destinationGasLimit: 1000000,
message: abi.encode(HELLO_WORLD_MESSAGE_TYPE, doRevert),
zetaValueAndGas: value,
zetaParams: abi.encode("")
})
);
}
2) I've modified the TestDAppMetaData.Bin
to the corresponding recompiled binary.
var TestDAppMetaData = &bind.MetaData{
ABI: "[{\"inputs\":[{\"internalType\ ...",
- Bin: "0x608060405234801561001057600080fd5b5060405162000ebe38038062000...",
+ Bin: "0x608060405234801561001057600080fd5b506040516108663803806108668...",
}
3) I've run those 3 scenarios which require to rebuild the smoketest docker (make zetanode) image each time and the following are the results.
--- GAS limit at 250k (original code) --
$$$ Before: SUPPLY OF AZETA: 108402100200000000000000000
ZetaReverted event:
Dest Addr: 0x0e141A7e7C0A7E15E7d22713Fc0a6187515Fa9BF
Dest Chain: 1337
RemainingZetaValue: 5999999999991975926
Message: 6e0182194bb1deba01849afd3e035a0b70ce7cb069e482ee663519c76cf569b40000000000000000000000000000000000000000000000000000000000000001
$$$ After: SUPPLY OF AZETA: 108402104200000000008024074
$$$ Diff: SUPPLY OF AZETA: 4000000000008024074
--- GAS limit at 1M--
$$$ Before: SUPPLY OF AZETA: 108402100200000000000000000
ZetaReverted event:
Dest Addr: 0x0e141A7e7C0A7E15E7d22713Fc0a6187515Fa9BF
Dest Chain: 1337
RemainingZetaValue: 5999999999967903710
Message: 6e0182194bb1deba01849afd3e035a0b70ce7cb069e482ee663519c76cf569b40000000000000000000000000000000000000000000000000000000000000001
$$$ After: SUPPLY OF AZETA: 108402104200000000032096290
$$$ Diff: SUPPLY OF AZETA: 4000000000032096290
--- GAS limit at 20M--
$$$ Before: SUPPLY OF AZETA: 108402100200000000000000000
ZetaReverted event:
Dest Addr: 0x0e141A7e7C0A7E15E7d22713Fc0a6187515Fa9BF
Dest Chain: 1337
RemainingZetaValue: 5999999999358074218
Message: 6e0182194bb1deba01849afd3e035a0b70ce7cb069e482ee663519c76cf569b40000000000000000000000000000000000000000000000000000000000000001
$$$ After: SUPPLY OF AZETA: 108402104200000000641925782
$$$ Diff: SUPPLY OF AZETA: 4000000000641925782
If we look at RemainingZetaValue
in those 3 scenarios or even better at Diff: SUPPLY OF AZETA
, we can confirm that the gas spent is increasing by the factor indicated in the destinationGasLimit
parameter.
Let's break down those fees. The test is sending 10 ZETA from one ETH account to another ETH account using ZetaChain. Since the protocol fee is hardcoded at 2 ZETA, and since there will be 2 outbound transactions (normal outbound which will revert, then the outbound refund tx), the protocol fees are 4 ZETA
. Then the remaining is the actual gas spent on the transaction which is what we are interested in, we can see that the increasing gas spend is corresponding to the gas limit factor when calling send
. Those are ridiculous in the smoketest as asset price (ETH and ZETA) are not real in the environnement, but if they would be, that would be much impactfull and as described in my report Impacts
, which proove my point, and I hope can be understood by everyone.
250k : 8024074
1M : 8024074 4 = 32096296
20M : 8024074 80 = 641925920
I did run the smoketest TestMessagePassingRevertSuccess
which is exploiting this code path with a single scenario sending only 4 ZETA instead of 10 ZETA
, which will simulate higher gas cost for the refund transaction, which will revert. Granted that the amounts here are ridiculous as indicated, but this is due to the fact how ETH and ZETA are valued in USD in the smoketest environnement, which is not representing the reality. The Impacts described in my report would be more representative of the reality.
The following is from a normal run.
$$$ Before: SUPPLY OF AZETA: 108402100200000000000000000
TestDApp.SendHello tx hash: 0xc01722d2abd2cbf0be2caa9ca18664de4c5942e78929f9e6f52147611026c612
TestDApp.SendHello tx receipt: status 1
Waiting for cctx to be mined by inTxHash: 0xc01722d2abd2cbf0be2caa9ca18664de4c5942e78929f9e6f52147611026c612
Error getting cctx by inTxHash: rpc error: code = NotFound desc = not found
Waiting for cctx to be mined by inTxHash: 0xc01722d2abd2cbf0be2caa9ca18664de4c5942e78929f9e6f52147611026c612
Deposit receipt cctx index: [0x20feb00f551724ce1acf0830f8555334779362db10c074a55ccaee98da4705e5]
Deposit receipt cctx status: Reverted; The cctx is processed
ZetaReverted event:
Dest Addr: 0x0e141A7e7C0A7E15E7d22713Fc0a6187515Fa9BF
Dest Chain: 1337
RemainingZetaValue: 5999999999991975926
Message: 6e0182194bb1deba01849afd3e035a0b70ce7cb069e482ee663519c76cf569b40000000000000000000000000000000000000000000000000000000000000001
$$$ After: SUPPLY OF AZETA: 108402104200000000008024074
$$$ Diff: SUPPLY OF AZETA: 4000000000008024074
This is when sending only 4 ZETA, which we can see the status being Aborted
, so no refund, user fund loss
.
$$$ Before: SUPPLY OF AZETA: 108402100200000000000000000 - ETH price: 8 Gwei, ZETA price: 448 Gwei
TestDApp.SendHello tx hash: 0x47c7fd77afa0039c5c737ff36ce1910ada38ea9a42ccb327603298237ff9f03c
TestDApp.SendHello tx receipt: status 1
Waiting for cctx to be mined by inTxHash: 0x47c7fd77afa0039c5c737ff36ce1910ada38ea9a42ccb327603298237ff9f03c
Error getting cctx by inTxHash: rpc error: code = NotFound desc = not found
Waiting for cctx to be mined by inTxHash: 0x47c7fd77afa0039c5c737ff36ce1910ada38ea9a42ccb327603298237ff9f03c
Deposit receipt cctx index: [0x37d78331443130871dd60d259b7eb19e93b7bb6eb1ebe903d625c0760cd4b2f0]
Deposit receipt cctx status: Aborted; The cctx is processed
panic: expected cctx to be reverted
For all the mention above, I would ask @0xean to reconsider this issue to be upgraded to High
and unique
finding and I would also appreciate the feedback of the sponsor @lumtis on the report as he probably hasn't read it. As indicated, since both impacts affect user funds negatively, that clearly seems to warrant High
according to C4 rules.
The understanding of a gaslimit is that you can put very high limit to be sure your transaction doesn't lack gas and revert in the middle, as you know the remaining gas will be refunded to you
This alone disqualifies this from being H severity. I do agree that there are multiple gas issues going on and have separated them accordingly. That being said, I think this issue is actually QA. I welcome comment form the sponsor before downgrading.
the issue is correct that the protocol charges twice the specified gas limit in such case of refund/revert. The user's payment for gas is either gasLimit if no revert or 2*gasLimit in case of revert, and I'm not sure that is higher than the user expectation. The revert tx gasLimit cannot be inferred by protocol, and needs to be set by dApp or user, and in this case the outbound gas limit is assumed which is reasonable.
lumtis (sponsor) acknowledged
I think QA is the correct severity here.
0xean marked the issue as not a duplicate
0xean changed the severity to QA (Quality Assurance)
0xean marked the issue as grade-a
Lines of code
https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/gas_payment.go#L289 https://github.com/code-423n4/2023-11-zetachain/blob/main/repos/node/x/crosschain/keeper/keeper_cross_chain_tx_vote_outbound_tx.go#L169-L172
Vulnerability details
Description
Normal cost of an
ERC20 transfer
on Ethereum is around65000 gas unit
. User on Ethereum (or any EVM supported chain) can send their wZETA to another EVM supported chain (thanks to CCTX), which from a user perspective should be similar in terms of cost of a normal ERC20.Unfortunatelly, this is not the reality, in some case this could imply a
huge cost
orfund loss
for the user which seems to warrantHigh
severity.Impact
High gas fees
orfund loss
when deposting wZETA to an external chain during the refund flow (see PoC).Here are two undesired scenario that can occur depending on the inputs:
High gas, but higher wZETA value
: If user send a super high gas limit like 1M gas unit (which is not abnormal) and wZETA transfered was higher in value, the refund transaction will work but that will cost a LOT of money to the user. Considering a 200 wZeta transfer that revert so refunded on Ethereum, at 30 gwei gas price, ETH at 2355 USD and wZETA at 1 USD, (1M gas unit * 60 gwei) sounds like140 USD in fees
for a failed transfer of the 200 wZETA (value of 200 USD), that's 70% of the value being transfered paid in fees! Worst is that the transfer didn't work, and the user is refunded only 30% of the value sent in that case (see PoC).High gas, but lower wZETA value
: That will cause to return errorErrNotEnoughZetaBurnt
inPayGasInZetaAndUpdateCctx
and will cause the transaction to be aborded,so funds loss
as refund is aborted.Proof of Concept
Let's consider the CCTX flow described in the
whitepaper
as follow forCross-Chain Message Passing
.Let say Alice want to send 200 wZETA from Ethereum to BSC using
ZetaConnector.eth::send
.step 1-2
, this is where the user callZetaConnector.eth::send
which will emitZetaSent
log.step 3
, this is where Observer consume the ZetaSent log inGetInboundVoteMsgForZetaSentEvent
, do some basics validation (but use the event.DestinationGasLimit as is) and report it to zetacore, which will do some validation when receiving this broadcast inVoteOnObservedInboundTx
.step 4-5
, this is where the inbound is finalized and the CCTX is created, status is changed toCctxStatus_PendingOutbound
as this is going to BSC and processed by Observers inTryProcessOutTx
.step 9
, unfortunatelly a failure happened (onReceive reverted, that can easily happen as dApp callback is involved, which is out of the control of Zeta protocol), so therefund mechanism
will be triggered.This is where there is an issue in the code.
Let's examine more in details VoteOnObservedOutboundTx when the ballot is finalized.BallotStatus_BallotFinalized_FailureObservation
is selected as there was a revert.else
branch is taken asmsg.CoinType == common.CoinType_Zeta
types.CctxStatus_PendingOutbound
is selectedcctx.InboundTxParams.CoinType == common.CoinType_Zeta
.gasLimit = cctx.OutboundTxParams[0].OutboundTxGasLimit
will be reach,which is the problem
. This will actually be the value specified originally by the user in the first call toZetaConnector.eth::send
(reminder: event.DestinationGasLimit).The understanding of a gaslimit is that you can put very high limit to be sure your transaction doesn't lack gas and revert in the middle, as you know the remaining gas will be refunded to you. The problem here is that this is not the case, as Zeta makes the user pay in advance for the gas the TSS will spend to send the refund transaction on the sender chain (Ethereum).
As we can see,
gasLimit
is assigned to the new OutboundTxParamsOutboundTxGasLimit: gasLimit
and thenPayGasAndUpdateCctx
is called.PayGasInZetaAndUpdateCctx
is then called as working with common.CoinType_Zeta, and the function will consume ALL the gaslimit.Cross-Chain Message Passing flow
Recommended Mitigation Steps
GetRevertGasLimit
is called in two places as follow. The commentfallback -- should not happen
is actually true forInboundTx
, it's a dead code path. On the other hand, it's an active path forOutboundTx
and cause this high severity report.VoteOnObservedInboundTx I would recommend to remove the
dead code path
and have no fallback, the code path is anyway dead, so keeping it there make the code more vulnerable.VoteOnObservedOutboundTx This is the crispy one. I don't see why you could not do the following, at least I'm proposing it XD. I understand that this will also impact the Inbound flow, but seems a noop as a failure in
k.HandleEVMDeposit
will always return isContractReverted false which would not reach this code path.Assessed type
Token-Transfer