Status DataClose notification

Overlayer Disclosed Report

Destination-local totalBridgedOut underflow on first inbound can permanently strand bridged funds

Company
Created date
Mar 28 2026

Target

hidden

Vulnerability Details

Summary

This issue is a regression in the cross-chain accounting fix introduced in the scoped commit. The protocol added totalBridgedOut to preserve effective supply after OFT burns, but that value is stored per token instance, not globally. On the source chain, _debit() increases the local counter. On the destination chain, _credit() decreases the destination-local counter. A fresh non-hub remote starts with totalBridgedOut == 0, so its first inbound credit attempts to subtract from zero and reverts after the source-side burn path has already executed. The result is a full lock of the affected bridged amount under normal protocol logic. This report targets the exact DualDefense scoped commit and the explicitly listed in-scope contracts.


Severity: Critical


Impact

The impact is a direct and permanent lock of end-user bridged funds in the affected transfer.

This is not a theoretical issue and it does not depend on privileged abuse. The failure occurs in the intended OFT send and receive path once a fresh non-hub remote receives its first inbound transfer. The attached proof of concept shows the burn on the hub side, the destination-side panic11, repeated retry failure, zero accessible user balance on both ends, and 100 percent of the affected amount stranded in bookkeeping only.

If the same pre-state is reached in production, the user’s source balance is already gone, the destination never credits, and the bridged amount cannot be recovered through the ordinary user-callable protocol paths demonstrated by the proof of concept. That is a permanent lock outcome for the affected transfer amount.


Why this is a permanent lock bug

The key point is call ordering.

The source-side OFT flow burns first through _debit(). The destination-side flow then reaches _credit(). In this implementation, _credit() performs the underlying credit logic and then decrements totalBridgedOut. On a fresh remote, that counter is still zero. Solidity arithmetic reverts, which unwinds the destination credit, but the source-side burn has already happened in the separate source transaction path that initiated the bridge.

That is why this is not a mere accounting mismatch. The user does not end up with a bad view of balance. The user ends up with no accessible balance on either side of the bridge, while the amount remains represented only by bookkeeping on the source instance.


Why this is not a mock artifact

The proof of concept uses OverlayerWrapMock only as a minimal harness to expose the already deployed internal paths _debit() and _credit(). It does not modify their logic. The mock simply forwards external calls to the real internal implementations. The bug is therefore in OverlayerWrapCore, not in the harness.


Vulnerability details

1. totalBridgedOut is local state, and hub-only logic prevents normal local seeding on a remote

From OverlayerWrapCore.sol:

uint256 public hubChainId;
uint256 public totalBridgedOut;

From OverlayerWrapCore.sol:

onlyHubChain(block.chainid)

From OverlayerWrapCore.sol:

onlyHubChain(block.chainid)

totalBridgedOut is ordinary contract storage on each instance. It is not shared across chains. At the same time, mint and redeem manager paths are restricted to hubChainId. That means a fresh non-hub remote has no normal local mint or redeem path that can initialize or increase its own totalBridgedOut before the first inbound OFT receive arrives.


2. Source-side OFT debit increases only the source-local counter

From OverlayerWrapCore.sol:

totalBridgedOut += amountSentLD;

This update happens only on the sending instance. It records that a local burn has occurred and that effective supply must still be counted for backing purposes. It does not update any remote contract state.


3. Destination-side OFT credit decreases the destination-local counter after credit logic

From OverlayerWrapCore.sol:

amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD;

This is the root cause. The destination instance decrements its own local totalBridgedOut, but a fresh remote starts at zero and had no previous outbound burn from that instance. The subtraction underflows and reverts. Since the revert happens inside _credit(), the destination mint does not persist. The first inbound transfer to a fresh non-hub remote therefore fails deterministically.


4. The remediation explicitly tied backing accounting to this counter

From AaveHandler.sol:

totalSupply() + totalBridgedOut()

This line shows why totalBridgedOut was introduced. The fix wanted backing calculations to treat burned-outgoing OFT supply as still economically outstanding. That goal is reasonable, but the destination-side decrement assumes the same counter is meaningful on the receiving instance. It is not. The counter is local, while the bridge event is cross-instance.


5. The proof of concept harness is a pure forwarder

From OverlayerWrapMock.sol:

return _debit(from_, amountLD_, minAmountLD_, dstEid_);
return _credit(to_, amountLD_, srcEid_);

The harness does not add new behavior. It exposes the real internal bridge accounting methods so the regression can be isolated cleanly.


Attack scenario

A realistic failure sequence is straightforward:

  1. A user holds wrapped balance on the hub.
  2. A remote instance exists with hubChainId != block.chainid.
  3. That remote has not performed any prior outbound bridge activity, so its local totalBridgedOut is still zero.
  4. The user initiates the first bridge into that remote.
  5. The source-side _debit() burns and increments the source-local totalBridgedOut.
  6. The destination-side _credit() reaches totalBridgedOut -= amountReceivedLD and reverts.
  7. Retry attempts hit the same state and fail again.
  8. Normal user recovery paths remain unavailable because the user has no balance left on the hub, and the remote is non-hub and uncredited.

This is exactly the state demonstrated by the passing proof of concept.


Validation steps

Proof of Concept

What the proof of concept proves

The attached overlayer.sh automates the full reproduction. It downloads the scoped repository, pins the audited commit, installs dependencies, writes the isolated harness and test files, compiles, and runs the proof of concept.

The passing run demonstrates all of the following:

  • a control path where debit and credit occur on the same instance and succeed
  • a fresh remote probe where the source burn succeeds
  • destination _credit() reverting with panic11
  • repeated retries reverting with the same failure
  • no normal hub-side redeem path after the burn
  • no local mint or local debit path on the non-hub destination
  • zero accessible user balance on both ends after the failed bridge
  • the full bridged amount remaining stranded in bookkeeping only
  • 10000 basis points locked, which is 100 percent of the affected deposit

How to reproduce

Download overlayer.zip, which contains the complete script that downloads the repository, installs the dependencies, and runs the proof of concept.

To reproduce the proof of concept:

unzip overlayer.zip
chmod +x overlayer.sh
bash overlayer.sh

This is the exact reproduction flow expected by this report.

Output

Bridge
╔═══════════════════════════════╤═══════════════════════╤═══════════════════════╗
║   TOTALBRIDGEDOUT DESTINATION CREDIT UNDERFLOW (FRESH REMOTE FIRST INBOUND)   ║
╠═══════════════════════════════╪═══════════════════════╪═══════════════════════╣
║ Metric                        │     Local Control     │  Fresh Remote Probe   ║
╠═══════════════════════════════╪═══════════════════════╪═══════════════════════╣
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║                                 [ PRE-STATE ]                                 ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Bridge Amount                 │ 100000000000000000000 │ 100000000000000000000 ║
║ Hub Alice Before              │ 100000000000000000000 │ 100000000000000000000 ║
║ Dst Alice Before              │                   N/A │                     0 ║
║ Hub Supply Before             │ 100000000000000000000 │ 100000000000000000000 ║
║ Hub BridgedOut Before         │                     0 │                     0 ║
║ Dst Supply Before             │                   N/A │                     0 ║
║ Dst BridgedOut Before         │                   N/A │                     0 ║
║ Dst Is Fresh?                 │                   N/A │                   YES ║
║ Dst Is Non-Hub?               │                   N/A │                   YES ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║                                 [ EXECUTION ]                                 ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Source Debit Success?         │                   YES │                   YES ║
║ Credit Target                 │                   hub │                   dst ║
║ Credit Result                 │                    ok │        revert panic11 ║
║ Retry Result                  │                   N/A │        revert panic11 ║
║ Hub Redeem Path               │                   YES │                    NO ║
║ Dst Redeem Path               │                   N/A │                    NO ║
║ Dst Local Mint Path           │                   N/A │                    NO ║
║ Dst Local Debit Path          │                   N/A │                    NO ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║                                [ POST-STATE ]                                 ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Hub Alice After               │ 100000000000000000000 │                     0 ║
║ Dst Alice After               │                   N/A │                     0 ║
║ Hub Supply After              │ 100000000000000000000 │                     0 ║
║ Hub BridgedOut After          │                     0 │ 100000000000000000000 ║
║ Dst Supply After              │                   N/A │                     0 ║
║ Dst BridgedOut After          │                   N/A │                     0 ║
║ Accessible User Balance       │ 100000000000000000000 │                     0 ║
║ Bookkeeping-Only Amount       │                     0 │ 100000000000000000000 ║
║ Stranded Amount               │                     0 │ 100000000000000000000 ║
║ Locked Share of Deposit (bps) │                     0 │                 10000 ║
║ In-Logic Recovery Possible?   │                   YES │                    NO ║
╚═══════════════════════════════╧═══════════════════════╧═══════════════════════╝

╔═══════════════════╤═══════════════════════╤════════════════════════════════════════════════╗
║                                       DELTA ANALYSIS                                       ║
╠═══════════════════╪═══════════════════════╪════════════════════════════════════════════════╣
║ Metric            │                 Delta │ Meaning                                        ║
╠═══════════════════╪═══════════════════════╪════════════════════════════════════════════════╣
║ accessibleGap     │ 100000000000000000000 │ user-accessible balance lost in probe          ║
║ strandedGap       │ 100000000000000000000 │ burn-not-credited amount                       ║
║ bookGap           │ 100000000000000000000 │ value stranded only in bookkeeping             ║
║ depositLockGapBps │                 10000 │ share of the affected deposit locked           ║
║ dstCreditGap      │                     1 │ first inbound works in control, fails in probe ║
║ retryGap          │                     1 │ retry remains impossible in probe              ║
╚═══════════════════╧═══════════════════════╧════════════════════════════════════════════════╝

-> Source burn applied
-> Fresh destination first inbound reverts on arithmetic underflow
-> Repeated retries remain impossible
-> User recovery paths fail under normal protocol logic
-> User balance stays zero on both ends
-> Bridged amount remains stranded in bookkeeping only
-> Locked share of affected deposit = 10000 bps (100%)
    ✔ proves the regression and path-B user-level no-recovery state (2919ms)


  1 passing (3s)

Recommendation

The fix should ensure that a destination instance never decrements a local counter that was not created by a local outbound debit.

A robust fix can follow one of these directions:

  1. Update totalBridgedOut only on the source or hub bookkeeping domain, and never decrement it on the destination instance.
  2. Keep per-instance outbound ledgers and decrement only amounts that were previously incremented by the same instance.
  3. Add a regression test with two different instances: a hub and a fresh non-hub remote, where the first inbound receive is executed after a source-side debit.

The safest long-term design is the first one. The variable was introduced to preserve effective supply for backing calculations after source-side burns. That purpose is source-local and should remain source-local.


Why this is Critical under DualDefense criteria

The DualDefense program pays only Critical issues and defines in-scope Critical impact as loss of end-user funds or permanent lock of end-user funds. It also requires a working PoC and a clear mitigation. This report satisfies those conditions on the scoped commit. This issue also matches the program’s high-likelihood model:

  • no privileged role is required
  • no large capital is required
  • the condition set is narrow
  • the proof of concept demonstrates full lock of the affected bridged amount for the reproduced transfer

The attached output shows a 100 percent lock of the affected deposit, with Accessible User Balance = 0, Stranded Amount = 100000000000000000000, and Locked Share of Deposit (bps) = 10000.


Why this issue is in scope

This finding relies only on the explicitly scoped DualDefense commit and the contracts listed in scope by the program, especially OverlayerWrapCore.sol and AaveHandler.sol. It does not depend on imported-contract bugs, front-running, style issues, or privileged abuse.

The bug is in the protocol’s own cross-chain accounting logic inside the scoped files, and the proof of concept demonstrates the failure directly on that code path.

Attachments

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