Cross-Chain Bridge Gateway Overpays Gas Refunds via Zero-Byte Calldata Mispricing
The Risk
A cross-chain bridge holds a pool of funds used to pay back the gas costs of the people who relay messages between chains. The contract used a shortcut formula that always overpaid those refunds, and an attacker could make the overpayment larger by padding their messages with extra empty bytes. Every relay leaked a small amount of the bridge's funds, repeatable indefinitely, with the leak coming straight out of the gateway's reserve.
The Vulnerability
The bridge gateway reimburses relayers for the gas they spend submitting cross-chain messages. The base-gas estimator used by the refund logic assumed every byte of calldata cost 16 gas. Ethereum charges 4 gas for zero bytes and 16 gas for non-zero bytes, so the estimator systematically overcharged for zero bytes by 12 gas each.
The relevant logic, simplified:
function v1_transactionBaseGas() internal pure returns (uint256) {
return 21_000 + 14_698 + (Math.min(msg.data.length, 3000) * 16);
}
uint256 gasUsed = v1_transactionBaseGas() + (startGas - gasleft());
uint256 refund = gasUsed * Math.min(tx.gasprice, message.maxFeePerGas);
uint256 amount = Math.min(refund + message.reward, address(this).balance);
payable(msg.sender).safeNativeTransfer(amount); Two issues compound:
- Cross-chain proofs and ABI-encoded payloads are zero-heavy by nature, so almost every legitimate relay overpays.
- Solidity tolerates trailing calldata. A relayer can append zero bytes after the encoded arguments, up to the 3000-byte refund cap, and each appended zero byte raises the refunded amount by 16 gas while only costing 4 gas to send.
The Attack
Confirming the mispricing on real transactions
A read-only proof of concept pulled recent submit transactions from the gateway, counted zero versus non-zero bytes in the first 3000 bytes of calldata, and compared the contract's base-gas formula to the actual EVM cost. For every analyzed transaction the formula returned more gas than was actually consumed by calldata, and the gateway balance dropped by more than the relayer's true cost.
Amplifying the overpayment
An attacker with relayer privileges can append zero-byte padding to any submit call until the total calldata reaches 3000 bytes. Each padded zero byte returns a net 12 gas of free refund. At realistic mainnet gas prices that is small per call, but the bug fires on every relay, the relayer chooses how much to pad, and there is no cap beyond the 3000-byte refund window. The drain compounds across the bridge's lifetime.
Because the attack is on-chain accounting and uses the contract's own refund path, there is no signature, key, or social engineering required. Any address allowed to submit relays can trigger it.
The Impact
The bug results in direct, repeatable loss of ETH from the gateway's balance:
- Every relay overpays gas by an amount proportional to the number of zero bytes in its first 3000 bytes of calldata.
- Padding amplification lets a malicious relayer add up to 3000 minus the original calldata length in zero bytes, all refunded at 16 gas while costing 4.
- The funds drained come out of the gateway's ETH reserve, which is also the pool used to pay legitimate refunds, so sustained exploitation degrades the bridge's ability to operate.
The proof of concept did not require private keys, write access, or any privileged credentials. It only needed a public RPC endpoint to read transaction data and balances around each block.
Remediation
- Charge calldata at the correct EVM rates: scan the refunded segment and add 4 gas per zero byte, 16 gas per non-zero byte, up to the 3000-byte cap.
- Reject extraneous trailing calldata in the submit entry point, or compute calldata gas only over the exact ABI-encoded portion that is meant to be refunded.
- Cap per-call refunds at a tight upper bound based on observed legitimate relay costs, so any future estimator drift cannot drain the reserve.
- Monitor gateway balance deltas per submit transaction and alert on outliers.
A safe replacement for the estimator looks like:
function v1_transactionBaseGas() internal view returns (uint256) {
uint256 l = Math.min(msg.data.length, 3000);
uint256 calldataGas = 0;
for (uint256 i = 0; i < l; i++) {
calldataGas += (msg.data[i] == 0) ? 4 : 16;
}
return 21_000 + 14_698 + calldataGas;
}