Cross-chain bridges are DeFi's most expensive attack surface. In 2022, Nomad lost $190M to a message replay bug. In 2023, Multichain lost $126M to a compromised CEO. And in February 2026, CrossCurve — a multi-chain bridge formerly known as EYWA — lost $3 million because its expressExecute() function never checked whether incoming messages actually came from the Axelar Gateway.
No flash loans. No oracle manipulation. No complex math. Just a publicly callable function that accepted anyone's word as gospel — and an attacker who noticed.
How CrossCurve's Bridge Architecture Works
CrossCurve connects multiple blockchains through a layered architecture:
┌──────────┐ Axelar GMP ┌───────────────────┐ unlock ┌──────────────┐
│ Source │ ──────────────→ │ ReceiverAxelar │ ──────────→ │ PortalV2 │
│ Chain │ │ (message handler) │ │ (asset vault)│
└──────────┘ └───────────────────┘ └──────────────┘
- Source chain: User deposits assets and initiates a cross-chain transfer
- Axelar GMP (General Message Passing): Relays and validates the message across chains
- ReceiverAxelar: Receives the validated message and instructs PortalV2 to release funds
- PortalV2: Holds locked assets and releases them based on instructions from the receiver
The critical security boundary is step 3: the ReceiverAxelar contract must verify that messages genuinely originated from the Axelar Gateway. Without this verification, anyone can instruct the contract to release funds.
CrossCurve marketed a triple-validation system — Axelar, LayerZero, and their own EYWA Oracle Network. In practice, one missing require statement made all three irrelevant.
The Vulnerability: Missing Gateway Validation
The expressExecute() function was designed for expedited cross-chain execution — a fast path that processes messages before full Axelar consensus completes. In a correct implementation, this function still validates the message source through the Axelar Gateway.
CrossCurve's implementation skipped this check entirely:
// VULNERABLE — DO NOT USE
contract ReceiverAxelar {
mapping(bytes32 => bool) public usedCommandIds;
uint256 public confirmationThreshold = 1; // Effectively no quorum
function expressExecute(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external {
// ❌ Only checks if commandId was used before
// ❌ Does NOT verify message came from Axelar Gateway
require(!usedCommandIds[commandId], "Already executed");
usedCommandIds[commandId] = true;
// Decode and execute the payload — attacker controls this!
_processPayload(sourceChain, sourceAddress, payload);
}
function _processPayload(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal {
// Instructs PortalV2 to unlock tokens
// All parameters are attacker-controlled
(address token, address recipient, uint256 amount) =
abi.decode(payload, (address, address, uint256));
portal.unlock(token, recipient, amount);
}
}
The fix is literally one line — verifying that msg.sender is the Axelar Gateway:
// FIXED — Proper Axelar integration
contract ReceiverAxelarFixed {
IAxelarGateway public immutable gateway;
mapping(bytes32 => bool) public usedCommandIds;
function expressExecute(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external {
// ✅ Verify the message comes from Axelar Gateway
require(
gateway.validateContractCall(
commandId, sourceChain, sourceAddress,
keccak256(payload)
),
"Not approved by gateway"
);
require(!usedCommandIds[commandId], "Already executed");
usedCommandIds[commandId] = true;
_processPayload(sourceChain, sourceAddress, payload);
}
}
The Attack: Four Steps, $3M Gone
Step 1: Spoofed Message Origin
The attacker crafted a transaction that called expressExecute() directly — not through Axelar. They generated a fresh commandId (any unique bytes32 value works) and supplied fake sourceChain and sourceAddress parameters:
commandId: 0xdeadbeef... (any unused value)
sourceChain: "ethereum" (spoofed — no verification)
sourceAddress: "0x..." (spoofed — no verification)
payload: <malicious ABI-encoded instructions>
Step 2: Malicious Payload Construction
The payload contained ABI-encoded instructions to mint approximately 999.8 million EYWA tokens to the attacker's wallet, or to unlock existing bridged assets from the PortalV2 vault:
// Attacker's payload — instructs the bridge to release funds
bytes memory maliciousPayload = abi.encode(
address(eywaToken), // token to unlock
attackerAddress, // recipient (attacker)
999_800_000 ether // amount — drain everything
);
Step 3: Bypassed Validation
The contract's only security check — commandId uniqueness — was trivially defeated. The attacker simply used a different commandId for each call. The confirmation threshold of 1 meant no multi-guardian verification was required.
Step 4: Multi-Chain Execution
The attacker repeated this process across Arbitrum, Ethereum, Optimism, and other chains where CrossCurve was deployed. Each chain's ReceiverAxelar contract had the same vulnerability.
Attack Timeline:
├── Arbitrum: ~$1.4M drained (primary target, most liquidity)
├── Ethereum: EYWA tokens minted (limited liquidation — frozen CEX deposits)
├── Optimism: Additional assets drained
└── Other chains: Residual assets claimed
Post-theft:
├── Converted stolen tokens → WETH via CoW Protocol (Arbitrum)
├── Bridged WETH → Ethereum via Across Protocol
└── Funds dispersed to attacker-controlled wallets
Total Damage: ~$3 million
PortalV2's balance went from ~$3M to near zero. CrossCurve shut down the platform immediately for investigation and remediation.
Why Cross-Chain Message Validation Is the #1 Bridge Risk
CrossCurve's vulnerability maps to the same root cause behind almost every major bridge exploit: trusting the message without verifying the messenger.
The Nomad Connection (2022, $190M)
Nomad's bridge had a different but philosophically identical bug: a misconfigured confirmAt mapping that allowed any message hash to be treated as pre-approved. The result was the same — anyone could craft messages instructing the bridge to release funds.
The Pattern
Every Bridge Exploit Shares This Structure:
1. Bridge holds locked assets (the honeypot)
2. Release mechanism depends on message validation
3. Message validation has a flaw:
- CrossCurve: No gateway source check
- Nomad: Pre-approved zero hash
- Ronin: Insufficient validator threshold
- Wormhole: Signature verification bypass
4. Attacker crafts a valid-looking message
5. Bridge releases funds to attacker
The security of every bridge reduces to one question: How does the receiver contract verify that a cross-chain message is legitimate?
Defense Patterns for Cross-Chain Bridge Developers
Pattern 1: Axelar Gateway Validation (The Basics)
If you're building on Axelar, use their SDK properly:
import {AxelarExecutable} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol";
// CORRECT: Inherit from AxelarExecutable
// The _execute() function is ONLY callable through the gateway
contract SecureBridgeReceiver is AxelarExecutable {
constructor(address gateway_) AxelarExecutable(gateway_) {}
// This function is protected by the AxelarExecutable base class
// It can ONLY be called via the Axelar Gateway after proper validation
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
// Safe to process — gateway has validated the message
_processValidatedPayload(sourceChain, sourceAddress, payload);
}
// If implementing expressExecute, use the protected variant
function _executeWithToken(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload,
string calldata tokenSymbol,
uint256 amount
) internal override {
// Also protected by AxelarExecutable
_processTokenTransfer(sourceChain, sourceAddress, payload, tokenSymbol, amount);
}
}
Key insight: Axelar provides AxelarExecutable specifically to handle gateway validation. CrossCurve either didn't use it or implemented their own version without the critical check.
Pattern 2: Multi-Layer Message Validation
Don't rely on a single validation point. Layer multiple checks:
contract DefenseInDepthReceiver {
IAxelarGateway public immutable gateway;
// Allowlisted source chains and addresses
mapping(string => mapping(string => bool)) public trustedSources;
// Per-chain rate limits
mapping(string => RateLimit) public chainLimits;
struct RateLimit {
uint256 maxPerHour;
uint256 currentHourUsed;
uint256 hourStart;
}
function processMessage(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external {
// Layer 1: Gateway validation (cryptographic proof)
require(
gateway.validateContractCall(
commandId, sourceChain, sourceAddress,
keccak256(payload)
),
"Gateway validation failed"
);
// Layer 2: Source allowlisting
require(
trustedSources[sourceChain][sourceAddress],
"Untrusted source"
);
// Layer 3: Rate limiting per source chain
_enforceRateLimit(sourceChain, _decodeAmount(payload));
// Layer 4: Amount sanity check
uint256 amount = _decodeAmount(payload);
require(amount <= MAX_SINGLE_TRANSFER, "Exceeds single transfer limit");
// All checks passed — safe to execute
_processPayload(sourceChain, sourceAddress, payload);
}
function _enforceRateLimit(
string calldata chain,
uint256 amount
) internal {
RateLimit storage limit = chainLimits[chain];
if (block.timestamp >= limit.hourStart + 1 hours) {
limit.currentHourUsed = 0;
limit.hourStart = block.timestamp;
}
limit.currentHourUsed += amount;
require(
limit.currentHourUsed <= limit.maxPerHour,
"Hourly rate limit exceeded"
);
}
}
Pattern 3: Bridge Message Monitoring
Detect spoofed messages in real-time:
# Cross-chain message validator — detects spoofed bridge messages
from web3 import Web3
BRIDGE_RECEIVER = "0x..."
AXELAR_GATEWAY = "0x..."
def monitor_bridge_messages(w3: Web3):
"""Alert on messages that bypass gateway validation"""
# Watch for expressExecute calls
receiver = w3.eth.contract(
address=BRIDGE_RECEIVER,
abi=RECEIVER_ABI
)
# Filter for execution events
event_filter = receiver.events.MessageExecuted.create_filter(
fromBlock='latest'
)
while True:
for event in event_filter.get_new_entries():
tx = w3.eth.get_transaction(event.transactionHash)
# Check 1: Was the caller the Axelar Gateway?
if tx['from'].lower() != AXELAR_GATEWAY.lower():
send_critical_alert(
f"🚨 SPOOFED BRIDGE MESSAGE\n"
f"Tx: {event.transactionHash.hex()}\n"
f"Caller: {tx['from']} (NOT gateway)\n"
f"commandId: {event.args.commandId.hex()}\n"
f"Amount: {event.args.amount}"
)
trigger_emergency_pause()
# Check 2: Does the commandId exist on the source chain?
if not verify_command_on_source_chain(
event.args.sourceChain,
event.args.commandId
):
send_critical_alert(
f"⚠️ UNVERIFIED commandId\n"
f"commandId {event.args.commandId.hex()} not found "
f"on {event.args.sourceChain}"
)
time.sleep(12)
Pattern 4: Foundry Invariant Test for Bridge Validation
// Test that ONLY the gateway can trigger fund releases
contract BridgeInvariantTest is Test {
ReceiverAxelar receiver;
PortalV2 portal;
address gateway = makeAddr("gateway");
function setUp() public {
receiver = new ReceiverAxelar(gateway);
portal = new PortalV2(address(receiver));
// Fund the portal
deal(address(portal), 100 ether);
}
function invariant_onlyGatewayCanReleaseFunds() public {
// Portal balance should never decrease from non-gateway calls
uint256 portalBalance = address(portal).balance;
// Try calling expressExecute from random addresses
address attacker = makeAddr("attacker");
vm.prank(attacker);
try receiver.expressExecute(
keccak256("fake"),
"ethereum",
"0xfake",
abi.encode(address(0), attacker, 1 ether)
) {
// If this succeeds, we have a vulnerability
assertEq(
address(portal).balance,
portalBalance,
"CRITICAL: Non-gateway call released funds"
);
} catch {
// Expected — non-gateway calls should revert
}
}
function test_spoofedMessageReverts() public {
address attacker = makeAddr("attacker");
vm.prank(attacker);
vm.expectRevert("Not approved by gateway");
receiver.expressExecute(
keccak256("spoofed-command"),
"ethereum",
"0xspoofed",
abi.encode(address(token), attacker, 1000 ether)
);
}
}
The Cross-Chain Bridge Security Checklist
Before trusting or deploying any cross-chain bridge, verify:
🔐 Message Validation
- [ ] All execution paths verify messages through the official gateway/relayer
- [ ]
expressExecutepaths have the same validation as standard execute - [ ] Source chain and address are allowlisted, not just checked for format
- [ ] commandId uniqueness is checked AFTER gateway validation, not instead of it
🛡️ Access Control
- [ ] Execution functions are not publicly callable without gateway validation
- [ ] Confirmation threshold requires actual multi-guardian consensus (not 1)
- [ ] Admin functions (pause, upgrade) are behind multisig + timelock
⚡ Rate Limiting
- [ ] Per-chain hourly/daily transfer limits
- [ ] Maximum single transfer amount
- [ ] Emergency pause when limits are breached
🔍 Monitoring
- [ ] Real-time alerts for non-gateway callers hitting execution functions
- [ ] Cross-chain commandId verification (does it exist on source chain?)
- [ ] Balance monitoring with automated pause on rapid drainage
🧪 Testing
- [ ] Invariant tests: non-gateway addresses cannot release funds
- [ ] Fuzz tests: arbitrary inputs to execution functions are rejected
- [ ] Integration tests: end-to-end message flow with actual gateway validation
Broader Implications: The Express Execute Anti-Pattern
CrossCurve's expressExecute was a performance optimization — a fast path for cross-chain messages that skips waiting for full consensus. This pattern exists across multiple bridge implementations, and it's inherently dangerous because:
- Fast paths skip checks. The whole point is to avoid waiting for full validation, which creates pressure to reduce security for speed.
-
Separate code paths mean separate attack surfaces. Even if the standard
executeis properly validated, the express path might not be. - Testing focuses on the happy path. Teams test the standard flow extensively but may treat the express path as a simple optimization.
Rule of thumb: Any function that can release bridged funds must have the same validation guarantees as the most secure path. If your express path has weaker checks, it's not an optimization — it's a vulnerability.
Key Takeaways
- Gateway validation is the bridge's security boundary — without it, everything else (commandId checks, thresholds, allowlists) is theater
-
Use the official SDK — Axelar's
AxelarExecutablebase class handles validation correctly; reimplementing it is where bugs creep in - Express paths are attack surfaces — any "fast lane" that skips validation checks is a vulnerability waiting to be found
-
$3M lost to one missing
requirestatement — the simplest bugs remain the most expensive - Bridges are DeFi's highest-risk contracts — they combine maximum asset concentration with maximum complexity. Audit accordingly.
The CrossCurve exploit wasn't sophisticated. It didn't require novel cryptographic attacks or complex DeFi composability. An attacker simply called a public function, passed fake parameters, and the bridge believed them. In cross-chain security, trust without verification isn't an optimization — it's an invitation.
DreamWork Security publishes weekly DeFi security research. Follow @ohmygod for vulnerability analyses, audit tool guides, and security best practices across Solana and EVM ecosystems.
Top comments (0)