Platform: Code4rena
Start Date: 13/12/2023
Pot Size: $36,500 USDC
Total HM: 18
Participants: 110
Period: 8 days
Judge: 0xTheC0der
Id: 311
League: ETH
Rank: 54/110
Findings: 4
Award: $67.06
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: jnforja
Also found by: 0x175, 0xCiphky, 0xDING99YA, 0xmystery, ArmedGoose, Aymen0909, Franklin, KupiaSec, McToady, MrPotatoMagic, Ocean_Sky, PNS, Pechenite, TermoHash, Topmark, _eperezok, alexbabits, deth, hals, imare, jeff, ktg, leegh, mahdirostami, marqymarq10, mojito_auditor, neocrao, ptsanev, twcctop, zraxx
1.337 USDC - $1.34
The AuctionHouse.sol
contract is designed for managing NFT auctions, includes also various features such as auction creation, bidding, settlement, and integration with external tokens. The contract allow several auction parameters such as timeBuffer
, reservePrice
, and minBidIncrementPercentage
to can be changed globally through an active auction. These parameters are not individually cached for each auction, but rather exist as global settings for the contract. This design can lead to inconsistencies and potentially exploitable conditions if these parameters are changed during an ongoing auction.
/** * @notice Set the auction time buffer. * @dev Only callable by the owner. */ function setTimeBuffer(uint256 _timeBuffer) external override onlyOwner { timeBuffer = _timeBuffer; emit AuctionTimeBufferUpdated(_timeBuffer); }
/** * @notice Set the auction reserve price. * @dev Only callable by the owner. */ function setReservePrice(uint256 _reservePrice) external override onlyOwner { reservePrice = _reservePrice; emit AuctionReservePriceUpdated(_reservePrice); }
/** * @notice Set the auction minimum bid increment percentage. * @dev Only callable by the owner. */ function setMinBidIncrementPercentage(uint8 _minBidIncrementPercentage) external override onlyOwner { minBidIncrementPercentage = _minBidIncrementPercentage; emit AuctionMinBidIncrementPercentageUpdated(_minBidIncrementPercentage); }
Global Parameters: In the current implementation, the contract uses single, global state variables for timeBuffer
, reservePrice
, and minBidIncrementPercentage
. These variables apply to the active auction and any future auctions until they are changed.
Lack of Individual Auction Context: Each auction does not have its own set of these parameters. Consequently, if an auction is ongoing and one of these parameters is changed, the new values immediately affect the current auction.
Potential Exploitation: If these parameters are changed while an auction is active. This can lead to scenarios where the rules of the auction are altered in real-time, potentially disadvantaging certain bidders or favoring others. For example, increasing the minBidIncrementPercentage
could unexpectedly raise the minimum additional bid amount required, while decreasing the timeBuffer
could shorten the auction duration unexpectedly.
Consider the following scenario:
reservePrice
for the AuctionHouse.sol
contract is 1 ether
.amount = 3e18
via createBid()
function.reservePrice
is changed to 5 ethers
. Subsequently the end time for the current auction is over._settleAuction()
function the NFT is burned and is not get the NFT.function _settleAuction() internal { IAuctionHouse.Auction memory _auction = auction; // ...code ... uint256 creatorTokensEmitted = 0; // Check if contract balance is greater than reserve price if (address(this).balance < reservePrice) { // If contract balance is less than reserve price, refund to the last bidder if (_auction.bidder != address(0)) { _safeTransferETHWithFallback(_auction.bidder, _auction.amount); } // And then burn the Noun verbs.burn(_auction.verbId); } else { // ... code ... } // ...code... }
Manual review
Implement a mechanism where each auction caches its own set of parameters at the time of creation. This ensures that the rules of an auction remain consistent throughout its duration.
Another possible solution is to add a whenPaused
modifier in the setter functions of auction parameters so that the auction parameters cannot be changed while there is an active auction.
Context
#0 - c4-pre-sort
2023-12-23T01:13:50Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-23T01:16:36Z
raymondfam marked the issue as duplicate of #55
#2 - c4-pre-sort
2023-12-24T14:18:10Z
raymondfam marked the issue as duplicate of #495
#3 - c4-judge
2024-01-06T18:14:50Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-01-07T16:03:57Z
MarioPoneder marked the issue as grade-c
#5 - c4-judge
2024-01-10T17:32:52Z
This previously downgraded issue has been upgraded by MarioPoneder
#6 - c4-judge
2024-01-10T17:37:00Z
MarioPoneder marked the issue as partial-50
#7 - c4-judge
2024-01-10T17:40:51Z
MarioPoneder marked the issue as duplicate of #515
#8 - c4-judge
2024-01-11T18:03:12Z
MarioPoneder changed the severity to 2 (Med Risk)
🌟 Selected for report: deth
Also found by: 00xSEV, 0xCiphky, 0xHelium, ABAIKUNANBAEV, Aamir, AkshaySrivastav, King_, Pechenite, SpicyMeatball, Tricko, ast3ros, ayden, bart1e, deth, fnanni, ke1caM, mahdirostami, peanuts, pep7siup, pontifex, ptsanev, roland, rvierdiiev, y4y
7.2215 USDC - $7.22
When new pieces are created in CultureIndex.sol
, they are assigned a required quorumVotes
threshold, which must be reached in order for them to be dropped and potentially moved to an auction. The dropper in CultureIndex.sol
can drop only the top voted piece, and only if this piece has reached its required quorum votes.
The dropTopVotedPiece()
function in CultureIndex.sol is as follows:
function dropTopVotedPiece() public nonReentrant returns (ArtPiece memory) { require(msg.sender == dropperAdmin, "Only dropper can drop pieces"); ICultureIndex.ArtPiece memory piece = getTopVotedPiece(); require(totalVoteWeights[piece.pieceId] >= piece.quorumVotes, "Does not meet quorum votes to be dropped."); // Set the piece as dropped pieces[piece.pieceId].isDropped = true; // Extract the piece from the heap maxHeap.extractMax(); emit PieceDropped(piece.pieceId, msg.sender); return pieces[piece.pieceId]; }
Currently, this logic could significantly impact the functionality and fairness of the system. Specifically, it can lead to scenarios where art pieces that have met their quorum requirements for votes are unable to progress to the dropping phase (which could mean moving to an auction). This issue arises from the dynamic nature of the quorumVotesBPS
variable, which affects new pieces but does not retroactively adjust the quorum for existing pieces. Consequently, a newer piece with a higher total vote weight but an unmet quorum can prevent older pieces from being dropped, even if they have met their quorum. This flaw can lead to:
Consider the following scenario:
Current State:
quorumVotesBPS = 3,000
erc20VotingToken.totalSupply() = 3,000
erc721VotingToken.totalSupply() = 0
quorumVotes
for this piece is calculated as 900 ((3000 * 3000) / 10,000).quorumVotesBPS
is then changed to 6,000.quorumVotesBPS
of 6,000, setting the quorumVotes
for this piece at 1,800 ((6000 * 3000) / 10,000).totalVoteWeight
for the first piece becomes 1,000 and for the second piece becomes 1,500. Thus, the first piece has reached its quorum, but the second piece has not yet.totalVotesWeight
.Manual review
A possible solution is to refactor the method by which the 'top voted piece' is selected in the getTopVotedPiece()
function. Instead of selecting the piece with the highest overall votes, the function should choose the top voted piece among those that have already reached their required quorumVotes
. This adjustment would also eliminate the need for the check require(totalVoteWeights[piece.pieceId] >= piece.quorumVotes, "Does not meet quorum votes to be dropped.");
in the dropTopVotedPiece()
function.
Context
#0 - c4-pre-sort
2023-12-23T00:51:12Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-23T00:51:19Z
raymondfam marked the issue as duplicate of #16
#2 - c4-pre-sort
2023-12-24T15:11:19Z
raymondfam marked the issue as duplicate of #449
#3 - c4-judge
2024-01-06T15:56:35Z
MarioPoneder changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-01-06T16:03:02Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: ast3ros
Also found by: 0xG0P1, KupiaSec, Pechenite, cccz, deth, dimulski, mojito_auditor, osmanozdemir1, peanuts, rvierdiiev, zhaojie
51.1381 USDC - $51.14
https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L209-L248 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/CultureIndex.sol#L307-L324
The CultureIndex.sol
contract logic concerning voting involves two key components: erc20VotingToken
and erc721VotingToken
. Votes are cast using these tokens, and their total supply
at the time of piece creation is critical in determining totalVotesSupply
and quorumVotes
for each piece. The issue arises when these tokens are minted after a piece's creation but within the same block. As the contract uses the current block's timestamp to calculate the vote weight
and quorum
, any tokens minted in the same block.timestamp
unexpectedly alter these calculations. This leads to a scenario where totalVotesSupply
and quorumVotes
become "outdated" or incorrect.
createPiece()
functionfunction createPiece( ArtPieceMetadata calldata metadata, CreatorBps[] calldata creatorArray ) public returns (uint256) { uint256 creatorArrayLength = validateCreatorsArray(creatorArray); // Validate the media type and associated data validateMediaType(metadata); uint256 pieceId = _currentPieceId++; /// @dev Insert the new piece into the max heap maxHeap.insert(pieceId, 0); ArtPiece storage newPiece = pieces[pieceId]; newPiece.pieceId = pieceId; newPiece.totalVotesSupply = _calculateVoteWeight( erc20VotingToken.totalSupply(), erc721VotingToken.totalSupply() ); newPiece.totalERC20Supply = erc20VotingToken.totalSupply(); newPiece.metadata = metadata; newPiece.sponsor = msg.sender; newPiece.creationBlock = block.number; newPiece.quorumVotes = (quorumVotesBPS * newPiece.totalVotesSupply) / 10_000; for (uint i; i < creatorArrayLength; i++) { newPiece.creators.push(creatorArray[i]); } emit PieceCreated(pieceId, msg.sender, metadata, newPiece.quorumVotes, newPiece.totalVotesSupply); // Emit an event for each creator for (uint i; i < creatorArrayLength; i++) { emit PieceCreatorAdded(pieceId, creatorArray[i].creator, msg.sender, creatorArray[i].bps); } return newPiece.pieceId; }
_vote()
functionfunction _vote(uint256 pieceId, address voter) internal { require(pieceId < _currentPieceId, "Invalid piece ID"); require(voter != address(0), "Invalid voter address"); require(!pieces[pieceId].isDropped, "Piece has already been dropped"); require(!(votes[pieceId][voter].voterAddress != address(0)), "Already voted"); uint256 weight = _getPastVotes(voter, pieces[pieceId].creationBlock); require(weight > minVoteWeight, "Weight must be greater than minVoteWeight"); votes[pieceId][voter] = Vote(voter, weight); totalVoteWeights[pieceId] += weight; uint256 totalWeight = totalVoteWeights[pieceId]; // TODO add security consideration here based on block created to prevent flash attacks on drops? maxHeap.updateValue(pieceId, totalWeight); emit VoteCast(pieceId, voter, weight, totalWeight); }
This vulnerability impacts the integrity of the voting mechanism in the CultureIndex.sol
contract. The ability for newly minted ERC20 and ERC721 tokens to immediately influence the total vote weight
of a piece within the same block undermines the accuracy and reliability of voting outcomes.
erc20VotingToken
and erc721VotingToken
.block.timestamp
, additional tokens of erc20VotingToken
and erc721VotingToken
are minted.totalVoteWeights
, even though they were not part of the original calculation for totalVotesSupply
and quorumVotes
.Manual Inspection
Implement a mechanism to lock in the total supply of voting tokens at the time of piece creation for the entire block. This ensures that tokens minted after the piece's creation within the same block do not affect the vote weight.
Context
#0 - c4-pre-sort
2023-12-23T03:11:14Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-23T03:11:35Z
raymondfam marked the issue as duplicate of #260
#2 - c4-pre-sort
2023-12-24T05:40:07Z
raymondfam marked the issue as duplicate of #409
#3 - c4-judge
2024-01-05T22:41:23Z
MarioPoneder marked the issue as satisfactory
🌟 Selected for report: 0xmystery
Also found by: 00xSEV, 0xCiphky, 0xDING99YA, 0xhitman, ABAIKUNANBAEV, Aamir, BARW, IllIllI, King_, MrPotatoMagic, Pechenite, SovaSlava, SpicyMeatball, Topmark, Ward, ZanyBonzy, ast3ros, bart1e, cheatc0d3, deth, developerjordy, hals, imare, kaveyjoe, ktg, leegh, osmanozdemir1, peanuts, roland, rvierdiiev, shaka, sivanesh_808, spacelord47
7.359 USDC - $7.36
The auction owner does not and the NFT creator(s) benefit from the auction at all. A malicious user obtains NFT at a very low price and grieves other users.
When top piece
is auctioned off every day as an ERC721 VerbsToken
via the AuctionHouse.sol
contract, the owner of AuctionHouse.sol
contract can call unpause()
function to create an auction via _createAuction()
function.
function unpause() external override onlyOwner { _unpause(); if (auction.startTime == 0 || auction.settled) { _createAuction(); } } function _createAuction() internal { // Check if there's enough gas to safely execute token.mint() and subsequent operations require(gasleft() >= MIN_TOKEN_MINT_GAS_THRESHOLD, "Insufficient gas for creating auction"); try verbs.mint() returns (uint256 verbId) { uint256 startTime = block.timestamp; uint256 endTime = startTime + duration; auction = Auction({ verbId: verbId, amount: 0, startTime: startTime, endTime: endTime, bidder: payable(0), settled: false }); emit AuctionCreated(verbId, startTime, endTime); } catch { _pause(); } }
After that everyone the want the certain NFT form the auction can call createBid()
function to create a bid for a Verb, with a given amount
function createBid(uint256 verbId, address bidder) external payable override nonReentrant { IAuctionHouse.Auction memory _auction = auction; //require bidder is valid address require(bidder != address(0), "Bidder cannot be zero address"); require(_auction.verbId == verbId, "Verb not up for auction"); //slither-disable-next-line timestamp require(block.timestamp < _auction.endTime, "Auction expired"); require(msg.value >= reservePrice, "Must send at least reservePrice"); require( msg.value >= _auction.amount + ((_auction.amount * minBidIncrementPercentage) / 100), "Must send more than last bid by minBidIncrementPercentage amount" ); address payable lastBidder = _auction.bidder; auction.amount = msg.value; auction.bidder = payable(bidder); // Extend the auction if the bid was received within `timeBuffer` of the auction end time bool extended = _auction.endTime - block.timestamp < timeBuffer; if (extended) auction.endTime = _auction.endTime = block.timestamp + timeBuffer; // Refund the last bidder, if applicable if (lastBidder != address(0)) _safeTransferETHWithFallback(lastBidder, _auction.amount); emit AuctionBid(_auction.verbId, bidder, msg.sender, msg.value, extended); if (extended) emit AuctionExtended(_auction.verbId, _auction.endTime); }
Every auction has an endTime
set in _createAuction()
function. Now, there is a serious problem, because stuffing whole duration
of the auction to auction#endTime
with dummy transactions is very cheap on Optimism L2 According to https://www.cryptoneur.xyz/en/gas-fees-calculator 50M gas (which is several whole blocks) - costs ~11.1192 USD$ on Optimism L2. This makes a malicious user occasion to cheaply prohibit other users to overbid them, winning the auction at the least favorable price for the protocol.
The createBid()
function actually try to prevent the block stuffing with the implementation of minBidIncrementPercentage
and timeBuffer
but this actually doesn't safe the current auction, because as it writes in the following article:
mining the block takes significantly more time than executing the transactions (https://medium.com/hackernoon/the-anatomy-of-a-block-stuffing-attack-a488698732ae)
Manual Review
There are several possible solutions:
reservePrice
is enough high in the beginning of the auction.Context
#0 - c4-pre-sort
2023-12-23T04:03:31Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-12-23T04:04:05Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2023-12-23T04:04:13Z
QA low.
#3 - c4-judge
2024-01-06T19:12:48Z
MarioPoneder changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-01-07T15:49:56Z
MarioPoneder marked the issue as grade-b