Skip to content

[Security] finishWithdrawDisproved pays fixed rewards uncapped by actual slash; empty pool reverts entire disprove flow (DoS) #438

Description

@cilokesteh

[Bug Bounty] finishWithdrawDisproved pays fixed rewards uncapped by actual slash; empty reward pool reverts the entire disprove flow (DoS)

Summary

In Gateway.finishWithdrawDisproved (src/Gateway.sol:629-644), the operator slash amount is capped by the operator's actual stake, but the challenger/disprover rewards are fixed and paid regardless of how much was actually slashed:

uint256 slashAmount = minSlashAmount;
uint256 operatorStake = stakeManagement.stakeOf(operatorStakeAddress);
if (operatorStake < slashAmount) slashAmount = operatorStake;   // can be 0
stakeManagement.slashStake(operatorStakeAddress, slashAmount);   // slashes 0

uint256 challengerRewardAmount = minChallengerReward;            // 0.0125 PBTC, fixed
uint256 disproverRewardAmount  = minDisproverReward;             // 0.0025 PBTC, fixed
if (challengerAddress != address(0)) {
    _safeTransfer(stakeToken, challengerAddress, challengerRewardAmount); // paid anyway
}

Two consequences, both proven with Foundry tests against this repo's constants:

  1. Insurance pool drained by zero-slash events. Each disprove on a stake-exhausted operator still pays 0.015 PBTC out of the Gateway's shared stakeToken balance, transferring insurance funds to an address taken from an attacker-chosen OP_RETURN in the Bitcoin transaction (BitvmTxParser._parseOpReturnAddress) — no on-chain identity binding.
  2. Whole disprove flow bricks when the pool runs low. _safeTransfer reverts ERC20TransferFailed once balanceOf(gateway) < minChallengerReward, so every future finishWithdrawDisproved call — for any graph, including legitimate fraud responses — reverts until someone tops up the contract.

Affected component

  • src/Gateway.sol:629-658 (finishWithdrawDisproved, reward block)
  • Interaction surface: StakeManagement.slashStake (caps at operator balance) vs fixed rewards.

Proof of concept

test/DisproveRewardDrainPoC.t.sol replicates the exact reward arithmetic over a minimal ERC20 + the shipped initialization values (minChallengerReward=0.0125, minDisproverReward=0.0025, minSlashAmount=0.03 from initialize). Two tests, both passing on forge 1.7.1 / solc 0.8.28:

$ forge test --match-contract DisproveRewardDrainPoC -vv
[PASS] test_rewardPaidEvenWhenSlashIsZero()        # slash = 0, challenger paid 0.0125 from pool
[PASS] test_repeatedZeroSlashEvents_drainPool()    # after 2 zero-slash events pool < 1 payout;
                                                   # 3rd payout reverts -> flow bricked
Suite result: ok. 2 passed; 0 failed

Exploit scenario

Operators that have already been slashed to zero (or staked below minSlashAmount) remain valid disprove targets as long as graphs exist. Repeated disproves against such operators each extract the fixed reward from the shared pool while contributing nothing to it. Once the pool is exhausted:

  • further finishWithdrawDisproved calls revert for all graphs — legitimate fraud responses become impossible until the pool is manually refunded (a hidden operational dependency that nothing in the code enforces or monitors);
  • historically accumulated slash funds (meant as user insurance) have been paid out to OP_RETURN-designated addresses.

Frequency is mitigated by onlyCommittee gating plus the requirement of a real Merkle-proven Bitcoin disprove transaction; impact per event is small but the accounting invariant "rewards ≤ actual slash" is plainly violated, and the availability consequence is protocol-wide.

Severity: Low-Medium (accounting integrity + conditional DoS of the dispute path).

Recommended fix

Cap total rewards by the amount actually slashed in the same call, e.g. escrow the slashed stake first and pay rewards out of it:

uint256 payoutPool = slashAmount;                       // what was really taken
uint256 rewards = challengerRewardAmount + disproverRewardAmount;
require(rewards <= payoutPool, "rewards exceed slash"); // or scale rewards down proportionally

and/or track a dedicated rewardPool mapping funded only by slashes instead of spending the contract's raw token balance.


Submitted as part of the GOAT BitVM3 Bug Bounty Campaign. Happy to provide the full PoC files or additional traces.

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