Solidity Cross-Chain Bridges & Layer-1 / Layer-2 Messaging Security
Overview & Empirical Grounding
This security auditing skill is synthesized from 189 empirical audit contest findings (28 Critical, 161 High severity) extracted from the Zaevlad/audit-findings-dataset corpus.
Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit Cross-Chain & Bridges patterns.
Key Trigger Keywords
bridge security, cross-chain messaging, layerzero lzreceive, l1 to l2, message replay, ccip, axelar gateway, bridge fee
Threat Model & Core Attack Vectors
Detailed failure modes identified across empirical audit findings:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- Economic & MEV Incentives: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.
Step-by-Step Audit Checklist
When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:
1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (
onlyOwner,onlyRole, custom guards) and ensure roles are initialized in constructor/initializer. - Trace all token balance state changes and external contract interactions.
2. Deep Invariant & Boundary Verification
- Input Validation: Check zero-address (
address(0)), zero-amount, array length parity, and upper/lower bounds. - State Transition Sequencing: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- External Return Handling: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- Failure Paths & DoS Hazards: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.
3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (forge test):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract AuditPoCTest is Test {
// Setup target protocol and mock dependencies
function setUp() public {
// Fork mainnet or deploy local environment
}
function test_exploit_vector() public {
// 1. Establish initial attacker balance
// 2. Trigger vulnerability
// 3. Assert invariant violation or balance drain
}
}
Empirical Case Studies from Zaevlad/audit-findings-dataset
The following real-world contest findings illustrate the root causes and remediation patterns:
Case Study: L1->L2 token deposits can be DoS’ed by purposefully providing a large data field in MsgInitiateTokenDeposit
- Severity: High (Dataset Weight: 1.00, ID: #2063)
- Vulnerability Mechanism:
On L2,
MsgFinalizeTokenDepositmust be relayed in strict sequence, matchingfinalizedL1Sequence, err := ms.GetNextL1Sequence(ctx)
x/opchild/keeper/msg_server.go::FinalizeTokenDeposit(..)
385: finalizedL1Sequence, err := ms.GetNextL1Sequence(ctx)
386: if err != nil {
387: return nil, err
388: }
389:
390: if req.Sequence < finalizedL1Sequence {
391: // No op instead of returning an error
392: return &types.MsgFinalizeTokenDepositResponse{Result: types.NOOP}, nil
393: } else if req.Sequence > finalizedL1Sequence {
394: return nil, types.ErrInvalidSequence
395: }
As long as a specific L1 sequence is not successfully processed, subsequent L1->L2 deposits have to wait and cannot be processed. If it were possible to purposefully create a MsgFinalizeTokenDeposit message that cannot be executed on L2, it will DoS all subsequent deposits to L2!
Due to
Proof of Concept / Vulnerable Pattern
The following PoC demonstrates how a large `data` field in `MsgInitiateTokenDeposit` can lead to a DoS of `L1->L2` deposits.
Paste the test case into `opinit-bots/e2e/multiple_txs_test.go` and run with `go test -v . -timeout 30m -run TestMultipleDepositsAndWithdrawalsExceedingLimit`.
```solidity
func TestMultipleDepositsAndWithdrawalsExceedingLimit(t *testing.T) {
l1ChainConfig := &ChainConfig{
ChainID: "initiation-2",
Image: ibc.DockerImage{Repository: "ghcr.io/initia-labs/initiad", Version: "v0.6.4", UIDGID: "1000:1000"},
Bin: "initiad",
Bech32Prefix: "init",
Denom: "uinit",
Gas: "auto",
GasPrices: "0.025uinit",
GasAdjustment: 1.2,
TrustingPeriod: "168h",
NumValidators: 1,
NumFullNodes: 0,
}
l2ChainConfig := &ChainConfig{
ChainID: "minimove-2",
Image: ibc.DockerImage{Repository: "ghcr.io/initia-labs/minimove", Version: "v0.6.5", UIDGID: "1000:1000"},
Bin: "minitiad",
Bech32Prefix: "init",
Denom: "umin",
Gas: "auto",
GasPrices: "0.025umin",
GasAdjustment: 1.2,
TrustingPeriod: "168h",
NumValidators: 1,
NumFullNodes: 0,
- Remediation Recommendation: No recommendation
Case Study: Tokens can get stuck during migration if the L2 side fails leading to loss of funds
- Severity: High (Dataset Weight: 0.99, ID: #3551)
- Vulnerability Mechanism:
function recieveL1Beans(address reciever, uint256 amount) external nonReentrant {
require(
msg.sender == address(BRIDGE) &&
IL2Messenger(BRIDGE).xDomainMessageSender() == L1BEANSTALK
);
s.sys.migration.migratedL1Beans += amount;
require(
EXTERNALL1BEANS >= s.sys.migration.migratedL1Beans,
"L2Migration: exceeds maximum migrated"
);
C.bean().mint(reciever, amount);
}
During the mirgration process between BeanL1ReceiverFacet and BeanL2MigrationFacet, when transactions fail on the L2 side, tokens are forever burnt with no existing method to reclaw them.
The migration process involves two key contracts: BeanL1ReceiverFacet and BeanL2MigrationFacet. The process starts on L1 where tokens are burned, and a message is sent to L2 to mint the equivalent amount of tokens.
Steps Involved: Burning on L1: The BeanL2MigrationFacet contract burns the tokens from the user's L1 balance. Message to L2: The contract then sends a message to L2 using the IL2Bridge interface, instructing the L2 contract to mint the equivalent amount of tokens.
Vulnerable Scenario: If the message sent from L1 to L2 fails to execute successfully on L
- Remediation Recommendation:
function recieveL1Beans(address reciever, uint256 amount) external nonReentrant {
require(
msg.sender == address(BRIDGE) &&
IL2Messenger(BRIDGE).xDomainMessageSender() == L1BEANSTALK
);
s.sys.migration.migratedL1Beans += amount;
require(
EXTERNALL1BEANS >= s.sys.migration.migratedL1Beans,
"L2Migration: exceeds maximum migrated"
);
C.bean().mint(reciever, amount);
// Mark the migration as completed on L1
bytes32 requestId = keccak256(abi.encodePacked(reciever, amount, block.timestamp));
IL2Bridge(BRIDGE).sendMessage(
L1Beanstalk,
abi.encodeCall(BeanL2MigrationFacet(L1Beanstalk).markMigrationCompleted, (requestId)),
gasLimit
);
}
It is recommended to comprise a refund/reclaw mecha
Case Study: Forcing Starknet handlers to be whitelisted on the same chain allows exploit of BurnUnlock mode to drain handler funds
- Severity: High (Dataset Weight: 0.99, ID: #21828)
- Vulnerability Mechanism:
fn receive_cross_chain_msg(ref self: ContractState, cross_chain_msg_id: u256, from_chain: felt252, to_chain: felt252,
from_handler: u256, to_handler: ContractAddress, payload: Array<u8>) -> bool{
assert(to_handler == get_contract_address(),"error to_handler");
assert(self.settlement_address.read() == get_caller_address(), "not settlement");
assert(self.support_handler.read((from_chain, from_handler)) &&
self.support_handler.read((to_chain, contract_address_to_u256(to_handler))), "not support handler");
// --SNIP
}
fn receive_cross_chain_callback(ref self: ContractState, cross_chain_msg_id: felt252, from_chain: felt252, to_chain: felt252,
from_handler: u256, to_handler: ContractAddress, cross_chain_msg_status: u8) -> bool{
assert(to_handler == get_contract_address(),"error to_handler");
assert(self.settlement_address.read() == get_caller_address(), "not settlement");
assert(self.support_handler.read((from_chain, from_handler)) &&
self.support_handler.read((to_chain, contract_address_to_u256(to_handler))), "not support handler");
// --SNIP
}
In this context, handlers
- Remediation Recommendation: The self-whitelisting of handlers introduces unnecessary risk and facilitates the aforementioned vulnerability. Consider removing the check that forces handlers to be self-whitelisted:
fn receive_cross_chain_msg(ref self: ContractState, cross_chain_msg_id: u256, from_chain: felt252, to_chain: felt252,
from_handler: u256, to_handler: ContractAddress, payload: Array<u8>) -> bool{
assert(to_handler == get_contract_address(),"error to_handler");
assert(self.settlement_address.read() == get_caller_address(), "not settlement");
assert(self.support_handler.read((from_chain, from_handler))
&& self.support_handler.read((to_chain, contract_address_to_u256(to_handler))), "not support handler");
// --SNIP
}
fn receive_cross_chain_callback(ref
---
### Case Study: TOFTOptionsReceiverModule miss cross-chain
- **Severity**: High (Dataset Weight: 0.98, ID: #22568)
- **Vulnerability Mechanism**:
Cross-chain token decimals transformation is applied partially in TOFTOptionsReceiverModule's lockAndParticipateReceiver() and mintLendXChainSGLXChainLockAndParticipateReceiver(). Currently only first level amounts are being transformed in cross-chain TOFTOptionsReceiverModule, while the nested deposit and lock amounts involved aren't. Whenever the decimals are different for underlying tokens across chains the absence of transformation will lead to magnitudes sized misrepresentation of user operations, which can result in core functionality unavailability (operations can constantly revert or become a noops due to running them with outsized or dust sized parameters) and loss of user funds (when an operation was successfully run, but with severely misrepresented parameters). Probability can be estimated as medium due to prerequisite of having asset decimals difference between transacting chains, while the operation misrepresentation and possible fund loss impact described itself has high severity. Likelihood: Medium + Impact: High = Severity: High. Only mintAmount is being transformed in mintLendXChainSGLXChainLockAndParticipateReceiver(): /tOFT/modules/TOFTOptionsReceiverModule.sol#
- **Remediation Recommendation**:
Consider adding these local decimals transformations, e.g.: /tOFT/modules/TOFTOptionsReceiverModule.sol#L80-L82 ```solidity if (msg_.mintData.mintAmount > 0) { msg_.mintData.mintAmount = _toLD(msg_.mintData.mintAmount.toUint64()); } if (msg_.mintData.collateralDepositData.amount > 0) { msg_.mintData.collateralDepositData.amount = _toLD(msg_.mintData.collateralDepositData.amount.toUint64()); } ``` /tOFT/modules/TOFTOptionsReceiverModule.sol#L112-L114 ```solidity if (msg_.lockData.lock) { _checkWhitelistStatus(msg_.lockData.target); if (msg_.lockData.amount > 0) msg_.lockData.amount = _toLD(msg_.lockData.amount.toUint64()); if (msg_.lockData.fraction > 0) msg_.lockData.fraction = _toLD(msg_.lockData.fraction.toUint64()); } ```
---
### Case Study: `settlement.cairo` doesn't process callback correctly leading to `CrossChainMsgStatus` marked as SUCCESS even if it failed on destination chain
- **Severity**: High (Dataset Weight: 0.98, ID: #21827)
- **Vulnerability Mechanism**:
```solidity
fn receive_cross_chain_callback(
ref self: ContractState,
cross_chain_msg_id: felt252,
from_chain: felt252,
to_chain: felt252,
from_handler: u256,
to_handler: ContractAddress,
cross_chain_msg_status: u8, <--
sign_type: u8,
signatures: Array<(felt252, felt256, bool)>,
) -> bool {
//other functionality
let success = handler.receive_cross_chain_callback(cross_chain_msg_id, from_chain, to_chain, from_handler, to_handler , cross_chain_msg_status);
let mut state = CrossChainMsgStatus::PENDING;
if success{
state = CrossChainMsgStatus::SUCCESS;
}else{
state = CrossChainMsgStatus::FAILED;
}
self.created_tx.write(cross_chain_msg_id, CreatedTx{
tx_id:cross_chain_msg_id,
tx_status:state, <--- update the status
from_chain: to_chain,
to_chain: from_chain,
from_handler: to_handler,
to_handler: from_handler
}
- **Remediation Recommendation**:
Change this line from this:
```solidity
if success{
state = CrossChainMsgStatus::SUCCESS;
to this:
if success{
state = cross_chain_msg_status;
To be consistent with the solidity implementation.
The Warden has identified that the status of a cross-chain message is not properly set when a callback is performed on the source chain.
I believe a severity of high is appropriate, as a failed cross-chain transaction would indicate it was processed successfully when this vulnerability manifests.
Case Study: Wrong parameter in remote transfer makes it possible to steal USDO
- Severity: High (Dataset Weight: 0.98, ID: #22521)
- Vulnerability Mechanism:
Setting a wrong parameter when performing remote transfers enables an attack flow where USDO can be stolen from users.
The following bug describes a way to leverage Tapioca’s remote transfers in order to drain any user’s USDO balance. Before diving into the issue, a bit of background regarding compose calls is required in order to properly understand the attack.
Tapioca allows users to leverage LayerZero’s compose calls, which enable complex interactions between messages sent across chains. Compose messages are always preceded by a sender address in order for the destination chain to understand who the sender of the compose message is. When the compose message is received, TapiocaOmnichainReceiver.lzCompose() will decode the compose message, extract the srcChainSender_ and trigger the internal _lzCompose() call with the decoded srcChainSender_ as the sender:
// TapiocaOmnichainReceiver.sol
function lzCompose(
address _from,
bytes32 _guid,
bytes calldata _message,
address, //executor
bytes calldata //extra Data
) external payable override {
...
// Decode LZ compose message.
(address srcChainSender_, bytes memory oftComposeMsg_) =
TapiocaOmnichainEngineCodec.decodeLzCompo
#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
The following proof of concept illustrates how the mentioned attack can take place.
In order to execute the PoC, the following steps must be performed:
1. Create an EndpointMock.sol file inside the test folder inside Tapioca-bar and paste the following code (the current tests are too complex, this imitates LZ’s endpoint contracts and reduces the poc’s complexity):
// SPDX-License-Identifier: LZBL-1.2
pragma solidity ^0.8.20;
struct MessagingReceipt {
bytes32 guid;
uint64 nonce;
MessagingFee fee;
}
struct MessagingParams {
uint32 dstEid;
bytes32 receiver;
bytes message;
bytes options;
bool payInLzToken;
}
struct MessagingFee {
uint256 nativeFee;
uint256 lzTokenFee;
}
contract MockEndpointV2 {
function send(
MessagingParams calldata _params,
address _refundAddress
) external payable
returns (MessagingReceipt memory receipt) {
// DO NOTHING
}
/// @dev the Oapp sends the lzCompose message to the endpoint
/// @dev the composer MUST assert the sender because anyone can send compose msg with this function
/// @dev with the same GUID, the Oapp can send compose to multiple _composer at the same time
/// @dev authenticated by the msg.sender
/// @param _to the address which will r
- Remediation Recommendation:
Change the parameter passed in the _internalRemoteTransferSendPacket() call so that the sender in the compose call built inside it is actually the real source chain sender. This will make it be kept along all the possible recursive calls that might take place:
function _remoteTransferReceiver(address _srcChainSender, bytes memory _data)
internal virtual {
RemoteTransferMsg memory remoteTransferMsg_ =
TapiocaOmnichainEngineCodec.decodeRemoteTransferMsg(_data);
/// @dev xChain owner needs to have approved dst srcChain `sendPacket()` msg.sender in a previous composedMsg. Or be the same address.
_internalTransferWithAllowance(
remoteTransferMsg_.owner, _srcChainSender,
remoteTransferMsg_.lzSendParam.sendParam.amountLD
);
// Make the internal transfer, burn the tokens from this cont
---
## Remediation & Hardening Principles
- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.