MEDIUM severity: No Way to Invoke Some Privileged Functions of Forwarders. The `ForwarderBase` contract includes privileged functions like `setCrossDomainAdmin` and `updateAdapter` that can only be called via cross-chain messages from the `crossDomainAdmin`. However, the `Router_Adapter` contract, which is the intended communication path from L1, only supports `relayMessag...

Vulnerable code:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import { ForwarderInterface } from "./interfaces/ForwarderInterface.sol";
import { AdapterInterface } from "./interfaces/AdapterInterface.sol";

/**
 * @title ForwarderBase
 * @notice This contract expects to receive messages and tokens from an authorized sender on L1 and forwards messages and tokens to spoke pool contracts on
 * L3. Messages are intended to originate from the hub pool. The motivating use case for this contract is to aid with sending messages from L1 to an L3, which
 * by definition is a network which does not have a direct connection with L1 but instead must communicate with that L1 via an L2. Each contract that extends
 * the ForwarderBase maintains a mapping of chain IDs to a bridge adapter addresses. For example, if this contract is deployed on Arbitrum, then this mapping
 * would send L3 chain IDs which roll up to Arbitrum to an adapter contract address deployed on Arbitrum which directly interfaces with the L3 token/message
 * bridge. In other words, this contract maintains a mapping of important contracts which helps transmit messages to the "next layer".
 * @custom:security-contact bugs@across.to
 */
abstract contract ForwarderBase is UUPSUpgradeable, ForwarderInterface {
    // Address that can relay messages using this contract and also upgrade this contract.
    address public crossDomainAdmin;

    // Map from a destination chain ID to the address of an adapter contract which interfaces with the L2-L3 bridge. The destination chain ID corresponds to
    // the network ID of an L3. These chain IDs are used as the key in this mapping because network IDs are enforced to be unique. Since we require the chain
    // ID to be sent along with a message or token relay, ForwarderInterface's relay functions include an extra field, `destinationChainId`, when compared to the
    // relay functions of `AdapterInterface`.
    mapping(uint256 => address) chainAdapters;

    event ChainAdaptersUpdated(uint256 indexed destinationChainId, address l2Adapter);
    event SetXDomainAdmin(address indexed crossDomainAdmin);

    error InvalidCrossDomainAdmin();
    error InvalidChainAdapter();
    error RelayMessageFailed();
    error RelayTokensFailed(address baseToken);
    // Error which is triggered when there is no adapter set in the `chainAdapters` mapping.
    error UninitializedChainAdapter();

    /*
     * @dev Cross domain admin permissioning is implemented specifically for each L2 that this contract is deployed on, so this base contract
     * simply prescribes this modifier to protect external functions using that L2's specific admin permissioning logic.
     */
    modifier onlyAdmin() {
        _requireAdminSender();
        _;
    }

    /**
     * @notice Constructs the Forwarder contract.
     * @dev _disableInitializers() restricts anybody from initializing the implementation contract, which if not done,
     * may disrupt the proxy if another EOA were to initialize it.
     */
    constructor() {
        _disableInitializers();
    }

    /**
     * @notice Initializes the forwarder contract.
     * @param _crossDomainAdmin L1 address of the contract which can send root bundles/messages to this forwarder contract.
     */
    function __Forwarder_init(address _crossDomainAdmin) public onlyInitializing {
        __UUPSUpgradeable_init();
        _setCrossDomainAdmin(_crossDomainAdmin);
    }

    /**
     * @notice Sets a new cross domain admin for this contract.
     * @param _newCrossDomainAdmin L1 address of the new cross domain admin.
     */
    function setCrossDomainAdmin(address _newCrossDomainAdmin) external onlyAdmin {
        if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin();
        _setCrossDomainAdmin(_newCrossDomainAdmin);
        emit SetXDomainAdmin(_newCrossDomainAdmin);
    }

    /**
     * @notice Maps a new destination chain ID to an adapter contract which facilitates bridging to that chain.
     * @param _destinationChainId The chain ID of the target network.
     * @param _l2Adapter Contract address of the adapter which interfaces with the L2-L3 bridge.
     * @dev Actual bridging logic is delegated to the adapter contract so that the forwarder can function irrespective of the "flavor" of
     * L3 (e.g. ArbitrumOrbit, OpStack, etc.).
     */
    function updateAdapter(uint256 _destinationChainId, address _l2Adapter) external onlyAdmin {
        if (_l2Adapter == address(0)) revert InvalidChainAdapter();
        chainAdapters[_destinationChainId] = _l2Adapter;
        emit ChainAdaptersUpdated(_destinationChainId, _l2Adapter);
    }

    /**
     * @notice Relays a specified message to a contract on L3. This contract assumes that `target` exists on the L3 and can properly
     * receive the function being called.
     * @param target The address of the spoke pool contract that will receive the input message.
     * @param destinationChainId The chain ID of the network which contains `target`.
     * @param message The data to execute on the target contract.
     */
    function relayMessage(
        address target,
        uint256 destinationChainId,
        bytes memory message
    ) external payable override onlyAdmin {
        address adapter = chainAdapters[destinationChainId];
        if (adapter == address(0)) revert UninitializedChainAdapter();

        // The forwarder assumes that `target` exists on the following network.
        (bool success, ) = adapter.delegatecall(abi.encodeCall(AdapterInterface.relayMessage, (target, message)));
        if (!success) revert RelayMessageFailed();
        emit MessageForwarded(target, destinationChainId, message);
    }

    /**
     * @notice Relays `amount` of a token to a contract on L3. Importantly, this contract assumes that `target` exists on L3.
     * @param baseToken This layer's address of the token to send.
     * @param destinationChainToken The next layer's address of the token to send.
     * @param amount The amount of the token to send.
     * @param destinationChainId The chain ID of the network which contains `target`.
     * @param target The address of the contract that which will *ultimately* receive the tokens. For most cases, this is the spoke pool contract on L3.
     * @dev While `relayMessage` also assumes that `target` is correct, this function has the potential of deleting funds if `target` is incorrectly set.
     * This should be guarded by the logic of the Hub Pool on L1, since the Hub Pool will always set `target` to the L3 spoke pool per UMIP-157.
     */
    function relayTokens(
        address baseToken,
        address destinationChainToken,
        uint256 amount,
        uint256 destinationChainId,
        address target
    ) external payable override onlyAdmin {
        address adapter = chainAdapters[destinationChainId];
        if (adapter == address(0)) revert UninitializedChainAdapter();

        (bool success, ) = adapter.delegatecall(
            abi.encodeCall(AdapterInterface.relayTokens, (baseToken, destinationChainToken, amount, target))
        );
        if (!success) revert RelayTokensFailed(baseToken);
        emit TokensForwarded(baseToken, destinationChainToken, amount, destinationChainId, target);
    }

    // Function to be overridden in order to authenticate that messages sent to this contract originated
    // from the expected account.
    function _requireAdminSender() internal virtual;

    // We also want to restrict who can upgrade this contract. The same admin that can relay messages through this
    // contract can upgrade this contract.
    function _authorizeUpgrade(address) internal virtual override onlyAdmin {}

    function _setCrossDomainAdmin(address _newCrossDomainAdmin) internal {
        if (_newCrossDomainAdmin == address(0)) revert InvalidCrossDomainAdmin();
        crossDomainAdmin = _newCrossDomainAdmin;
        emit SetXDomainAdmin(_newCrossDomainAdmin);
    }

    // Reserve storage slots for future versions of this base contract to add state variables without
    // affecting the storage layout of child contracts. Decrement the size of __gap whenever state variables
    // are added. This is at bottom of contract to make sure it's always at the end of storage.
    uint256[1000] private __gap;
}

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.0;

import { AdapterInterface } from "./interfaces/AdapterInterface.sol";
import { ForwarderInterface } from "./interfaces/ForwarderInterface.sol";
import { HubPoolInterface } from "../interfaces/HubPoolInterface.sol";

/**
 * @notice Contract containing logic to send messages from L1 to "L3" networks that do not have direct connections
 * with L1. L3's are defined as networks that connect to L1 indirectly via L2, and this contract sends
 * messages to those L3's by rerouting them via those L2's. This contract is called a "Router" because it uses
 * (i.e. delegatecall's) existing L2 adapter logic to send a message first from L1 to L2 and then from L2 to L3.
 * @dev Due to the constraints of the `SetCrossChainContracts` event as outlined in UMIP-157 and how the HubPool
 * delegatecalls adapters like this one, all messages relayed through this
 * adapter have target addresses on the L3's. However, these target addresses do not exist on L2 where all messages are
 * rerouted through. Therefore, this contract is designed to be used in tandem with "L2 Forwarder Adapters" which help
 * get the messages from L1 to L3 via L2's.
 * @dev Public functions calling external contracts do not guard against reentrancy because they are expected to be
 * called via delegatecall, which will execute this contract's logic within the context of the originating contract.
 * For example, the HubPool will delegatecall these functions, therefore its only necessary that the HubPool's methods
 * that call this contract's logic guard against reentrancy.
 * @custom:security-contact bugs@across.to
 */

// solhint-disable-next-line contract-name-camelcase
contract Router_Adapter is AdapterInterface {
    // Adapter designed to relay messages from L1 to L2 addresses and delegatecalled by this contract to reroute
    // messages to L3 via the L2_TARGET.
    address public immutable L1_ADAPTER;
    // L2_TARGET is a "Forwarder" contract that will help relay messages from L1 to L3. Messages are "rerouted" through
    // the L2_TARGET.
    address public immutable L2_TARGET;
    // L2_CHAIN_ID is the chain ID of the network which is "in between" the L1 and L3. This network will contain the forwarder.
    uint256 public immutable L2_CHAIN_ID;
    // L3_CHAIN_ID is the chain ID of the network which contains the target spoke pool. This chain id is passed to the
    // forwarder contract so that it may determine the correct L2-L3 bridge to use to arrive at L3.
    uint256 public immutable L3_CHAIN_ID;
    // Interface of the Hub Pool. Used to query state related to L1-L2 token mappings.
    HubPoolInterface public immutable HUB_POOL;

    error RelayMessageFailed();
    error RelayTokensFailed(address l1Token);
    error L2RouteNotWhitelisted(address l1Token);

    /**
     * @notice Constructs new Adapter. This contract will re-route messages destined for an L3 to L2_TARGET via the L1_ADAPTER contract.
     * @param _l1Adapter Address of the adapter contract on mainnet which implements message transfers
     * and token relays to the L2 where _l2Target is deployed.
     * @param _l2Target Address of the L2 contract which receives the token and message relays in order to forward them to an L3.
     * @param _l2ChainId Chain ID of the network which contains the forwarder. It is the network which is intermediate in message
     * transmission from L1 to L3.
     * @param _l3ChainId Chain ID of the network which contains the spoke pool which corresponds to this adapter instance.
     * @param _hubPool Address of the hub pool deployed on L1.
     */
    constructor(
        address _l1Adapter,
        address _l2Target,
        uint256 _l2ChainId,
        uint256 _l3ChainId,
        HubPoolInterface _hubPool
    ) {
        L1_ADAPTER = _l1Adapter;
        L2_TARGET = _l2Target;
        L2_CHAIN_ID = _l2ChainId;
        L3_CHAIN_ID = _l3ChainId;
        HUB_POOL = _hubPool;
    }

    /**
     * @notice Send cross-chain message to a target on L2 which will forward messages to the intended remote target on an L3.
     * @param target Address of the remote contract which receives `message` after it has been forwarded by all intermediate
     * contracts.
     * @param message Data to send to `target`.
     * @dev The message passed into this function is wrapped into a `relayMessage` function call, which is then passed
     * to L2. The `L2_TARGET` contract implements AdapterInterface, so upon arrival on L2, the arguments to the L2 contract's
     * `relayMessage` call will be these `target` and `message` values. From there, the forwarder derives the next appropriate
     * method to send `message` to the following layers and ultimately to the target on L3.
     */
    function relayMessage(address target, bytes memory message) external payable override {
        bytes memory wrappedMessage = abi.encodeCall(ForwarderInterface.relayMessage, (target, L3_CHAIN_ID, message));
        (bool success, ) = L1_ADAPTER.delegatecall(
            abi.encodeCall(AdapterInterface.relayMessage, (L2_TARGET, wrappedMessage))
        );
        if (!success) revert RelayMessageFailed();
    }

    /**
     * @notice Bridge tokens to a target on L2 and follow up the token bridge with a call to continue bridging the sent tokens.
     * @param l1Token L1 token to deposit.
     * @param l3Token L3 token to receive.
     * @param amount Amount of L1 tokens to deposit and L3 tokens to receive.
     * @param target The address of the contract which should ultimately receive `amount` of `l3Token`.
     * @dev When sending tokens, we follow-up with a message describing the amount of tokens we wish to continue bridging.
     * This allows forwarders to know how much of some token to allocate to a certain target.
     */
    function relayTokens(
        address l1Token,
        address l3Token,
        uint256 amount,
        address target
    ) external payable override {
        // Fetch the address of the L2 token, as defined in the Hub Pool. This is to complete a proper token bridge from L1 to L2.
        address l2Token = HUB_POOL.poolRebalanceRoute(L2_CHAIN_ID, l1Token);
        // If l2Token is the zero address, then this means that the L2 token is not enabled as a pool rebalance route. This is similar
        // to the check in the hub pool contract found here: https://github.com/across-protocol/contracts/blob/a2afefecba57177a62be35be092516d0c106097e/contracts/HubPool.sol#L890
        if (l2Token == address(0)) revert L2RouteNotWhitelisted(l1Token);

        // Relay tokens to the forwarder.
        (bool success, ) = L1_ADAPTER.delegatecall(
            abi.encodeCall(AdapterInterface.relayTokens, (l1Token, l2Token, amount, L2_TARGET))
        );
        if (!success) revert RelayTokensFailed(l1Token);

        // Follow-up token relay with a message to continue the token relay on L2.
        bytes memory message = abi.encodeCall(
            ForwarderInterface.relayTokens,
            (l2Token, l3Token, amount, L3_CHAIN_ID, target)
        );
        (success, ) = L1_ADAPTER.delegatecall(abi.encodeCall(AdapterInterface.relayMessage, (L2_TARGET, message)));
        if (!success) revert RelayMessageFailed();
    }
}


Vulnerability type: CWE-435|CWE-436|CWE-437