F-2026-0003·missing-msg-value-validation

Missing msg.value validation in transferOnly remainder path enables zero-cost drain of entire bridge native balance

Fixedbridgecross-chainkey-registrygithub.com/pdxwebdev/yadakeyeventwallet
TL;DR

The transferOnly remainder branch in _executePermits sends native tokens from the bridge's reserves without validating msg.value, letting any caller drain the entire native balance for the cost of a fresh key inception.

Severity
CRITICAL
Impact
HIGH
Likelihood
HIGH
Method
MManual review
CAT.
Complexity
LOW
Exploitability
HIGH
02Section · Description

Description

The _executePermits function processes native token transfers without validating that msg.value covers the total transfer amount. For the transferOnly code path, the bridge sends native tokens from its own reserves based solely on permit.amount, with zero validation that the caller provided sufficient msg.value.

Root Cause

For ERC20 tokens, the permit + safeTransferFrom mechanism naturally limits transfers to what the user signed for. For native tokens (address(0)), no equivalent mechanism exists. The bridge simply calls _transferNative using its own balance.

solidity
// Bridge.sol lines 331-341
if (transferOnly) {
uint256 remainder = permit.amount - totalTransferred;
if (remainder > 0) {
if (ectx.token == address(0)) {
_transferNative(ectx.prerotatedKeyHash, remainder); // Sends from bridge reserves!
} else {
IERC20(permit.token).safeTransferFrom(ectx.user, ectx.prerotatedKeyHash, remainder);
}
totalTransferred += remainder;
}
}

No check exists that msg.value >= permit.amount or msg.value >= totalTransferred.

Vulnerable Scenario

Prerequisites: Bridge holds any amount of native tokens from legitimate users' wraps.

  1. Attacker generates fresh keys (A, B, C) for inception.
  2. Attacker calls registerKeyPairWithTransfer with:
    • ctx.token = address(0) (native token)
    • msg.value = 0
    • One native permit: {token: address(0), amount: BRIDGE_BALANCE, recipients: []}
  3. Processing in _executePermits:
    • hasNativeTransfer = true (native permit exists)
    • Recipient loop: 0 recipients, totalTransferred = 0
    • transferOnly = true (no wrap/unwrap flags)
    • remainder = BRIDGE_BALANCE - 0 = BRIDGE_BALANCE
    • _transferNative(prerotatedKeyHash, BRIDGE_BALANCE), sends entire bridge native tokens balance to attacker's next key
    • totalTransferred = BRIDGE_BALANCE == permit.amount, check passes
  4. Key inception succeeds. Attacker receives all native tokens balance.
03Section · Impact

Impact

  • Complete drain of all native tokens held by the bridge.
  • All users with wrapped native tokens become insolvent, cannot unwrap.
  • Zero-cost attack (gas only) requiring only a fresh key inception.
04Section · Recommendation

Recommendation

Add a cumulative msg.value check for native token operations:

solidity
uint256 nativeTokenUsed = 0;
nativeTokenUsed += recipient.amount;
// ... in the transfer loop for native tokens (line 324):
require(msg.value >= nativeTokenUsed, "Insufficient native tokens");
// ... in the remainder path (line 335):
require(msg.value >= nativeTokenUsed, "Insufficient native tokens");
05Section · Resolution

Resolution

YadaCoin, Confirmed.

Zealynx, Fixed.

Status
Fixed
F-2026-0003

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx