Bid Beasts

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

Unauthorized Withdrawal of Failed Credits

Root + Impact

Description

  • Normal behavior: withdrawAllFailedCredits should let the credited user pull ETH that failed to transfer to them.

  • Issue: The function in src/BidBeastsNFTMarketPlace.sol:238 pays msg.sender and zeroes the wrong slot, so any address can steal another user’s credits and repeat the theft forever.

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

Risk

Likelihood:

  • Credits appear whenever _payout hits a reverting recipient during routine auctions, so vulnerable balances arise in normal operation.

  • Attackers only need the victim address and a transaction; they can immediately invoke the function and repeat without restriction.

Impact:

  • Attacker seizes victims’ failed credits, causing instant loss of funds.

  • Because the victim’s slot never resets, the attacker can loop withdrawals until the marketplace’s ETH balance is fully drained.

Proof of Concept

  1. Set up contracts and actors: Deploy BidBeasts and BidBeastsNFTMarket, mint token ID 0 to a helper contract failSink whose receive()/fallback() always reverts so it can’t accept ETH.

  2. Create failed credits: failSink lists token 0. Bidder A bids 0.02 ETH and becomes the high bidder. Bidder B overbids with 0.03 ETH; _payout tries to refund Bidder A (the reverting failSink), records the refund in failedTransferCredits[address(failSink)], and leaves the ETH in the marketplace.

  3. Exploit: An unrelated attacker calls withdrawAllFailedCredits(address(failSink)). Funds go to the attacker instead of failSink, and failedTransferCredits[address(failSink)] still holds the same amount. The attacker can repeat the call to drain the marketplace balance.

Example Forge test skeleton:

function testExploitFailedCredits() public {
uint256 tokenId = nft.mint(address(failSink));
vm.prank(address(failSink));
nft.approve(address(market), tokenId);
vm.prank(address(failSink));
market.listNFT(tokenId, 0.01 ether, 0);
vm.deal(bidder1, 0.02 ether);
vm.prank(bidder1);
market.placeBid{value: 0.02 ether}(tokenId);
vm.deal(bidder2, 0.03 ether);
vm.prank(bidder2);
market.placeBid{value: 0.03 ether}(tokenId);
vm.prank(attacker);
market.withdrawAllFailedCredits(address(failSink));
vm.prank(attacker);
market.withdrawAllFailedCredits(address(failSink));
}

Recommended Mitigation

The fix enforces that only the credited address can withdraw, clears the correct storage slot, and keeps the payout destination aligned with the credited user.

function withdrawAllFailedCredits(address _receiver) external {
- uint256 amount = failedTransferCredits[_receiver];
- require(amount > 0, "No credits to withdraw");
-
- failedTransferCredits[msg.sender] = 0;
-
- (bool success, ) = payable(msg.sender).call{value: amount}("");
- require(success, "Withdraw failed");
+ require(msg.sender == _receiver, "Only credited user");
+ uint256 amount = failedTransferCredits[_receiver];
+ require(amount > 0, "No credits to withdraw");
+
+ failedTransferCredits[_receiver] = 0;
+
+ (bool success, ) = payable(_receiver).call{value: amount}("");
+ require(success, "Withdraw failed");
}
Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months 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.

Give us feedback!