Platform: Code4rena
Start Date: 16/12/2022
Pot Size: $32,070 USDC
Total HM: 18
Participants: 5
Period: 3 days
Judge: hickuphh3
Total Solo HM: 5
Id: 195
League: ETH
Rank: 5/5
Findings: 13
Award: $0.00
🌟 Selected for report: 7
🚀 Solo Findings: 2
Data not available
claim() function is used in GroupBuy to mint Raes proportional to user's contribution to the purchased NFT. withdrawBalance() is used to get back funds which are not part of the contribution.
They both contain an unsafe call with ETH. For example:
function withdrawBalance() public { // Reverts if caller balance is insufficient uint256 balance = pendingBalances[msg.sender]; if (balance == 0) revert InsufficientBalance(); // Resets pending balance amount delete pendingBalances[msg.sender]; // Transfers pending ether balance to caller payable(msg.sender).call{value: balance}(""); }
If msg.sender is a contract that does not implement a fallback function, the pending balance would be permanently frozen in GroupBuy contract. There are no warnings or validations that user is an EOA, so the call return value must be checked. It makes sense for contracts to be built around GroupBuy so it is a necessary validation.
When user of GroupBuy is a contract, refunds will be permanently frozen.
Manual audit
2 options:
#0 - c4-judge
2022-12-20T01:17:34Z
HickupHH3 marked the issue as duplicate of #6
#1 - c4-judge
2022-12-20T01:17:43Z
HickupHH3 marked the issue as satisfactory
#2 - c4-judge
2022-12-20T01:17:48Z
HickupHH3 changed the severity to 3 (High Risk)
Data not available
In OptimisticListingSeaport.sol, users call propose() to set a new proposal. The pendingBalance is updated immediately for the calling proposer:
// Sets collateral amount to pending balances for withdrawal pendingBalances[_vault][proposedListing.proposer] += proposedListing.collateral;
This is the amount transferred from user to contract:
// Transfers new collateral amount from caller to this contract IERC1155(token).safeTransferFrom(msg.sender, address(this), id, _collateral, "");
However, any value in pendingBalances is instantly claimable. Using withdrawCollateral, user will simply take back their collateral. There is no additional bookkeeping, like done in rejectActive and rejectProposal flow, which cancel the proposal when there is no collateral defending it.
function withdrawCollateral(address _vault, address _to) external { // Reverts if vault is not registered (address token, uint256 id) = _verifyVault(_vault); // Reverts if token balance is insufficient uint256 balance = pendingBalances[_vault][_to]; if (balance == 0) revert NotEnoughTokens(); // Resets collateral balance amount // ----- simply transfer out balance here ----- pendingBalances[_vault][_to] = 0; // Transfers collateral amount to receiver IERC1155(token).safeTransferFrom(address(this), _to, id, balance, ""); // Emits event for withdrawing collateral balance emit WithdrawCollateral(_vault, _to, balance); }
Reject proposal logic:
// Decrements collateral amount proposedListing.collateral -= _amount; // Checks if proposed listing has been rejected if (proposedListing.collateral == 0) { // Resets proposed listing to default _setListing(proposedListing, address(this), 0, type(uint256).max, 0); }
User can send a proposal and instantly take back their collateral, keeping the proposal active without risking any Raes amount.
Manual audit
It is best not to add pendingBalance for proposer in propose() function. Use the rejectProposal, rejectActive and cash functions to update pendingBalance when we know how much of user's collateral should be given back to them.
#0 - c4-judge
2022-12-20T08:02:14Z
HickupHH3 marked the issue as satisfactory
#1 - c4-judge
2022-12-20T08:02:19Z
HickupHH3 marked the issue as primary issue
#2 - c4-sponsor
2023-01-04T20:05:29Z
stevennevins marked the issue as sponsor confirmed
#3 - c4-sponsor
2023-01-09T21:06:55Z
mehtaculous marked the issue as sponsor disputed
#4 - mehtaculous
2023-01-09T21:09:37Z
Duplicate of #12 but mitigation steps are not correct. Pending balance of previous proposer needs to be updated rather than balance of the new proposer.
#5 - trust1995
2023-01-09T23:08:39Z
This is exactly what was intended, perhaps the grammar wasn't perfect but I thought it was clear that was I meant.
#6 - HickupHH3
2023-01-11T14:01:15Z
It's not clear to me that the mitigation is the same as #12, but the described vulnerability is the same.
#7 - c4-judge
2023-01-11T14:01:26Z
HickupHH3 marked the issue as duplicate of #12
#8 - c4-judge
2023-01-11T14:01:43Z
HickupHH3 changed the severity to 3 (High Risk)
Data not available
OptimisticListingSeaport.sol is easily DOSable due to the following conditions:
Therefore, an attacker can continually reset an honest proposal and never let it reach maturity and be activated.
Proposal can be infinitely DOSed with no additional conditions
Manual audit
The standard practice in this type of scenario is allow multiple proposals to run simultaneously. Once enough time has passed the proposal will be promoted to activeProposal and can be executed.
#0 - c4-sponsor
2023-01-03T19:32:49Z
stevennevins requested judge review
#1 - stevennevins
2023-01-03T19:35:19Z
This seems like a dup for #25
#2 - c4-judge
2023-01-11T15:11:39Z
HickupHH3 marked the issue as duplicate of #25
#3 - c4-judge
2023-01-11T15:11:45Z
HickupHH3 marked the issue as satisfactory
Data not available
OptimisticListingSeaport exposes propose() method to create new proposal, and rejectProposal to remove a listing in proposal stage.
In propose, proposer commits a certain amount of collateral:
// Sets collateral amount to pending balances for withdrawal pendingBalances[_vault][proposedListing.proposer] += proposedListing.collateral; // Transfers new collateral amount from caller to this contract IERC1155(token).safeTransferFrom(msg.sender, address(this), id, _collateral, "");
In rejectProposal, user pays proposer msg.value which is the ETH equivalent of amount stored in collateral. In return they get the Raes collateral.
// Transfers tokens to caller IERC1155(token).safeTransferFrom(address(this), msg.sender, id, _amount, ""); // Sends ether to proposer _sendEthOrWeth(proposer, msg.value); // Emits event for rejecting a proposed listing emit RejectProposal(_vault, msg.sender, _amount, msg.value, proposedListing.order);
The issue is that these two actions are zero sum and can be executed without time constraints. Therefore, they can be used by a single party to infinitely DOS any proposal and not let any proposal become active. Effectively it is an NFT freeze of funds.
Any user which holds Raes tokens can infinitely freeze NFT in OptimisticListingSeaport
collateral
is not lower-bounded.collateral
deposited earlier. They are net zero.Manual audit
Add some penalty for rejected proposals which will make the attack not worthwhile. Additionally, make the collateral be a significant amount to limit the number of potential attackers.
#0 - c4-judge
2022-12-23T00:33:07Z
HickupHH3 marked the issue as duplicate of #25
#1 - c4-judge
2022-12-23T00:33:25Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: Trust
Data not available
_constructOrder is called in propose(), OptimisticListingSeaport.sol. It fills the order params stored in proposedListings[_vault].
{ orderParams.offerer = _vault; orderParams.startTime = block.timestamp; // order doesn't expire in human time scales and needs explicit cancellations orderParams.endTime = type(uint256).max; orderParams.zone = zone; // 0: no partial fills, anyone can execute orderParams.orderType = OrderType.FULL_OPEN; orderParams.conduitKey = conduitKey; // 1 Consideration for the listing itself + 1 consideration for the fees orderParams.totalOriginalConsiderationItems = 3; }
Importantly, it updates the order hash associated with the vault:
vaultOrderHash[_vault] = _getOrderHash(orderParams, counter);
There is only one other use of vaultOrderHash
, in _verifySale().
function _verifySale(address _vault) internal view returns (bool status) { (bool isValidated, bool isCancelled, uint256 totalFilled, uint256 totalSize) = ISeaport( seaport ).getOrderStatus(vaultOrderHash[_vault]); if (isValidated && !isCancelled && totalFilled > 0 && totalFilled == totalSize) { status = true; } }
This function gets order information from the order hash, and makes sure the order is completely fulfilled.
After NFT sell has completed, cash() is used to distribute income ETH:
function cash(address _vault, bytes32[] calldata _burnProof) external { // Reverts if vault is not registered (address token, uint256 id) = _verifyVault(_vault); // Reverts if active listing has not been settled Listing storage activeListing = activeListings[_vault]; // Reverts if listing has not been sold // -------------- _verifySale MUST BE TRUE --------- if (!_verifySale(_vault)) { revert NotSold(); } else if (activeListing.collateral != 0) { uint256 collateral = activeListing.collateral; activeListing.collateral = 0; // Sets collateral amount to pending balances for withdrawal pendingBalances[_vault][activeListing.proposer] = collateral; }
As long as sale is not complete, cash() can't be called as highlighted. The issue is that vaultOrderHash[_vault]
is not protected during the lifetime of an active proposal. If another proposal is proposed and then the sell using active proposal takes place, cash() will keep reverting. Funds are stuck in listing contract.
We can try to be clever and call propose() again with the same parameters to create an identical orderID, which will make vaultOrderHash[_vault]
fine again and allow cash() to go through. But order params contain block.timestamp which will certainly be different which will make the hash different.
Funds are permanently stuck in OptimisticListingSeaport.sol contract if active proposal is executed after new proposal is pending.
Manual audit
Keep the order hash in the Listing structure rather than a single one per vault.
#0 - c4-judge
2022-12-20T07:20:03Z
HickupHH3 marked the issue as satisfactory
#1 - c4-judge
2022-12-20T07:20:07Z
HickupHH3 marked the issue as primary issue
#2 - mehtaculous
2023-01-03T19:22:20Z
Agree with High severity. Solution is to move orderHash
to Listing struct so that active and proposed listings can have separate order hashes
#3 - c4-sponsor
2023-01-03T19:22:25Z
mehtaculous marked the issue as sponsor confirmed
#4 - c4-judge
2023-01-11T15:15:13Z
HickupHH3 marked the issue as selected for report
#5 - stevennevins
2023-01-26T00:40:17Z
Data not available
https://github.com/code-423n4/2022-12-tessera/blob/f37a11407da2af844bbfe868e1422e3665a5f8e4/src/seaport/modules/OptimisticListingSeaport.sol#L295 https://github.com/code-423n4/2022-12-tessera/blob/f37a11407da2af844bbfe868e1422e3665a5f8e4/src/seaport/modules/OptimisticListingSeaport.sol#L232
In OptimisticListingOpensea, there are several functions which update pendingBalances of a proposer:
Unfortunately, in list() and cash() the = operator is used instead of += when writing the new pendingBalances. For example:
function cash(address _vault, bytes32[] calldata _burnProof) external { // Reverts if vault is not registered (address token, uint256 id) = _verifyVault(_vault); // Reverts if active listing has not been settled Listing storage activeListing = activeListings[_vault]; // Reverts if listing has not been sold if (!_verifySale(_vault)) { revert NotSold(); } else if (activeListing.collateral != 0) { uint256 collateral = activeListing.collateral; activeListing.collateral = 0; // Sets collateral amount to pending balances for withdrawal pendingBalances[_vault][activeListing.proposer] = collateral; } ...
pendingBalances is not guaranteed to be zero. There could be funds from previous proposals which are not yet collected. Propose updates pendingBalance correctly:
// Sets collateral amount to pending balances for withdrawal pendingBalances[_vault][proposedListing.proposer] += proposedListing.collateral;
So, when propose is followed by another propose(), the pendingBalance is updated correctly, but in cash and list we don't account for pre-existing balance. This issue would manifest even after the fix suggested in the issue "User can send a proposal and instantly take back their collateral" because reject functions would increment the pendingBalance and then it would be overriden.
User loses collateral converted to pendingBalance when cash() or list() is called
Manual audit
Change the = operator to += in list() and cash().
#0 - c4-judge
2022-12-20T07:32:12Z
HickupHH3 marked the issue as primary issue
#1 - c4-judge
2022-12-20T07:35:20Z
HickupHH3 marked the issue as selected for report
#2 - c4-sponsor
2023-01-03T19:08:05Z
mehtaculous marked the issue as sponsor confirmed
#3 - mehtaculous
2023-01-03T19:08:28Z
Agree with High severity. Solution is to replace =
with +=
for both list
and cash
functions
#4 - stevennevins
2023-01-26T00:41:33Z
Data not available
purchase() in GroupBuy.sol executes the purchase call for the group. After safety checks, the NFT is bought with _market's execute() function. Supposedly it deploys a vault which owns the NFT. The code makes sure the vault is the new owner of the NFT and exits.
// Executes purchase order transaction through market buyer contract and deploys new vault address vault = IMarketBuyer(_market).execute{value: _price}(_purchaseOrder); // Checks if NFT contract supports ERC165 and interface ID of ERC721 tokens if (ERC165Checker.supportsInterface(_nftContract, _INTERFACE_ID_ERC721)) { // Verifes vault is owner of ERC-721 token if (IERC721(_nftContract).ownerOf(_tokenId) != vault) revert UnsuccessfulPurchase(); } else { // Verifies vault is owner of CryptoPunk token if (ICryptoPunk(_nftContract).punkIndexToAddress(_tokenId) != vault) revert UnsuccessfulPurchase(); } // Stores mapping value of poolId to newly deployed vault poolToVault[_poolId] = vault; // Sets pool state to successful poolInfo[_poolId].success = true; // Emits event for purchasing NFT at given price emit Purchase(_poolId, vault, _nftContract, _tokenId, _price);
The issue is that _market user-supplied variable is not validated at all. Attacker can pass their malicious contract, which uses the passed funds to buy the NFT and store it in attacker's wallet. It will return the NFT-holding wallet so the checks will pass. As a result, attacker has the NFT while they could have contributed nothing to the GroupBuy. Attacker can also just steal the supplied ETH and return the current address which holds the NFT.
Attacker can steal the amount collected so far in the GroupBuy for NFT purchase.
minReservePrices[_poolId] * filledQuantities[_poolId]
, as checked in line 182.Manual audit
_market should be whitelisted, or supplied in createPool stage and able to be scrutinized.
#0 - c4-judge
2022-12-20T00:32:12Z
HickupHH3 marked the issue as primary issue
#1 - HickupHH3
2022-12-20T00:57:20Z
While #39 has better formatting, I found this report's POC and description to be more succinct.
#2 - c4-judge
2022-12-20T00:57:25Z
HickupHH3 marked the issue as selected for report
#3 - c4-sponsor
2023-01-03T19:02:07Z
mehtaculous marked the issue as sponsor confirmed
#4 - mehtaculous
2023-01-03T19:03:23Z
Agree with High severity. Solution is to check that the vault
deployed from the MarketBuyer is actually registered through the VaultRegistry
. This would confirm that the vault is not a user address
#5 - stevennevins
2023-01-26T00:42:03Z
Data not available
purchase() in GroupBuy faciilitates the purchasing of an NFT after enough contributions were gathered. Another report titled "Attacker can steal the amount collected so far in the GroupBuy for NFT purchase" describes a high impact bug in purchase. It is advised to read that first for context.
Additionally, purchase() is vulnerable to a re-entrancy exploit which can be chained or not chained to the _market issue to steal the entire ETH stored in GroupBuy, rather than being capped to minReservePrices[_poolId] * filledQuantities[_poolId]
.
Attacker may take control of execution using this call:
// Executes purchase order transaction through market buyer contract and deploys new vault address vault = IMarketBuyer(_market).execute{value: _price}(_purchaseOrder);
It could occur either by exploiting the unvalidated _market vulnerability , or by abusing an existing market that uses a user address in _purchaseOrder.
There is no re-entrancy protection in purchase() call:
function purchase( uint256 _poolId, address _market, address _nftContract, uint256 _tokenId, uint256 _price, bytes memory _purchaseOrder, bytes32[] memory _purchaseProof ) external {
_verifyUnsuccessfulState() needs to not revert for purchase call. It checks the pool.success flag:
if (pool.success || block.timestamp > pool.terminationPeriod) revert InvalidState();
However, success is only set as the last thing in purchase():
// Stores mapping value of poolId to newly deployed vault poolToVault[_poolId] = vault; // Sets pool state to successful poolInfo[_poolId].success = true; // Emits event for purchasing NFT at given price emit Purchase(_poolId, vault, _nftContract, _tokenId, _price); }
Therefore, attacker can re-enter purchase() function multiple times, each time extracting the maximum allowed price. If attacker uses the controlled _market exploit, the function will return the current NFT owner, so when all the functions unwind they will keep setting success to true and exit nicely.
GroupBuy can be drained of all ETH.
minReservePrices[_poolId] * filledQuantities[_poolId]
) is 50 * 20 = 1000 ETHManual audit
Add a re-entrancy guard to purchase() function. Also, change success variable before performing external contract calls.
#0 - HickupHH3
2022-12-20T00:43:35Z
Potentially dup of #47 because it uses the same attack vector of a malicious _market
. I'd be a little more inclined to mark this as a standalone issue if the sponsor can verify the other attack path:
or by abusing an existing market that uses a user address in _purchaseOrder.
Agree with recommendations in adding re-entrancy + CEI pattern.
#1 - c4-judge
2022-12-20T00:43:44Z
HickupHH3 marked the issue as primary issue
#2 - c4-sponsor
2023-01-03T18:08:01Z
mehtaculous marked the issue as sponsor confirmed
#3 - mehtaculous
2023-01-03T18:10:31Z
Agree with High severity. Instead of adding re-entrancy
tag to purchase
function, pool state simply needs to be updated to success
before execution.
In regards to:
or by abusing an existing market that uses a user address in _purchaseOrder.
This is not considered an issue since users will most likely NOT contribute to a pool where they are not familiar with the NFT and / or contract. Since the NFT contract is set when the pool is created, it should not matter whether the contract is malicious or is for an existing market that uses a user address, the pool will just be disregarded.
#4 - stevennevins
2023-01-03T19:03:55Z
Potentially dup of https://github.com/code-423n4/2022-12-tessera-findings/issues/47 because it uses the same attack vector of a malicious _market
I think they're different enough where one is due to reentrancy and one is directly stealing the NFT
#5 - c4-judge
2023-01-11T01:45:01Z
HickupHH3 marked the issue as selected for report
#6 - stevennevins
2023-01-26T00:42:26Z
Data not available
purchase() in GroupBuy.sol executes the purchase call for the group. There are two possibilities for which tokenIDs can be bought in GroupBuy:
The merkleRoot value is defined in createPool:
bytes32 merkleRoot = (length == 1) ? bytes32(_tokenIds[0]) : _generateRoot(_tokenIds);
purchase() receives a _purchaseProof bytes32[] variable. It verifies the tokenID being bought is pre-accepted in this code snippet:
// Checks merkle proof based on size of array if (_purchaseProof.length == 0) { // Hashes tokenId to verify merkle root if proof is empty if (bytes32(_tokenId) != merkleRoot) revert InvalidProof(); } else { // Verifies merkle proof based on position of leaf node in tree bytes32 leaf = keccak256(abi.encode(_tokenId)); if (!MerkleProof.verify(_purchaseProof, merkleRoot, leaf)) revert InvalidProof(); }
The issue is that merkleRoot is treated as a merkleRoot or as an inline tokenID based on the existence of _purchaseProof which is supplied by the user. Therefore, attacker may pass a _purchaseProof when merkleRoot is intended to be a tokenId to treat the tokenId as a merkle root. More severely, when merkleRoot is intended as a true merkleRoot, attacker can pass a zero proof and copy the merkleRoot value in tokenId. It will be accepted in this check: if (bytes32(_tokenId) != merkleRoot)
The impact is that attacker may use the group's funds to buy a non intended tokenId. It will be some random uint256 number attacker doesn't control. The best way to exploit the error is to use NFT collections with user-chosen tokenIds. The group would raise money to buy a high value token, while user will mint a worthless one and use the group funds on it.
Attacker can make group pay for tokenID that is not intended.
Manual audit
Add an additional variable in PoolInfo struct, to note if there are multiple tokenIds or single one.
#0 - c4-judge
2022-12-20T07:47:31Z
HickupHH3 marked the issue as duplicate of #11
#1 - c4-judge
2022-12-20T07:47:35Z
HickupHH3 marked the issue as satisfactory
#2 - c4-judge
2023-01-11T02:13:52Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - C4-Staff
2023-01-13T17:06:49Z
liveactionllama marked the issue as duplicate of #14
Data not available
processBidsInQueue in GroupBuy handles integrating a new bid into the existing structure. If bumps out lower-paying bids until the new bid is completely fulfilled or the remaining bids are too high.
When the lowest paying bid has higher quantity than the remaining bid quantity to satisfy, we only bump out the necessary amount and leave it at the head of the priority queue:
if (lowestBidQuantity > quantity) { // Decrements given quantity amount from lowest bid quantity lowestBid.quantity -= quantity; // Calculates partial contribution of bid by quantity amount and price uint256 contribution = quantity * lowestBid.price; // Decrements partial contribution amount of lowest bid from total and user contributions totalContributions[_poolId] -= contribution; userContributions[_poolId][lowestBid.owner] -= contribution; // Increments pending balance of lowest bid owner pendingBalances[lowestBid.owner] += contribution; // Inserts new bid with given quantity amount into proper position of queue bidPriorityQueues[_poolId].insert(msg.sender, _price, quantity); // Resets quantity amount to exit while loop quantity = 0;
The line that changes the bid is lowestBid.quantity -= quantity;
. The issue is that this behavior is unfair and contrary to the docs.
It is stated that "If two users place bids at the same price but with different quantities, the queue will pull from the bid with a higher quantity first."
After removing "quantity" amount from lowestBid, it may now be a smaller quantity than others with the same price. Therefore, it should not be the minimum node at this point. The heap needs to re-organize and place the highest remaining quantity next in the queue. This would retain fairness and not completely remove one bid while another has much higher quantity.
Bidding data structure is corrupted, leading to unfair removal from queue.
Manual audit
Use the sink() queue function to update the data structure when not completely removing a node from the tree.
#0 - c4-judge
2022-12-20T07:55:34Z
HickupHH3 marked the issue as duplicate of #32
#1 - c4-judge
2022-12-20T07:55:39Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: Trust
Data not available
sendEthOrWeth() is used in several locations in OptimisticListingSeaport:
This is the implementation of sendEthOrWeth:
function _attemptETHTransfer(address _to, uint256 _value) internal returns (bool success) { // Here increase the gas limit a reasonable amount above the default, and try // to send ETH to the recipient. // NOTE: This might allow the recipient to attempt a limited reentrancy attack. (success, ) = _to.call{value: _value, gas: 30000}(""); } /// @notice Sends eth or weth to an address /// @param _to Address to send to /// @param _value Amount to send function _sendEthOrWeth(address _to, uint256 _value) internal { if (!_attemptETHTransfer(_to, _value)) { WETH(WETH_ADDRESS).deposit{value: _value}(); WETH(WETH_ADDRESS).transfer(_to, _value); } }
The issue is that the receive could be a contract that does not have a fallback function. In this scenario, _attemptETHTransfer will fail and WETH would be transferred to the contract. It is likely that it bricks those funds for the contract as there is no reason it would support interaction with WETH tokens.
It can be reasonably assumed that developers will develop contracts which will interact with OptimisticListingSeaport using proposals. They are not warned and are likely to suffer losses.
Loss of ETH for proposer when it is a contract that doesn't have fallback function.
Manual audit
Either enforce that proposer is an EOA or take in a recipient address for ETH transfers.
#0 - HickupHH3
2022-12-20T07:11:14Z
QA issue IMO. Seems reasonable to expect the proposer / RAE holder participating to be able to handle ETH in the first place.
Once the listing is purchased, the vault will act similarly to an Optimistic Buyout. The proposer will have their Raes returned to them, and all users can burn their Raes for their portion of ETH gained from the sale.
Will await sponsor input before making a final decision.
#1 - c4-judge
2022-12-20T07:12:00Z
HickupHH3 marked the issue as primary issue
#2 - c4-judge
2022-12-20T07:12:04Z
HickupHH3 marked the issue as satisfactory
#3 - trust1995
2022-12-21T06:19:08Z
QA issue IMO. Seems reasonable to expect the proposer / RAE holder participating to be able to handle ETH in the first place.
Once the listing is purchased, the vault will act similarly to an Optimistic Buyout. The proposer will have their Raes returned to them, and all users can burn their Raes for their portion of ETH gained from the sale.
Will await sponsor input before making a final decision.
It is not about ETH handling, because if it doesn't have a receive() method it would not receive the ETH in the first place in _attemptETHTransfer. It would get WETH credit instead but we can't expect contracts to support arbitrary WETH<->ETH conversion. I think it is a loss of funds with stated hypotheticals which doesn't require a user mistake.
Update: It is a similar issue to #6 , both assume contract fallback is implemented.
#4 - HickupHH3
2022-12-21T08:59:04Z
I'm confused, the title and description's premise is that the contract doesn't have a fallback / receive function right? Not sure what you mean by "both assume contract fallback is implemented". My stance is that this premise is questionable because the README clearly stated that ETH will be claimed in exchange for RAEs.
#6 is different; it's arguing that the tx won't revert even if the logic inside fallback / receive function of the receiver contract does, which would be catastrophic.
#5 - trust1995
2022-12-21T09:16:05Z
Not sure what you mean by "both assume contract fallback is implemented"
In this report, if sender contract doesn't implement fallback it will be loss of funds. Same thing happens in issue #6 if sender doesn't implement it. I accept the argument that this doesn't seem likely but loss of funds in stated hypotheticals can be accepted as M-level.
it's arguing that the tx won't revert even if the logic inside fallback / receive function of the receiver contract does, which would be catastrophic.
Described scenario also applies to this issue. It is not detailed in the submission but it's still a major concern like in #6. If sponsor would address _sendEthOrWeth issue it would fix both the hypothetical and the little-less-hypothetical attack.
#6 - HickupHH3
2022-12-21T09:59:10Z
The main problem is using WETH as a fallback for failed ETH transfers, which I agree with. However, I disagree with the premise being a failure to accept ETH. I would've had no qualms with the issue if the premise was a revert in the fallback function instead.
I'd like the sponsor to give their input before commenting further.
#7 - stevennevins
2023-01-03T19:43:49Z
This and #6 seem like distinct issues to me
#8 - c4-sponsor
2023-01-03T19:57:00Z
stevennevins marked the issue as disagree with severity
#9 - c4-sponsor
2023-01-03T20:03:35Z
stevennevins marked the issue as sponsor confirmed
#10 - HickupHH3
2023-01-11T15:48:21Z
Yes, distinct issues. The argument here is about the contract being able to handle ETH but not WETH. If the ETH transfer fails (eg. gas used exceeds the 30k sent), then funds would be stuck.
On the fence regarding severity here.
#11 - stevennevins
2023-01-13T14:54:53Z
I actually more agree with this being an issue:
Yes, distinct issues. The argument here is about the contract being able to handle ETH but not WETH. If the ETH transfer fails (eg. gas used exceeds the 30k sent), then funds would be stuck.
But it's not clear to me that is what was originally highlighted in the description of the issue
#12 - HickupHH3
2023-01-13T15:16:45Z
Yeah it's not fully clear because the premise is the contract not having a fallback function, but the intended effect of not being able to handle WETH is.
It is likely that it bricks those funds for the contract as there is no reason it would support interaction with WETH tokens.
#13 - c4-judge
2023-01-13T15:17:04Z
HickupHH3 marked the issue as selected for report
Data not available
In GroupBuy module, users can call contribute to get a piece of the NFT pie. There are two stages in transforming the msg.value to holdings in the NFT.Â
uint256 fillAtAnyPriceQuantity = remainingSupply < _quantity ? remainingSupply : _quantity; // Checks if quantity amount being filled is greater than 0 if (fillAtAnyPriceQuantity > 0) { // Inserts bid into end of queue bidPriorityQueues[_poolId].insert(msg.sender, _price, fillAtAnyPriceQuantity); // Increments total amount of filled quantities filledQuantities[_poolId] += fillAtAnyPriceQuantity; }
// Calculates unfilled quantity amount based on desired quantity and actual filled quantity amount uint256 unfilledQuantity = _quantity - fillAtAnyPriceQuantity; // Processes bids in queue to recalculate unfilled quantity amount unfilledQuantity = processBidsInQueue(_poolId, unfilledQuantity, _price);
The while loop in processBidsInQueue
will keep removing existing bids with lower price and create new queue entries for currently processed bid. When it reached a bid with a higher price than msg.sender's price, it will break:
while (quantity > 0) { // Retrieves lowest bid in queue Bid storage lowestBid = bidPriorityQueues[_poolId].getMin(); // Breaks out of while loop if given price is less than than lowest bid price if (_price < lowestBid.price) { break; }
The issue is that when _price == lowestBid.price
, we don't break and current bid will kick out older bid, as can be seen here:
// Decrements given quantity amount from lowest bid quantity lowestBid.quantity -= quantity; // Calculates partial contribution of bid by quantity amount and price uint256 contribution = quantity * lowestBid.price; // Decrements partial contribution amount of lowest bid from total and user contributions totalContributions[_poolId] -= contribution; userContributions[_poolId][lowestBid.owner] -= contribution; // Increments pending balance of lowest bid owner pendingBalances[lowestBid.owner] += contribution; // Inserts new bid with given quantity amount into proper position of queue bidPriorityQueues[_poolId].insert(msg.sender, _price, quantity);
The described behavior goes against what the docs describe will happen when two equal priced bids collide.
Earlier bidders get cut out of future NFT holdings by bidders specifying the same price.
Manual audit
Change the < to <= in the if condition:
if (_price <= lowestBid.price) { break; }
#0 - c4-judge
2022-12-20T07:44:31Z
HickupHH3 marked the issue as primary issue
#1 - c4-judge
2022-12-20T07:44:36Z
HickupHH3 marked the issue as selected for report
#2 - IllIllI000
2022-12-22T10:06:25Z
@HickupHH3 @trust1995 the final code block in this submission comes from here, which falls under the case where the price is the same and the quantity is different. The readme states If two users place bids at the same price but with different quantities, the queue will pull from the bid with a higher quantity first
, and as the submission shows, it's pulling from the higher quantity first - doesn't that mean this finding is invalid?
#3 - trust1995
2022-12-22T10:24:55Z
I agree the example shown is accidentally the wrong block (if clause) and the misbehavior occurs in the else block:
} else { // Calculates total contribution of bid by quantity amount and price uint256 contribution = lowestBid.quantity * lowestBid.price; // Decrements full contribution amount of lowest bid from total and user contributions totalContributions[_poolId] -= contribution; userContributions[_poolId][lowestBid.owner] -= contribution; // Increments pending balance of lowest bid owner pendingBalances[lowestBid.owner] += contribution; // Removes lowest bid in queue bidPriorityQueues[_poolId].delMin(); // Inserts new bid with lowest bid quantity amount into proper position of queue bidPriorityQueues[_poolId].insert(msg.sender, _price, lowestBidQuantity); // Decrements lowest bid quantity from total quantity amount quantity -= lowestBidQuantity;
The issue described still manifests in this block.
#4 - IllIllI000
2022-12-22T10:42:34Z
if you meant the else block, then isn't it a duplicate of https://github.com/code-423n4/2022-12-tessera-findings/issues/50 ?
#5 - trust1995
2022-12-22T10:47:49Z
No. #50 is about bad sorting issue when quantity is the same and price is the same. This issue is that when new bid comes with price equal to current min price, it is guaranteed to kick it out, when it shouldn't.
#6 - IllIllI000
2022-12-22T10:55:27Z
what is the adverse effect of 50 (if it ends up being valid)? Isn't the only effect that it leads to #45? That sounds like the same issue to me. I'll leave it up to @HickupHH3
#7 - HickupHH3
2022-12-23T00:57:39Z
Different vulnerability because the attack path and mitigation required is different, although the effect is the same. ie. this issue still exists even if #50 is fixed.
#8 - c4-sponsor
2023-01-04T21:46:24Z
stevennevins marked the issue as sponsor confirmed
#9 - stevennevins
2023-01-27T15:38:13Z
Data not available
createPool() in GroupBuy.sol creates a new contribution pool around an NFT. It specifies a target _initialPrice as minimum amount of ETH the NFT will cost, and _totalSupply which is the number of Raes to be minted on purchase success.
minBidPrices is calculated from the two numbers. All future bids must be at least minBidPrices. It is assumed that if the totalSupply of Raes is filled up, the group will collect the initialPrice.
// Calculates minimum bid price based on initial price of NFT and desired total supply minBidPrices[currentId] = _initialPrice / _totalSupply;
The issue is that division rounding error will make minBidPrices too low. Therefore, when all Raes are minted using minBidPrices price: minBidPrices[currentId] * _totalSupply != _initialPrice
Therefore, not enough money has been collected to fund the purchase. It can be assumed that most people will use minBidPrices to drive the price they will choose. Therefore, even after discovering that the Group has not raised enough after filling the supply pool, it will be very hard to get everyone to top up the contribution by a bit. This is because the settled price which is collected from all contributions is minReservePrices, which is always the minimum price deposited.
Code in contribute that updates minReservePrices:
// Updates minimum reserve price if filled quantity amount is greater than 0 if (filledQuantity > 0) minReservePrices[_poolId] = getMinPrice(_poolId);
The check in purchase() that we don't charge more than minReservePrices from each contribution:
if (_price > minReservePrices[_poolId] * filledQuantities[_poolId]) revert InvalidPurchase();
We can see an important contract functionality is not working as expected which will impair NFT purchases.
GroupBuys that are completely filled still don't raise stated target amount
Manual audit
Round the minBidPrices up, rather than down. It will ensure enough funds are collected.
#0 - HickupHH3
2022-12-20T07:53:26Z
Agree that the division rounding down will be a problem. Reasonable for a RAE holder to create a new pool where the _initialPrice
isn't exactly divisible by _totalSupply
:
Eg. _initialPrice = 100
, _totalSupply = 8. Then purchase will always revert with
InvalidPurchase()` because:
minBidPrices[currentId] = 100 / 8
and_price (100) > minReservePrices[_poolId] * filledQuantities[_poolId]) = 12 * 8 = 96
is true.#1 - c4-judge
2022-12-20T07:53:34Z
HickupHH3 marked the issue as selected for report
#2 - c4-judge
2022-12-20T07:53:39Z
HickupHH3 marked the issue as primary issue
#3 - c4-sponsor
2023-01-04T20:08:23Z
stevennevins marked the issue as sponsor confirmed
#4 - stevennevins
2023-01-27T15:38:52Z