[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:
- 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.
- 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.
[Bug Bounty]
finishWithdrawDisprovedpays 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:Two consequences, both proven with Foundry tests against this repo's constants:
BitvmTxParser._parseOpReturnAddress) — no on-chain identity binding._safeTransferrevertsERC20TransferFailedoncebalanceOf(gateway) < minChallengerReward, so every futurefinishWithdrawDisprovedcall — for any graph, including legitimate fraud responses — reverts until someone tops up the contract.Affected component
src/Gateway.sol:629-658(finishWithdrawDisproved, reward block)StakeManagement.slashStake(caps at operator balance) vs fixed rewards.Proof of concept
test/DisproveRewardDrainPoC.t.solreplicates the exact reward arithmetic over a minimal ERC20 + the shipped initialization values (minChallengerReward=0.0125, minDisproverReward=0.0025, minSlashAmount=0.03 frominitialize). Two tests, both passing on forge 1.7.1 / solc 0.8.28: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:finishWithdrawDisprovedcalls 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);Frequency is mitigated by
onlyCommitteegating 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:
and/or track a dedicated
rewardPoolmapping 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.