Introduction
CrossCurve is a cross-chain yield protocol, powered by the Curve AMM. It aims to enable efficient swaps and access to deep liquidity via its consensus bridge.
The Consensus Bridge is its data transfer protocol, that integrates with many bridge services like Axelar, LayerZero and CCIP, and relies on them to help users earn access to more yield opportunities.
On Feb 1, at 06:38 PM UTC, the PortalV2 contract from CrossCurve (previously known as eywa.fi) was exploited to extract an estimated $3M USD worth of funds. The first part of the attack happened on Ethereum Mainnet, followed by the same exploit targeting PortalV2 on Arbitrum, extracting many tokens including $EYWA and $USDT0.
Within hours, the CrossCurve team swung into action : they setup a war room with MixBytes to investigate the root cause, while taking the following measures to contain the damage : The backend was taken offline and, router contracts in all networks began to be put on pause.
Chinmay, Security Researcher at Cantina, emphasizes that: "Extra caution is required when combining multiple services into a single system, particularly when it involves bridging funds. Since bridges play a critical role in enabling interoperability, maintaining their security is vital for preserving trust and ensuring the wider adoption of DeFi systems"
Below we break down the vulnerable code and the actual steps taken by the attacker.
Analyzing the exploit
How does CrossCurve’s Consensus Bridge work ?
From the CrossCurve docs :
- Unlike the traditional PoA (proof-of-authority) solutions, the CrossCurve Consensus Bridge uses a validation model based on agreement between independent data transmission protocols. This architecture involves Axelar, LayerZero, Router Protocol, Asterizm and Chainlink CCIP. A transaction is completed only if consensus is reached among multiple sources. If the data diverges, the transaction is automatically rejected, preventing potential compromise and ensuring the safety of user funds.
This information will be important when we discuss the cause of the exploit.
The Consensus Bridge integrates Axelar via its ReceiverAxelar contract, and this contract was the primary entry point for the attack.
The ReceiverAxelar contracts inherit from Axelar GMP SDK’s AxelarExpressExecutable contract (v 5.10) here.
This AxelarExpressExecutable contract includes a dangerous design pattern with the expressExecute() function, which if not handled carefully, can lead to critical access control issues. This is exactly what the attacker used to drain funds from CrossCurve’s Portal.
.png)
The above code is an excerpt from the AxelarExpressExecutable contract. Here, we can see that expressExecute() is a public function. In ReceiverAxelar.sol, this function got inherited as it is, CrossCurve protocol did not override it or protect this function with access controls.
In comparison to that, here is the execute() function : with a built-in guard ie. gateway.validateContractCall() .
.png)
Let’s look at the AxelarGateway version used by CrossCurve: available here.
The same code can be viewed on github: https://github.com/axelarnetwork/axelar-cgp-solidity/blob/4d01eb6ea1b5413520d9ecab804f815103021148/contracts/AxelarGateway.sol#L233
.png)
This logic requires the specific messageHash to be approved on the destination chain. Messages can only be approved by the Axelar signers via AxelarGateway.execute() ⇒ approveContractCall().
.png)
This flow ensures that only authorized cross-chain messages can be executed on the destination chain, but this whole validation is missing from expressExecute() logic as it only checks that a particular commandID was not already used, and skips the call to validateContractCall().
The attacker used this design flaw to execute a message payload that the Axelar Gateway did not authorize and could not verify.
The design difference between execute() and expressExecute() has been pointed out in Axelar Docs:

Full attack path :
We will use Phalcon to breakdown the actual attack flow : you can view one of the exploit transactions here (there were many transactions on Ethereum, Arbitrum ) : https://app.blocksec.com/phalcon/explorer/tx/eth/0x37d9b911ef710be851a2e08e1cfc61c2544db0f208faeade29ee98cc7506ccc2?line=53
- The main entry point is ReceiverAxelar.expressExecute ( inherited from AxelarExpressExecutable.sol ).
function expressExecute(
bytes32 commandId,
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) external payable virtual {
if (gateway.isCommandExecuted(commandId)) revert AlreadyExecuted();
The parameters were all chosen by the attacker, and just needed to satisfy the following requirements:
- The commandID needs to be fresh. This is being checked via isCommandExecuted()
- The sourceChain and sourceAddress need to be legitimately registered on the ReceiverAxelar. This is being checked in _execute() function.
Both these checks can be bypassed trivially, as we will see later.
So the attacker used the following params:
args:
commandId = 0x5e77d6809707bb0c062a5c82270d7d939c4ad094dc683ccd4738131925cdeb01
sourceChain = "berachain"
sourceAddress = 0x5eEdDcE72530e4fC96d43E3d70Fe09aD0D037175
payload = (The malicious cross-chain message goes here)
As per the AxelarGateway, the given commandID was unused. In fact, it is still marked as unused because the execution pathway in AxelarGateway was not used in the exploit transactions.
Upon doing a simple query on etherscan, we can see that berachain and the given address were indeed set as peers.

This allowed both the checks to pass.
- Then the logic reaches ReceiverAxelar._execute()
Dang, that's the “legitimate peers” check we talked about.
.png)
Later in the _execute() logic, it calls the Receiver contract, which acts as the consolidator of all bridging data flowing through different data protocols into CrossCurve’s Consensus Bridge.
.png)
The payload here was constructed maliciously by the attacker to spoof all data in such a way that every check passes.
This is the data decoded from the payload_ :
args:
sender = 0x000000000000000000000000f3792bae7f35dcde2916c6e6a72ccd3a5330d565
chainIdFrom = 80,094
receivedData = (Malicious cross-chain op calldata, we will talk about it soon)
requestId = 0x105b391f32e7c1e4224ff1a86ab4c6ab0742f5c68f39d485d04b149bda59a97c
Note that 80,094 is the chainID of berachain, and the sender address was purposefully chosen by the attacker (we solve the mystery below).
Now execution moves on to Receiver.receiveData()
.png)
Here’s where it gets interesting. The core issue with lack of access control in expressExecute() function got amplified because the threshold for given sender Address and given chainID was equal to 1.
It turns out the CrossCurve Consensus bridge was not using consensus from different bridging protocols because it executed the cross-chain message immediately whenever the threshold was equal to1. A single confirmation from one bridge path was enough to execute the payload and allowed the attacker to execute malicious transactions without waiting for additional confirmations.
In fact the threshold for sender address equal to `0xF3792BAE7F35DCDE2916C6E6A72CCD3A5330D565` was set in this transaction only 58 days ago, thats how the attacker might have found out on what "sourceChain" this attack could use with threshold == 1 (like berachain).

Upon following the code in Receiver contract further, we find that it calls the executor address decoded from calldata, which happens to be a Diamond Proxy address managing various contracts from the CrossCurve system : CoreFacet and CrossChainFacet being involved in this attack path.
Both these contracts handle the decoding of the “payload” and execution of the cross-chain operation therein. The payload was designed by the attacker to contain a BURN-UNLOCK Opcode.
Finally, the call flow reaches CrossChainFacet.executeCrossChainOp() function. The following is an excerpt from the function as it handles message decoding and delivery according to the Opcode:
} else if (LOCK_MINT_CODE == op || BURN_UNLOCK_CODE == op || BURN_MINT_CODE == op) {
ICrosschainFacet.SynthParams memory p = abi.decode(params, (ICrosschainFacet.SynthParams));
if (isOpHalfDone == false) {
...
...
} else {
require(
ds.processedOps[CoreFacetStorage.currentRequestId()].crossChainOpState == uint8(ICoreFacet.CrossChainOpState.Unknown),
"CrosschainFacet: op processed"
);
ds.processedOps[CoreFacetStorage.currentRequestId()] = ProcessedOps({
hashSynthParams: keccak256(params),
crossChainOpState: uint8(ICoreFacet.CrossChainOpState.Succeeded)
});
// TODO: check
if (p.to == 0) {
p.to = checkTo(p.to, p.emergencyTo, p.chainIdTo, op, nextOp);
}
==>> maskedParams.amountOut = BURN_UNLOCK_CODE == op ? _unlock(p) : _mint(p);
maskedParams.to = p.to;
maskedParams.emergencyTo = p.emergencyTo;
}
The full contract can be read here.
This is the _unlock() function:
function _unlock(ICrosschainFacet.SynthParams memory p) private returns (uint256 amountOut) {
address portal = IAddressBook(CoreFacetStorage.ds().addressBook).portal(uint64(block.chainid));
amountOut = IPortalV2(portal).unlock(
TypecastLib.castToAddress(p.tokenIn),
p.amountIn,
TypecastLib.castToAddress(p.from),
TypecastLib.castToAddress(p.to)
);
}
This just calls PortalV2.unlock for tokenIn.
Upon decoding the cross-chain message at this last step, we found that unlock() was called with the following arguments :
args:
otoken = EYWA (0x8cb8c4263eb26b2349d74ea2cb1b27bc40709e12)
amount = 999,887,441,776,713,115,294,103,393
from = 0xcda36e1b514fcc52e4ca1238491e6e789a11a8bb
to = Crosscurvefi Exploiter (0x632400f42e96a5deb547a179ca46b02c22cd25cd)
In PortalV2 logic:
function unlock(
address otoken,
uint256 amount,
address from,
address to
) external onlyRouter returns (uint256 amountOut) {
IAddressBook addressBookImpl = IAddressBook(addressBook);
address whitelist = addressBookImpl.whitelist();
address treasury = addressBookImpl.treasury();
require(IWhitelist(whitelist).tokenState(otoken) == uint8(IWhitelist.TokenState.InOut), "Portal: token must be whitelisted");
uint256 feeAmount = amount * IWhitelist(whitelist).bridgeFee(otoken) / FEE_DENOMINATOR;
amountOut = amount - feeAmount;
SafeERC20.safeTransfer(IERC20(otoken), to, amountOut);
SafeERC20.safeTransfer(IERC20(otoken), treasury, feeAmount);
balanceOf[otoken] -= amount;
emit Unlocked(otoken, amount, from, to);
}
The unlock just checks that the token was whitelisted and simply transfers the given token amount to the “to” address, which was carefully crafted to be the attacker’s address.
This is how maximum amount of EYWA tokens was extracted from the Portal using a malicious cross-chain message that wasn't validated or authorized. The amount of EYWA tokens chosen by the attacker was the entire balance of the Portal contract.
Lessons learned from this exploit
Below is the list of quirks that enabled this attack :
- expressExecute() was inherited from AxelarExpressExecutable, but it was not overriden and it led to unauthorized execution. Proper validation existed in the
execute() ⇒ _execute()flow because it was overriden and modified in theReceiverAxelarcontract - The function was publicly callable with an arbitrary payload to execute arbitrary cross-chain messages.
- The Consensus threshold on crossCurve bridge was set to 1 for certain “sourceChains”, meaning no real consensus, and this contradicts the claim made by the protocol.
A simple fix that could have been prevented the attack: The expressExecute() entrypoint needed to be guarded behind a trusted actor, and overriden in the ReceiverAxelar contract to add some validation for the message payload via Axelar’s Gateway, similar to how its done in execute() function.
Note: The expressExecuteWithToken() function from the same AxelarExpressExecutable contract has the same vulnerability; it calls _executeWithToken() without gateway validation. This quirk was not exploited in the Cross Curve incident because the CrossCurve protocol did not add logic for _executeWithToken().
Moreover, the same dangerous pattern is also present in other AxelarExpressExecutable* and AxelarValuedExpressExecutable* contracts: the vulnerable functions are namely:
- AxelarValuedExpressExecutable.expressExecute()
- AxelarExpressExecutableWithToken.expressExecute()
- AxelarExpressExecutableWithToken.expressExecuteWithToken()
- AxelarValuedExpressExecutableWithToken.expressExecuteWithToken()
Some of these contracts were introduced in the latest Axelar GMP SDK v6.0.6.
Actionable tips for integrators ⇒ If you inherit any of these AxelarExpressExecutable* contracts, as suggested in the official Axelar examples, you expose your internal _execute*() function to anyone. Attackers can invoke it with spoofed cross-chain data. Ensure you understand who can trigger _execute, and whether your logic is safe when called with arbitrary parameters.
General Security checklist to prevent such errors :
- Design with symmetrical validation for different functions meant for similar purposes/ outcomes : If expressExecute() would have followed the same validation logic as execute(), the attack would not have been possible.
- For bridges, trust assumptions and guardian thresholds need to be configured properly. Threat modelling can help here.
- While reviewing logic that inherits from other libraries, thoroughly understand all the quirks and edge cases of the integrated service and how it can affect your protocol.
- While reviewing code, it is possible to miss some call paths. Slither can help with this by listing out all the possible entrypoints for a contract, including the inherited ones: https://x.com/nisedo_/status/2019000294105821579?s=20
Additional thoughts about the event
On the part of Axelar, it was observed that better communication/ documentation for integrators could have helped.
Sujith Somraaj, who is an LSR at Spearbit, pointed out that he had reported the exact issue to Axelar 2 years ago on their Immunefi bug bounty program, but his report was rejected as invalid because the team considered it as “intended design”. Nevertheless, it can be understood as bad architecture. According to Sujith, they promised to warn the integrators but failed to do so as the current docs do not mention this risk about a public expressExecute() function executing arbitrary payloads.
You can read the conversation around it here.
We recommend to the Axelar team either redesign the express flow to be opt-in or safely overridable (e.g., making the above functions internal).
We would also encourage clearly documenting that these expressExecute* entry points are permissionless and can execute the payload without prior Gateway validation, and that integrators should explicitly decide whether to restrict or disable them for their use case.
The Aftermath of the incident
The exploit was first reported on Ethereum, followed by many attack transactions on Arbitrum (targeting unlock of different assets held by CrossCurve PortalV2 on these chains).
- Most prominent exploit transaction on Ethereum: https://etherscan.io/tx/0x37d9b911ef710be851a2e08e1cfc61c2544db0f208faeade29ee98cc7506ccc2
- First exploit transaction on Arbitrum: https://arbiscan.io/tx/0x6b8a31171c081e079f24b1c72f97df3278c51fa05b8a390e26ba1da618b109a1
- Victim contract on Ethereum: EYWA PortalV2
- Victim contract on Arbitrum: EYWA PortalV2
Where are the funds now ?
- On Arbitrum, the attacker swapped most assets into WETH using Cow Protocol and bridged them to $ETH on Ethereum Mainnet via Across Protocol. Then they laundered all the $ETH to this address, where they are sitting currently.
- On Ethereum, the attacker gained EYWA tokens but could not swap them because there was no liquidity. So all the $EYWA tokens are currently sitting in the attacker address on Ethereum itself.
The CrossCurve team provided a complete list of all the assets stolen, total worth of $1.4 million, excluding the $EYWA tokens extracted on Ethereum.
CrossCurve team even offered a 10 % bounty to the attacker. A few days later, the CrossCurve team attempted to incentivize the attacker to return the exploited funds by offering a 20 % compensation.
Conclusion
This exploit demonstrates how weak input validation and authorization checks can make the code execute arbitrary calldata. Even though the team responded swiftly with mitigations and coordination efforts ( including threatening legal action and identifying attacker addresses and their associations with CEX entities), the attacker has not responded to project’s bounty offers and no further fund movement was seen on these addresses.
The project team also announced cooperation with CEXs and bridge operators, coordination with Circle on USDC, on-chain attribution via analytics firms, and public disclosure of wallet traces.
The incident reinforces the need for proper threat modelling of the entire system, and hardening of all execution paths by restricting them with guardrails.
Secure Your Protocol with Cantina
The CrossCurve exploit is a stark reminder that even well-intentioned integrations can lead to losses if inherited code isn't rigorously tested. In the digital assets industry, maintaining security is vital for preserving trust, don't wait for a post-mortem to find out where your blind spots lie.
Cantina is the intelligent security platform combining advanced AI, elite domain expertise, and a community of over 8,800 top-tier security researchers to find real risk before attackers do. Trusted by industry leaders like Coinbase, Uniswap Labs, and Euler, Cantina has successfully secured over $98B in TVL and analyzed more than 50,000 real-world vulnerabilities.
Whether you are building a new DeFi primitive or integrating complex bridge infrastructure, Cantina provides an end-to-end security solution:
- World-Class Audits: Rigorous smart contract reviews by hand-picked elite teams or through open code competitions to ensure your logic is bulletproof.
- AI Code Analyzer: Surface real, exploitable risks in your repositories ranked by actual impact, not noisy vanity findings.
- Elite Bug Bounties: Incentivize the best minds in Web3 security to continuously protect your live funds.
- Managed Detection and Response: Detect, respond, and resolve threats at speed when every second counts.
Built for organizations that can't afford failure. Contact Cantina to elevate your application's security posture.
