DEV Community

ohmygod
ohmygod

Posted on

The CrossCurve $3M Bridge Exploit: How One Missing Check Let Attackers Forge Cross-Chain Messages

The CrossCurve $3M Bridge Exploit

How a single missing access control check let an attacker forge cross-chain messages — and the 4-layer defense every bridge must implement


On February 1, 2026, the CrossCurve bridge (formerly EYWA) lost approximately $3 million across Arbitrum, Ethereum, and several other chains. The root cause wasn't a sophisticated flash loan or a novel cryptographic attack — it was a publicly callable function with insufficient input validation.

This is one of those exploits that makes auditors wince, because the fix is obvious in hindsight. But it reveals a systemic problem in cross-chain bridge architecture that goes far beyond one protocol.

Background: How CrossCurve's Bridge Worked

CrossCurve used Axelar's General Message Passing (GMP) to relay cross-chain messages. The architecture looked like this:

Source Chain                     Destination Chain
┌─────────┐    Axelar GMP     ┌──────────────────┐
│  Portal  │ ──────────────►  │ ReceiverAxelar   │
│  (lock)  │   commandId +    │ (validate + exec) │
│          │   payload        │        │          │
└─────────┘                   │        ▼          │
                              │   PortalV2       │
                              │   (unlock/mint)  │
                              └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

When a user deposited tokens on the source chain, the Portal contract locked them and emitted a message through Axelar. On the destination chain, ReceiverAxelar validated the message and instructed PortalV2 to release the corresponding tokens.

The critical assumption: only legitimate Axelar messages should trigger token releases.

The Vulnerability: expressExecute() Was Wide Open

The ReceiverAxelar contract exposed an expressExecute() function designed for fast-path cross-chain execution. Here's the simplified vulnerable logic:

// VULNERABLE — simplified from CrossCurve's ReceiverAxelar
function expressExecute(
    bytes32 commandId,
    string calldata sourceChain,
    string calldata sourceAddress,
    bytes calldata payload
) external {
    // Only check: has this commandId been used before?
    require(!executedCommands[commandId], "Already executed");
    executedCommands[commandId] = true;

    // ⚠️ NO validation of sourceChain authenticity
    // ⚠️ NO validation of sourceAddress against whitelist
    // ⚠️ NO multi-guardian confirmation requirement
    // ⚠️ NO verification that Axelar actually relayed this message

    // Decode and execute the payload
    _executePayload(sourceChain, sourceAddress, payload);
}
Enter fullscreen mode Exit fullscreen mode

Three critical flaws:

  1. No source authentication — The function accepted any sourceChain and sourceAddress without verifying they came from a trusted Axelar relayer
  2. Confirmation threshold of 1 — Multi-guardian verification was effectively disabled
  3. Publicly callable — Anyone could call expressExecute() with fabricated parameters

The Attack: Step by Step

Step 1: Craft the Fake Message

The attacker generated a fresh commandId (any unique bytes32 value) and constructed a malicious payload:

// Attacker's fabricated cross-chain "message"
bytes32 commandId = keccak256(abi.encode(block.timestamp, attackerNonce));
string memory sourceChain = "ethereum";  // Spoofed
string memory sourceAddress = "0x<portal_address>";  // Spoofed
bytes memory payload = abi.encode(
    UNLOCK_SELECTOR,
    attackerAddress,
    tokenAddress,
    drainAmount  // e.g., 1,000,000 USDC
);
Enter fullscreen mode Exit fullscreen mode

Step 2: Call expressExecute()

// Direct call — no Axelar involvement needed
receiverAxelar.expressExecute(
    commandId,
    sourceChain,
    sourceAddress,
    payload
);
Enter fullscreen mode Exit fullscreen mode

Step 3: Tokens Released

The ReceiverAxelar contract:

  1. ✅ Checked commandId uniqueness — passed (fresh ID)
  2. ❌ Did NOT verify the message came from Axelar
  3. ❌ Did NOT validate sourceAddress against a whitelist
  4. Called PortalV2.unlock() → tokens sent to attacker

Step 4: Repeat Across Chains

The attacker replicated this on Arbitrum, Ethereum, and other supported chains, draining approximately $3M total before CrossCurve's team detected the anomaly and shut down the platform.

Why This Vulnerability Class Keeps Recurring

CrossCurve isn't the first bridge to fall to message forgery. This is the same fundamental pattern that hit:

Bridge Year Loss Root Cause
Ronin (Axie) 2022 $625M Compromised validator keys
Wormhole 2022 $325M Signature verification bypass
Nomad 2022 $190M Initialized with trusted root = 0x00
CrossCurve 2026 $3M Missing source authentication

The common thread: bridges must solve the \"who sent this message?\" problem, and every shortcut in that verification is a potential $M+ vulnerability.

The 4-Layer Bridge Defense Model

After analyzing every major bridge exploit since 2022, here's the defense stack that actually works:

Layer 1: Source Authentication

// SECURE: Verify the message came from the actual relay network
function expressExecute(
    bytes32 commandId,
    string calldata sourceChain,
    string calldata sourceAddress,
    bytes calldata payload
) external {
    // Verify caller is the authorized Axelar gateway
    require(
        msg.sender == axelarGateway,
        "Unauthorized: not gateway"
    );

    // Verify the command was actually approved by Axelar validators
    require(
        IAxelarGateway(axelarGateway).validateContractCall(
            commandId, sourceChain, sourceAddress, keccak256(payload)
        ),
        "Not approved by gateway"
    );

    // ... rest of execution
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Source Address Whitelisting

mapping(string => mapping(string => bool)) public trustedSources;

modifier onlyTrustedSource(
    string calldata sourceChain,
    string calldata sourceAddress
) {
    require(
        trustedSources[sourceChain][sourceAddress],
        "Untrusted source"
    );
    _;
}
Enter fullscreen mode Exit fullscreen mode

Layer 3: Rate Limiting and Caps

struct RateLimit {
    uint256 maxPerTx;
    uint256 maxPerHour;
    uint256 usedThisHour;
    uint256 hourStart;
}

function _checkRateLimit(address token, uint256 amount) internal {
    RateLimit storage limit = rateLimits[token];
    require(amount <= limit.maxPerTx, "Exceeds per-tx limit");

    if (block.timestamp > limit.hourStart + 1 hours) {
        limit.usedThisHour = 0;
        limit.hourStart = block.timestamp;
    }
    limit.usedThisHour += amount;
    require(
        limit.usedThisHour <= limit.maxPerHour,
        "Hourly limit exceeded"
    );
}
Enter fullscreen mode Exit fullscreen mode

Layer 4: Time-Delayed Execution for Large Transfers

uint256 public constant LARGE_TRANSFER_THRESHOLD = 500_000e6; // $500K
uint256 public constant DELAY_PERIOD = 30 minutes;

mapping(bytes32 => uint256) public pendingExecutions;

function _executeOrQueue(
    bytes32 commandId,
    bytes calldata payload,
    uint256 amount
) internal {
    if (amount > LARGE_TRANSFER_THRESHOLD) {
        pendingExecutions[commandId] = block.timestamp + DELAY_PERIOD;
        emit LargeTransferQueued(commandId, amount);
        // Guardians can cancel during delay window
    } else {
        _execute(payload);
    }
}
Enter fullscreen mode Exit fullscreen mode

Bridge Security Audit Checklist

If you're auditing a bridge or building one, verify each item:

Message Validation:

  • [ ] expressExecute / fast-path functions verify caller is the authorized gateway
  • [ ] Gateway's validateContractCall is invoked before payload execution
  • [ ] Source chain and source address are checked against a maintained whitelist
  • [ ] commandId replay protection covers ALL execution paths (express + normal)

Access Control:

  • [ ] Multi-guardian confirmation threshold > 1 (ideally 2/3+ of guardian set)
  • [ ] Guardian rotation doesn't leave windows where threshold drops to 1
  • [ ] Admin functions (whitelist updates, threshold changes) are behind timelock + multisig

Economic Controls:

  • [ ] Per-transaction transfer caps are enforced at the contract level
  • [ ] Hourly/daily volume limits exist and are monitored
  • [ ] Large transfers trigger a time delay with guardian cancellation capability
  • [ ] Circuit breaker exists: auto-pause if drain rate exceeds threshold

Monitoring:

  • [ ] Real-time alerting on transfers > X% of TVL
  • [ ] Anomaly detection on expressExecute call frequency
  • [ ] Cross-chain balance reconciliation runs continuously
  • [ ] Incident response playbook includes contract pause procedure

Key Takeaways

  1. Bridge fast-paths are attack magnets. Every express or fast function needs more validation, not less. Speed should never come at the cost of authentication.

  2. The \"who sent this?\" question is existential for bridges. If your bridge can't cryptographically prove that a cross-chain message originated from a trusted source, your TVL is a bounty.

  3. Confirmation threshold = 1 is equivalent to no confirmation. If a single entity (or a publicly callable function) can authorize releases, you've created a single point of failure worth your entire TVL.

  4. Defense in depth saves millions. Even if CrossCurve's source authentication had been bypassed, rate limits + time delays + monitoring could have limited the damage to thousands instead of millions.

The CrossCurve exploit is a $3M lesson in bridge security fundamentals. The vulnerability wasn't novel — it was a missing require statement that every bridge security guide warns about. The question for every bridge team: have you actually verified your fast-path functions, or are you assuming the happy path is the only path?


This is part of the DeFi Security Research series analyzing real-world exploits and the defense patterns that prevent them. Follow for weekly deep-dives into smart contract, bridge, and protocol security.

Top comments (0)