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: 42/243
Findings: 4
Award: $216.86
๐ Selected for report: 1
๐ 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
In Attack Ideas there is:
"Consider ways in which an allowlist address can mint more tokens than what it is allowed to mint."
"Consider ways in which an address during the public phase can mint more tokens compared to what its allowed to mint (maxCollectionPurchases)"
Both limits are easily bypassed by exploiting re-entrancy in minter.mint()
function.
minter.mint()
with _numberOfTokens == maxAllowance
.maxAllowance
check (1, 2 or 3) passes as expected.gencore.mint
is reached here._mintProcessing
is called before tokensMintedAllowlistAddress[_collectionID][_mintingAddress]
or tokensMintedPerAddress[_collectionID][_mintingAddress]
gets incremented._mintProcessing
, _safeMint
is reached. This is an ERC721 function that triggers onERC721Received
callback.onERC721Received
. They call minter.mint
with _numberOfTokens == maxAllowance
again.tokensMintedAllowlistAddress[_collectionID][_mintingAddress]
and tokensMintedPerAddress[_collectionID][_mintingAddress]
haven't been incremented yet, the same checks as before (1, 2 or 3) pass without problem.minter.mint
finishes. Now maxAllowance + 1
tokens have been minted.minter.mint
. It finishes minting without problem. Now 2 * maxAllowance
of tokens have been minted.Full code and exact steps to reproduce the exploit are in this secret gist.
Note, that only the case of bypassing maxCollectionPurchases
in the public phase is included in this PoC.
The attack for the allowlist phase is the same (but more time-consuming to write coded PoC for due to use of Merkle Tree).
Itโs worth noting that limits for the public phase can also be easily bypassed by a sybil attack (simply mint maxCollectionPurchases
of tokens from many addresses and send them all to one, main attacker address).
Foundry, manual analysis
Add ReentrancyGuard to the minter.mint()
function.
Reentrancy
#0 - c4-pre-sort
2023-11-17T08:40:57Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:26Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:24:25Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:24:42Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:09Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-09T00:18:52Z
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/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L105 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L116-L117
Itโs possible to drain all ETH from the AuctionDemo
contract. There are two bugs that make it possible when they're exploited together.
In AuctionDemo
there are two phases of an auction:
minter.getAuctionEndTime(_tokenid)
, when users can call participateToAuction
and cancelBid
functionsminter.getAuctionEndTime(_tokenid)
, when the winner can call claimAuction
However, =<
and >=
inequalities are used in all checks (1, 2, 3). Therefore, if block.timestamp == minter.getAuctionEndTime(_tokenid)
then both phases are active at the same time and the winner of an auction can call all of these functions in the same transaction.
On Ethereum blockchain, one block is produced every 12
seconds. Therefore, for each auction thereโs 1/12
probability that minter.getAuctionEndTime(_tokenid)
will be exactly equal to block.timestamp
for some block (assuming that minter.getAuctionEndTime(_tokenid) % 12
is uniformly random).
In cancelBid
function, auctionInfoData[_tokenid][index].status
is set to false
when a user gets a refund. This is to prevent getting refund on the same bid multiple times.
However, there is no such protection in claimAuction
when refunding all non-winning bids.
These two bugs exploited together allow an attacker to quickly become the winner for an auction when minter.getAuctionEndTime(_tokenid) == block.timestamp
, then place additional bid, and then get refunds on these bids by cancelling them.
Refund on the winning bid causes the winner to get the auctioned NFT for free.
Refund on the additional bid, allows the attacker to drain the contract because they get back twice the deposited amount of ETH (one refund from claimAuction
and one from cancelBid
, both for the same bid).
Attacker can โwinโ an NFT from an auction and steal all ETH deposited for other auctions that are running at the time of the attack.
Condition that enables the attack is that minter.getAuctionEndTime(_tokenid) == block.timestamp
for some block and for some _tokenid
.
Letโs assume that:
minter.getAuctionEndTime(_tokenid) % 12
is a uniformly distributed random variable (all 12
possible values have the same probability 1/12
).12
seconds100
auctions running in AuctionDemo
(only some of them have to overlap in time).Under these assumptions, probability of minter.getAuctionEndTime(_tokenid) == block.timestamp
occurring is 1 - (11/12)^100 ~= 0.99983
. So itโs nearly guaranteed to happen.
Exact steps and full code to reproduce the exploit are in this secret gist.
Here is the test with main logic of the exploit:
function testExploit() public { // get id of of 1st auctioned token uint256 tokenId = gencore.viewTokensIndexMin(collectionId); // check initial state assertEq(gencore.ownerOf(tokenId), nftOwner); assertEq(attacker.balance, 5 ether); assertEq(honestUser.balance, 5 ether); assertEq(address(auction).balance, 0); assertEq(auctionOwner.balance, 0); // required approval so that claimAuction won't revert vm.prank(nftOwner); gencore.approve(address(auction), tokenId); // honest user participates in two auctions vm.startPrank(honestUser); auction.participateToAuction{value: 1 ether}(tokenId); auction.participateToAuction{value: 3.06 ether}(tokenId + 1); vm.stopPrank(); // attacker waits until block.timestamp == auctionEndTime vm.warp(minter.getAuctionEndTime(tokenId)); // exploit vm.startPrank(attacker); // attacker places three bids and becomes the winner of the auction auction.participateToAuction{value: 1.01 ether}(tokenId); auction.participateToAuction{value: 1.02 ether}(tokenId); auction.participateToAuction{value: 1.03 ether}(tokenId); // attacker claims the auctioned NFT // and recieves refunds on their two non-winning bids auction.claimAuction(tokenId); // attacker gets refunds on all three bids auction.cancelAllBids(tokenId); vm.stopPrank(); // check final state assertEq(gencore.ownerOf(tokenId), attacker); assertEq(attacker.balance, 7.03 ether); assertEq(honestUser.balance, 5 ether - 3.06 ether); assertEq(address(auction).balance, 0); assertEq(auctionOwner.balance, 1.03 ether); }
Foundry, manual analysis
Use strict inequality (>
instead of >=
) in claimAuction
to exclude the exact minter.getAuctionEndTime(_tokenid)
from 2nd phase of the auction.
Set auctionInfoData[_tokenid][index].status = false
in claimAuction
before sending refunds (just like in cancelBid
) to protect against double refunds.
Timing
#0 - c4-pre-sort
2023-11-14T11:00:53Z
141345 marked the issue as duplicate of #1370
#1 - c4-pre-sort
2023-11-14T14:21:08Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-01T15:05:50Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-01T15:06:05Z
alex-ppg marked the issue as duplicate of #1788
#4 - c4-judge
2023-12-04T18:54:36Z
alex-ppg marked the issue as selected for report
#5 - alex-ppg
2023-12-04T18:56:28Z
The Warden has illustrated that it is possible to exploit a time-based overlap between the claim of an auction and the cancellation of a bid to siphon funds from the NextGen system.
In contrast to #175 which is based on a similar time overlap albeit in different functions, I deem it to be better suited as a "major" severity issue given that its likelihood is low but its impact is "critical", permitting up to the full auction amount to be stolen thereby tapping into funds of other users.
The Warden's submission was selected as best because it clearly outlines the issue (it is possible to siphon all funds and not just claim the NFT for free), marks the timestamp caveat which limits the vulnerability's exploitability, and provides a short-and-sweet PoC illustrating the problem.
#6 - c4-judge
2023-12-04T18:56:38Z
alex-ppg marked the issue as satisfactory
#7 - alex-ppg
2023-12-04T21:39:37Z
I initially planned to split cancel-after-bid submissions between re-entrancies and back-running attacks, however, both rely on the same core issue and as such will be merged under this exhibit given that back-running has "primacy" over re-entrancies (i.e. fixing a re-entrancy doesn't mean back-running is fixed, however, the opposite is true).
#8 - alex-ppg
2023-12-07T11:52:42Z
Findings relating to the cancellation of a bid and immediate claim at a low price have been grouped under this finding as concerning the same root cause. For more information, consult the relevant response in the original primary exhibit.
#9 - c4-sponsor
2024-01-04T08:58:54Z
a2rocket (sponsor) confirmed
๐ Selected for report: ast3ros
Also found by: 00xSEV, Al-Qa-qa, CaeraDenoir, Jiamin, Juntao, Ruhum, bart1e, circlelooper, crunch, gumgumzum, rishabh, smiling_heretic, ustas
191.4685 USDC - $191.47
The purpose of burnToMint()
function is to allow users to burn an NFT from one collection and receive freshly minted NFT from another collection. Pairs of (_burnCollectionID, _mintCollectionID,)
for which this operation is allowed have to be approved by admin.
In this function, _mintProcessing
is called before _burn
which allows the attacker to do something inside the onERC721Received
callback right before token gets burned.
Moreover, there is no check that would verify the owner of the burned _tokenId
before it gets burned.
It follows that, if attacker transfers the NFT in the callback, then burnToMint
function won't revert. NFT will just get burned after changing its owner.
There are protocols out there that allow users to borrow tokens against NFTs as collateral (e.g. JPEG'd) and AMMs for NFTs (e.g. Caviar). This allows the attacker to profit from this last transfer right before _burn
.
This exploit:
_mintCollectionID
and tokens from selling (or borrowing against) an NFT from _burnCollectionID
.Full code and exact steps how to reproduce the exploit are in this secret gist.
Foundry, manual analysis
Add a check for gencore.ownerOf(tokenId)
before calling _burn
in gencore.burnToMint
.
Call _burn
before _mintProcessing
, not after.
Invalid Validation
#0 - c4-pre-sort
2023-11-17T08:40:47Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T13:37:56Z
141345 marked the issue as not a duplicate
#2 - c4-sponsor
2023-11-27T05:50:43Z
a2rocket (sponsor) confirmed
#3 - c4-judge
2023-12-07T00:46:50Z
alex-ppg marked the issue as duplicate of #1597
#4 - c4-judge
2023-12-08T21:24:00Z
alex-ppg marked the issue as partial-50
#5 - c4-judge
2023-12-08T21:24:40Z
alex-ppg marked the issue as satisfactory
๐ Selected for report: bird-flu
Also found by: 00decree, 0xAadi, AS, Audinarey, DeFiHackLabs, Eigenvectors, Fitro, Hama, Kaysoft, Krace, REKCAH, SovaSlava, The_Kakers, Viktor_Cortess, cartlex_, degensec, devival, evmboi32, funkornaut, jacopod, openwide, peanuts, rotcivegaf, smiling_heretic, xAriextz, xiao
25.2356 USDC - $25.24
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L113-L114 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L18
In Attack Ideas there is:
"Consider ways in which the owner of the token will not receive the funds of the highest bid after an Auction is claimed."
This makes it clear that expected behavior of the AuctionDemo.claimAuction
function is to send highest bid to the owner of the auctioned token.
However, highest bid is sent to the owner of the AuctionDemo
contract instead (AuctionDemo
inherits from Ownable
).
So if the owner of the contract is different from the owner of the token, then highest bid is sent to wrong address.
Full code to and exact steps to reproduce the issue are in this secret gist.
Here is only test that demonstrates the issue (without setup):
function testWrongOwner() public { // get id of of the auctioned token uint256 tokenId = gencore.viewTokensIndexMin(collectionId); // check initial state assertEq(gencore.ownerOf(tokenId), nftOwner); assertEq(auction.owner(), auctionOwner); // Ownabele owner of the contract // and owner of the auction tokenId // are two different addresses assertTrue((auctionOwner != nftOwner)); assertEq(auctionOwner.balance, 0); assertEq(nftOwner.balance, 0); // required approval so that claimAuction won't revert vm.prank(nftOwner); gencore.approve(address(auction), tokenId); // user1 participates in the acution vm.prank(user1); auction.participateToAuction{value: 1 ether}(tokenId); // user2 wins the auction vm.prank(user2); auction.participateToAuction{value: 2 ether}(tokenId); // wait until claimAuction available vm.warp(minter.getAuctionEndTime(tokenId) + 12); // call claimAuction vm.prank(user2); auction.claimAuction(tokenId); // check final state assertEq(gencore.ownerOf(tokenId), user2); assertEq(auction.owner(), auctionOwner); // Verify that ETH was sent to auctionOwner, not nftOwner assertTrue((auctionOwner != nftOwner)); assertEq(auctionOwner.balance, 2 ether); assertEq(nftOwner.balance, 0); }
Foundry, manual analysis
Change owner()
to ownerOfToken
in AuctionDemo.claimAuction
function.
Other
#0 - c4-pre-sort
2023-11-17T08:39:55Z
141345 marked the issue as duplicate of #245
#1 - c4-judge
2023-12-08T22:25:24Z
alex-ppg marked the issue as satisfactory