Bid Beasts

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

[H-1] Critical Vulnerability: WithdrawAllFailedCredits Function Allows Arbitrary User Payout Withdrawal

Root + Impact

Description

  • Allow funds recorded in the contract’s failedTransferCredits mapping (from prior failed payouts) to be safely paid out to the appropriate recipient (or withdrawn by the recipient themself).

  • The function withdrawAllFailedCredits(address _receiver) reads failedTransferCredits[_receiver] but clears and sends funds to msg.sender. That is, the mapping key used for reading (the victim) does not match the key used for clearing (the caller). This is a basic, critical logic bug: the contract will transfer a victim’s accumulated “failed credits” to the caller while leaving the victim’s record intact (or clearing the wrong slot).

Risk

Likelihood:

  • When the contract’s failedTransferCredits mapping contains any non-zero entries and the mapping is publicly readable, an attacker can enumerate those addresses and immediately call withdrawAllFailedCredits(_receiver) to collect the corresponding amount.

  • When an attacker initiates the call from a malicious contract whose receive()/fallback() reenters the vulnerable function, the lack of correct key clearing and absence of nonReentrant protection enables repeated withdrawals within a single transaction until the victim’s credited amount or the contract’s ETH is drained.

Impact:

  • Direct fund theft — An attacker can divert other users’ failedTransferCredits to their own account, potentially automating bulk extraction to drain many users’ balances or the contract’s ETH pool.

  • On-chain accounting inconsistency and operational/legal risk — The mapping may continue to show balances for victims (or enable double payouts), leading to bookkeeping errors, customer claims, regulatory/legal exposure, forensic work, and potential compensation liabilities.

Proof of Concept

Minimal exploit (explain in words + minimal contract):

  1. Find an address victim with failedTransferCredits[victim] > 0 (public getter or scanning on-chain).

  2. Deploy a malicious contract with a fallback() that calls withdrawAllFailedCredits(victim) again (for reentrancy) or simply receives the ETH.

  3. Call withdrawAllFailedCredits(victim) from attacker account / contract. The contract will read victim’s amount and send it to msg.sender (attacker), but not clear failedTransferCredits[victim]. Reentering allows repeating the theft.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
contract ExploitWithdrawAllFailedCreditsTest is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
address victim = address(0xBEEF);
address attacker = address(0xCAFE);
function setUp() public {
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
vm.deal(address(market), 1 ether);
uint256 mappingSlot = 5;
bytes32 slot = keccak256(abi.encode(victim, mappingSlot));
vm.store(address(market), slot, bytes32(uint256(1 ether)));
uint256 credits = market.failedTransferCredits(victim);
assertEq(credits, 1 ether);
}
function testExploitWithdrawAllFailedCredits() public {
vm.deal(attacker, 0);
vm.startPrank(attacker);
market.withdrawAllFailedCredits(victim);
vm.stopPrank();
assertEq(attacker.balance, 1 ether);
assertEq(market.failedTransferCredits(victim), 1 ether);// demonstrates bug
}
}

Recommended Mitigation

  1. Change the API so callers can only withdraw their own credits. Remove the _receiver parameter or restrict usage: provide a no-arg withdrawFailedCredits() that acts on msg.sender.

  2. Add reentrancy protection: use OpenZeppelin ReentrancyGuard and mark withdrawal functions nonReentrant.

  3. Emit events on storing failed payouts and on successful withdrawals for monitoring.

  4. (Optional) If admin withdrawal is required, implement adminWithdrawFailedCredits(address _receiver) with onlyOwner: it must clear failedTransferCredits[_receiver] and send funds to _receiver (not msg.sender), with the same CEI and reentrancy protections.

+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
- 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");
- }
+ /** @notice Allows a user to withdraw their own failed transfer credits */
+ function withdrawFailedCredits() external nonReentrant {
+ uint256 amount = failedTransferCredits[msg.sender];
+ require(amount > 0, "No credits to withdraw");
+
+ failedTransferCredits[msg.sender] = 0;
+
+ (bool success, ) = payable(msg.sender).call{value: amount}("");
+ if (!success) {
+ failedTransferCredits[msg.sender] = amount;
+ revert("Withdraw failed");
+ }
+
+ emit FailedCreditsWithdrawn(msg.sender, amount);
+ }
+
+ /** @notice (Optional) Allows owner/admin to withdraw a user’s failed credits to the user address */
+ function adminWithdrawFailedCredits(address _receiver) external onlyOwner nonReentrant {
+ uint256 amount = failedTransferCredits[_receiver];
+ require(amount > 0, "No credits to withdraw");
+
+ failedTransferCredits[_receiver] = 0;
+
+ (bool success, ) = payable(_receiver).call{value: amount}("");
+ if (!success) {
+ failedTransferCredits[_receiver] = amount;
+ revert("Admin withdraw failed");
+ }
+
+ emit AdminWithdrawFailedCredits(_receiver, amount);
+ }
+
+ event FailedCreditsWithdrawn(address indexed recipient, uint256 amount);
+ event AdminWithdrawFailedCredits(address indexed recipient, uint256 amount);
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!