DEV Community

ohmygod
ohmygod

Posted on

The CrossCurve Bridge Heist: How Spoofed Axelar Messages Drained $3M Without a Single Legitimate Cross-Chain Transaction

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)│
└──────────┘                   └───────────────────┘              └──────────────┘
Enter fullscreen mode Exit fullscreen mode
  1. Source chain: User deposits assets and initiates a cross-chain transfer
  2. Axelar GMP (General Message Passing): Relays and validates the message across chains
  3. ReceiverAxelar: Receives the validated message and instructs PortalV2 to release funds
  4. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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
  • [ ] expressExecute paths 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:

  1. Fast paths skip checks. The whole point is to avoid waiting for full validation, which creates pressure to reduce security for speed.
  2. Separate code paths mean separate attack surfaces. Even if the standard execute is properly validated, the express path might not be.
  3. 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

  1. Gateway validation is the bridge's security boundary — without it, everything else (commandId checks, thresholds, allowlists) is theater
  2. Use the official SDK — Axelar's AxelarExecutable base class handles validation correctly; reimplementing it is where bugs creep in
  3. Express paths are attack surfaces — any "fast lane" that skips validation checks is a vulnerability waiting to be found
  4. $3M lost to one missing require statement — the simplest bugs remain the most expensive
  5. 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)