Status DataClose notification

Overlayer Disclosed Report

Bridge transfers to net-receiver chains permanently blackhole bridged OVA

Company
Created date
Apr 04 2026

Target

hidden

Vulnerability Details

OverlayerWrapCore tracks bridge outflow with the local variable totalBridgedOut. The accounting logic assumes that every destination chain can safely decrement its own local counter when an inbound LayerZero message is delivered.

The implementation does not satisfy that assumption:

  • contracts/overlayer/OverlayerWrapCore.sol:510 increments totalBridgedOut inside _debit() on the source chain.
  • contracts/overlayer/OverlayerWrapCore.sol:537 decrements totalBridgedOut inside _credit() on the destination chain.

These are two different storage slots on two different chains. As a result, any chain that is a net receiver can reach a state where the inbound amount is larger than its local totalBridgedOut. The simplest case is the first legitimate inbound transfer to a fresh satellite chain, where the destination counter is still zero.

Attack / failure sequence:

  1. A user mints OVA on the source chain and bridges it to a destination chain.
  2. _debit() executes on the source chain, burns the user’s OVA, and increments the source chain’s totalBridgedOut.
  3. The LayerZero message is later delivered to the destination chain.
  4. _credit() executes on the destination chain, mints via super._credit(...), and then executes totalBridgedOut -= amountReceivedLD.
  5. Because the destination chain’s local totalBridgedOut is smaller than the inbound amount, the subtraction underflows and reverts.

The user’s OVA has already been burned on the source chain before the destination-side revert occurs. Retrying message delivery reaches the same revert condition, so the transfer is permanently blackholed.

This issue affects standard non-privileged bridge usage and can permanently lock 100% of the bridged amount for the affected user.

Validation steps

  1. Save the PoC below as forge-test/BridgeCreditUnderflow.t.sol.
  2. Run forge test --match-test testFirstInboundDeliveryBlackholesBurnedTransfer -vv.
  3. Observe that:
    • the source-chain balance is burned immediately after send(...);
    • the destination-chain delivery reverts on the first legitimate inbound transfer;
    • retrying delivery reverts again;
    • the destination user balance remains zero throughout.

4.poc

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;

import {OverlayerWrap} from "../contracts/overlayer/OverlayerWrap.sol";
import {IOverlayerWrapDefs} from "../contracts/overlayer/interfaces/IOverlayerWrapDefs.sol";
import {OverlayerWrapCoreTypes} from "../contracts/overlayer/types/OverlayerWrapCoreTypes.sol";
import {SixDecimalsUsd} from "../contracts/mock_ERC20/mocks/SixDecimalsUsd.sol";
import {
    MessagingFee,
    MessagingParams,
    MessagingReceipt,
    Origin
} from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol";
import {SendParam} from "@layerzerolabs/oft-evm/contracts/interfaces/IOFT.sol";

interface Vm {
    function prank(address) external;
    function startPrank(address) external;
    function stopPrank() external;
    function expectRevert() external;
}

contract MockLayerZeroEndpoint {
    error Unauthorized();
    error InvalidNonce();

    mapping(address => address) public delegates;
    uint64 public nextNonce = 1;
    mapping(address receiver => mapping(uint32 srcEid => mapping(bytes32 sender => uint64 nonce)))
        public lazyInboundNonce;

    struct PendingMessage {
        address sender;
        bytes32 receiver;
        bytes message;
        uint64 nonce;
    }

    mapping(bytes32 => PendingMessage) public pendingMessages;

    function setDelegate(address delegate_) external {
        delegates[msg.sender] = delegate_;
    }

    function quote(
        MessagingParams calldata,
        address
    ) external pure returns (MessagingFee memory fee) {
        fee = MessagingFee({nativeFee: 0, lzTokenFee: 0});
    }

    function send(
        MessagingParams calldata params_,
        address
    ) external payable returns (MessagingReceipt memory receipt) {
        bytes32 guid = keccak256(
            abi.encode(msg.sender, params_.dstEid, params_.receiver, nextNonce)
        );
        pendingMessages[guid] = PendingMessage({
            sender: msg.sender,
            receiver: params_.receiver,
            message: params_.message,
            nonce: nextNonce
        });
        receipt = MessagingReceipt({
            guid: guid,
            nonce: nextNonce,
            fee: MessagingFee({nativeFee: 0, lzTokenFee: 0})
        });
        nextNonce++;
    }

    function deliver(bytes32 guid_, uint32 srcEid_) external {
        PendingMessage memory pending = pendingMessages[guid_];
        address receiver = address(uint160(uint256(pending.receiver)));
        bytes32 sender = bytes32(uint256(uint160(pending.sender)));

        if (pending.nonce <= lazyInboundNonce[receiver][srcEid_][sender]) {
            revert InvalidNonce();
        }

        bytes memory callData = abi.encodeWithSignature(
            "lzReceive((uint32,bytes32,uint64),bytes32,bytes,address,bytes)",
            Origin({
                srcEid: srcEid_,
                sender: sender,
                nonce: pending.nonce
            }),
            guid_,
            pending.message,
            address(this),
            bytes("")
        );

        (bool ok, bytes memory reason) = receiver.call(callData);
        if (!ok) {
            assembly {
                revert(add(reason, 32), mload(reason))
            }
        }

        delete pendingMessages[guid_];
    }
}

contract BridgeCreditUnderflowTest {
    Vm internal constant vm =
        Vm(address(uint160(uint256(keccak256("hevm cheat code")))));

    address internal victim = address(0xB0B);
    MockLayerZeroEndpoint internal endpoint;
    SixDecimalsUsd internal collateral;
    SixDecimalsUsd internal aCollateral;

    function setUp() public {
        endpoint = new MockLayerZeroEndpoint();
        collateral = new SixDecimalsUsd(1_000_000, "USDT", "USDT");
        aCollateral = new SixDecimalsUsd(1_000_000, "aUSDT", "aUSDT");
    }

    function _buildParams()
        internal
        view
        returns (IOverlayerWrapDefs.ConstructorParams memory params)
    {
        params = IOverlayerWrapDefs.ConstructorParams({
            admin: address(this),
            lzEndpoint: address(endpoint),
            name: "Overlayer",
            symbol: "OVA+",
            collateral: OverlayerWrapCoreTypes.StableCoin({
                addr: address(collateral),
                decimals: 6
            }),
            aCollateral: OverlayerWrapCoreTypes.StableCoin({
                addr: address(aCollateral),
                decimals: 6
            }),
            maxMintPerBlock: type(uint256).max,
            maxRedeemPerBlock: type(uint256).max,
            minValmaxRedeemPerBlock: 1,
            hubChainId: block.chainid
        });
    }

    function testFirstInboundDeliveryBlackholesBurnedTransfer() public {
        IOverlayerWrapDefs.ConstructorParams memory params = _buildParams();

        OverlayerWrap sourceToken = new OverlayerWrap(params);
        OverlayerWrap destinationToken = new OverlayerWrap(params);

        sourceToken.setPeer(
            101,
            bytes32(uint256(uint160(address(destinationToken))))
        );
        destinationToken.setPeer(
            100,
            bytes32(uint256(uint160(address(sourceToken))))
        );

        collateral.transfer(victim, 1e6);

        vm.startPrank(victim);
        collateral.approve(address(sourceToken), type(uint256).max);
        sourceToken.mint(
            OverlayerWrapCoreTypes.Order({
                benefactor: victim,
                beneficiary: victim,
                collateral: address(collateral),
                collateralAmount: 1e6,
                overlayerWrapAmount: 1 ether
            })
        );

        (MessagingReceipt memory receipt, ) = sourceToken.send(
            SendParam({
                dstEid: 101,
                to: bytes32(uint256(uint160(victim))),
                amountLD: 1 ether,
                minAmountLD: 1 ether,
                extraOptions: "",
                composeMsg: "",
                oftCmd: ""
            }),
            MessagingFee({nativeFee: 0, lzTokenFee: 0}),
            victim
        );
        vm.stopPrank();

        require(
            sourceToken.balanceOf(victim) == 0,
            "source bridge-out should burn before delivery"
        );
        require(
            destinationToken.totalBridgedOut() == 0,
            "fresh destination starts with zero bridge-out counter"
        );

        vm.expectRevert();
        endpoint.deliver(receipt.guid, 100);

        require(
            destinationToken.balanceOf(victim) == 0,
            "destination credit must fail"
        );

        vm.expectRevert();
        endpoint.deliver(receipt.guid, 100);
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$69
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden