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: 2/5
Findings: 6
Award: $0.00
🌟 Selected for report: 4
🚀 Solo Findings: 1
Data not available
On a successful purchase, users can call claim
on the GroupBuy contract to mint and refund. The refunded amount is userContributions - sum(minReservePrices*qty) - pendingBalances
. In case of the actual purchase price < minReservePrices, the difference is not refunded.
In the testClaimSuccess
test case, add the following line at the end:
assertEq(address(groupBuy).balance, 0);
Only Alice, Bob and Eve contributed to the GroupBuy, so when they all claim the contract should be left with 0 ether. There are no other way to remove the excess ether in the contract.
Foundry
After purchase, record the actual purchase price. Also need to think about rounding issues.
#0 - c4-judge
2022-12-20T03:01:47Z
HickupHH3 marked the issue as duplicate of #7
#1 - c4-judge
2022-12-20T03:01:52Z
HickupHH3 marked the issue as satisfactory
Data not available
The only check on a new proposal is that it is priced lower than the existing proposal. It does not constrain on the _collateral
supplied (except it will revert in _verifyBalance if set to 0). Anyone can block normal proposal creation by creating a proposal with lower price but _collateral == 1. When a high total supply is used, the price of each Rae is negligible and enable an attacker to DOS the protocol.
This violated the prevent a user from holding a vault hostage and never letting the piece be reasonably bought
requirement.
For any proposal, an attacker can deny it with _collateral = 1 and _price = price - 1
If he do not want the NFT to be sold, he can reject the proposal himself, reseting the contract state.
// Reverts if price per token is not lower than both the proposed and active listings if ( _pricePerToken >= proposedListing.pricePerToken || _pricePerToken >= activeListings[_vault].pricePerToken ) revert NotLower();
Add this test to OptimisticListingSeaport.t.sol:
function testProposeRevertLowerTotalValue() public { uint256 _collateral = 100; uint256 _price = 100; // setup testPropose(_collateral, _price); lowerPrice = pricePerToken - 1; // execute vm.expectRevert(); _propose(eve, vault, 1, lowerPrice, offer); // expect _assertListing(eve, 1, lowerPrice, block.timestamp); _assertTokenBalance(eve, token, tokenId, eveTokenBalance - 1); }
[FAIL. Reason: Call did not revert as expected]
Foundry
Require the total value of the new collateral be greater than the previous. This however still allow a Rae holder with sufficiently large holding to block proposal by creating a new proposal and immediately reject it himself.
#0 - trust1995
2022-12-21T06:55:14Z
imo valid, dup of #42
#1 - IllIllI000
2022-12-22T09:34:10Z
@HickupHH3 this looks like a dup of https://github.com/code-423n4/2022-12-tessera-findings/issues/41
#2 - HickupHH3
2022-12-23T00:32:37Z
Dup of #42
#3 - c4-judge
2022-12-23T00:32:51Z
HickupHH3 marked the issue as primary issue
#4 - c4-judge
2022-12-23T00:33:23Z
HickupHH3 marked the issue as satisfactory
#5 - c4-sponsor
2023-01-04T20:57:20Z
stevennevins marked the issue as sponsor confirmed
#6 - HickupHH3
2023-01-11T15:01:00Z
Best report for foundry POC + the following statement:
This violated the
prevent a user from holding a vault hostage and never letting the piece be reasonably bought
requirement.
#7 - c4-judge
2023-01-11T15:01:08Z
HickupHH3 marked the issue as selected for report
Data not available
When _purchaseProof.length == 0
, GroupBuy.purchase compare the tokenId with the merkleRoot. This allow any tokenId that match the merkleRoot to be purchased, even if they are not included in the allow list during setup.
if (_purchaseProof.length == 0) { // Hashes tokenId to verify merkle root if proof is empty if (bytes32(_tokenId) != merkleRoot) revert InvalidProof();
Add the following to GroupBuy.t.sol, it would still revert (since no such nft existed) but not as expected.
// modified from testPurchaseRevertInvalidProof function testPurchaseRevertInvalidTokenIdZeroLength() public { // setup testContributeSuccess(); // exploit uint256 invalidPunkId = uint256(nftMerkleRoot); bytes32[] memory invalidPurchaseProof = new bytes32[](0); // expect vm.expectRevert(INVALID_PROOF_ERROR); // execute _purchase( address(this), currentId, address(punksBuyer), address(punks), invalidPunkId, minValue, purchaseOrder, invalidPurchaseProof ); }
Foundry
Hash the tokenId even if there is only when length is 1
#0 - c4-judge
2022-12-20T02:19:33Z
HickupHH3 changed the severity to 2 (Med Risk)
#1 - c4-judge
2022-12-20T02:20:17Z
HickupHH3 marked the issue as duplicate of #11
#2 - c4-judge
2022-12-20T02:20:21Z
HickupHH3 marked the issue as satisfactory
#3 - HickupHH3
2023-01-11T02:15:43Z
Best report because foundry POC.
#4 - c4-judge
2023-01-11T02:15:47Z
HickupHH3 marked the issue as selected for report
#5 - C4-Staff
2023-01-13T17:05:48Z
liveactionllama marked the issue as not a duplicate
#6 - C4-Staff
2023-01-13T17:05:59Z
liveactionllama marked the issue as primary issue
🌟 Selected for report: gzeon
Data not available
In OptimisticListingSeaport.rejectProposal
, it revert if proposedListing.collateral < _amount
. An attacker can therefore monitor the mempool, reducing the proposedListing.collateral
to _amount - 1
by frontruning the rejectProposal
call and delay the rejection. The attacker may even be able to deny the rejection when the deadline passes.
if (proposedListing.collateral < _amount) revert InsufficientCollateral();
proposedListing.collateral -= _amount;
When proposedListing.collateral < _amount
, set _amount to proposedListing.collateral and refund the excess.
#0 - c4-sponsor
2023-01-04T20:23:07Z
stevennevins marked the issue as disagree with severity
#1 - stevennevins
2023-01-04T20:32:56Z
While annoying, i think this is a Medium severity issue. The attacker has to under price their proposal, defend this under priced proposal from other users and front run each purchase > 1, while selling their Raes at a loss, and it would be relatively costly for griefer to defend their underpriced proposal for the duration of the proposal period. Users could purchase 1 Rae at a time without risk of front running
#2 - HickupHH3
2023-01-11T14:45:19Z
Agree with med severity given the external requirements needed to pull this off.
#3 - c4-judge
2023-01-11T14:45:25Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-01-11T14:45:36Z
HickupHH3 marked the issue as primary issue
#5 - c4-judge
2023-01-11T14:56:55Z
HickupHH3 marked the issue as selected for report
Judge has assessed an item in Issue #22 as M risk. The relevant finding follows:
[NC-2] Return values of approve() not checked
#0 - c4-judge
2022-12-20T09:29:08Z
HickupHH3 marked the issue as duplicate of #36
#1 - c4-judge
2022-12-20T09:29:26Z
HickupHH3 marked the issue as satisfactory
#2 - c4-judge
2022-12-20T09:29:47Z
HickupHH3 marked the issue as selected for report
#3 - IllIllI000
2022-12-21T13:36:14Z
@HickupHH3 This issue was marked as non-critical, and did not elaborate on the consequences of the approval, and how it would lead to loss of funds. Doesn't that mean it should, at most, get reduced credit, rather than being selected for report? The submitter of the QA report even says, Disclaimer: report extended from 4naly3er tool
, and the finding is a literal copy-paste from that tool.
It is also worth noting that QA items may be marked as a duplicate of a Medium or High finding without being granted an upgrade, since making the case for how an issue can be exploited, and providing a thorough description and proof of concept, is part of what merits a finding properly earning Medium or High severity
https://docs.code4rena.com/roles/judges/how-to-judge-a-contest#upgrading-issues-from-qa-gas-to-medium-high
#4 - HickupHH3
2022-12-21T14:41:20Z
Sry, error on my end. This shouldn't have been selected for the report; should've been #36. Thanks for pointing it out.
#5 - c4-judge
2022-12-21T14:41:27Z
HickupHH3 marked the issue as not selected for report
#6 - c4-judge
2022-12-21T14:41:42Z
HickupHH3 marked the issue as selected for report
#7 - HickupHH3
2022-12-21T14:42:21Z
Ok, it's a bug with the extension. Somehow clicking on the GH link and selecting it as best affects this issue instead of #36.
#8 - c4-judge
2022-12-21T14:43:45Z
HickupHH3 marked the issue as not selected for report
#9 - c4-judge
2022-12-21T14:45:19Z
HickupHH3 marked the issue as partial-50
Judge has assessed an item in Issue #22 as M risk. The relevant finding follows:
minBidPrices is rounded down
#0 - c4-judge
2022-12-20T09:34:55Z
HickupHH3 marked the issue as duplicate of #49
#1 - c4-judge
2022-12-20T09:35:02Z
HickupHH3 marked the issue as partial-25
#2 - HickupHH3
2022-12-20T09:35:38Z
functional vulnerability is mentioned, but lacks the impact described by #49
Data not available
Disclaimer: report extended from 4naly3er tool
Issue | Instances | |
---|---|---|
L-1 | Unsafe ERC20 operation(s) | 1 |
L-2 | Contribution to GroupBuy should check against minReservePrices | 1 |
L-3 | Onchain merkle tree generation limited the size of tokenId | 1 |
Issue | Instances | |
---|---|---|
NC-1 | Missing checks for address(0) when assigning values to address state variables | 9 |
NC-2 | Return values of approve() not checked | 1 |
NC-3 | Functions not used internally could be marked external | 11 |
NC-4 | minBidPrices is rounded down | 1 |
NC-5 | Member of _tokenIds might not be unique | 1 |
NC-6 | Do not use assert() in production | 1 |
NC-7 | Hardcoded fees | 1 |
NC-8 | Wrong comment | 1 |
Instances (1):
File: src/seaport/targets/SeaportLister.sol 40: IERC20(token).approve(conduit, type(uint256).max);
If the bid is below minReservePrices it will be filtered
Instances (1):
if (msg.value < _quantity * minBidPrices[_poolId] || _quantity == 0)
Generate merkle tree onchain is expensive espically when you want to include a large set of value. For example, it is not possible to include all Punk in the tokenId becuase the merkle root is generated onchain. Consider generate it offchain and publish the root.
bytes32 merkleRoot = (length == 1) ? bytes32(_tokenIds[0]) : _generateRoot(_tokenIds);
address(0)
when assigning values to address state variablesInstances (9):
File: src/punks/protoforms/PunksMarketBuyer.sol 32: registry = _registry; 33: wrapper = _wrapper; 34: listing = _listing;
File: src/seaport/modules/OptimisticListingSeaport.sol 71: registry = _registry; 72: seaport = _seaport; 73: zone = _zone; 75: supply = _supply; 76: seaportLister = _seaportLister;
File: src/seaport/targets/SeaportLister.sol 20: conduit = _conduit;
approve()
not checkedNot all IERC20 implementations revert()
when there's a failure in approve()
. The function signature has a boolean return value and they indicate errors that way instead. By not checking the return value, operations that should have marked as failed, may potentially go through without actually approving anything
Instances (1):
File: src/seaport/targets/SeaportLister.sol 40: IERC20(token).approve(conduit, type(uint256).max);
Instances (11):
File: src/lib/MinPriorityQueue.sol 27: function initialize(Queue storage self) public { 36: function getNumBids(Queue storage self) public view returns (uint256) { 41: function getMin(Queue storage self) public view returns (Bid storage) { 90: function delMin(Queue storage self) public returns (Bid memory) {
File: src/modules/GroupBuy.sol 346: function getBidInQueue(uint256 _poolId, uint256 _bidId) 371: function getNextBidId(uint256 _poolId) public view returns (uint256) { 377: function getNumBids(uint256 _poolId) public view returns (uint256) { 384: function getBidQuantity(uint256 _poolId, uint256 _bidId) public view returns (uint256) { 402: function printQueue(uint256 _poolId) public view {
File: src/seaport/modules/OptimisticListingSeaport.sol 218: function list(address _vault, bytes32[] calldata _listProof) public { 344: function getPermissions()
minBidPrices is rounded down and can be 0, which will cause precision issue
minBidPrices[currentId] = _initialPrice / _totalSupply;
uint256[] calldata _tokenIds,
It will consume all gas and does not provide a revert string, use require()
instead
assert(ConsiderationInterface(_consideration).validate(_orders));
assert(ConsiderationInterface(_consideration).cancel(_orders));
Would require migration if either decided to change fee structure in the future.
uint256 openseaFees = _listingPrice / 40; uint256 tesseraFees = _listingPrice / 20;
Here should be + 2 consideration for the fees
// 1 Consideration for the listing itself + 1 consideration for the fees
#0 - HickupHH3
2022-12-20T09:37:17Z
| L-1 | Unsafe ERC20 operation(s) | 1 |
This could've been a dup of #36 or #37, but I can't discern what the vuln is from the title.
#1 - c4-judge
2022-12-20T09:37:36Z
HickupHH3 marked the issue as grade-b
#2 - c4-sponsor
2023-01-05T18:35:18Z
mehtaculous marked the issue as sponsor acknowledged
Data not available
Disclaimer: report extended from 4naly3er tool
Issue | Instances | |
---|---|---|
GAS-1 | Use assembly to check for address(0) | 2 |
GAS-2 | Cache array length outside of loop | 2 |
GAS-3 | State variables should be cached in stack variables rather than re-reading them from storage | 5 |
GAS-4 | Use calldata instead of memory for function arguments that do not get mutated | 5 |
GAS-5 | Use Custom Errors | 2 |
GAS-6 | Don't initialize variables with default value | 2 |
GAS-7 | ++i costs less gas than i++ , especially when it's used in for -loops (--i /i-- too) | 4 |
GAS-8 | Use shift Right/Left instead of division/multiplication if possible | 5 |
GAS-9 | Use != 0 instead of > 0 for unsigned integer comparison | 5 |
Gas-10 | Use unchecked when it is safe | ? |
Gas-11 | Refund contribution and pending balance in the same call | 1 |
Gas-12 | Generate merkle tree offchain | 1 |
Gas-13 | Pack Bid Struck | 1 |
Gas-14 | Pack Queue Struck | 1 |
address(0)
Saves 6 gas per instance
Instances (2):
File: src/seaport/modules/OptimisticListingSeaport.sol 106: proposedListings[_vault].proposer == address(0) && 107: activeListings[_vault].proposer == address(0)
If not cached, the solidity compiler will always read the length of the array during each iteration. That is, if it is a storage array, this is an extra sload operation (100 additional extra gas for each iteration except for the first) and if it is a memory array, this is an extra mload operation (3 additional gas for each iteration except for the first).
Instances (2):
File: src/lib/MinPriorityQueue.sol 98: for (uint256 i = 0; i < curUserBids.length; i++) {
File: src/seaport/modules/OptimisticListingSeaport.sol 390: for (uint256 i = 0; i < _offer.length; ++i) {
The instances below point to the second+ access of a state variable within a function. Caching of a state variable replaces each Gwarmaccess (100 gas) with a much cheaper stack read. Other less obvious fixes/optimizations include having local memory caches of state variable structs, or having local caches of state variable contracts/addresses.
Saves 100 gas per instance
Instances (5):
File: src/modules/GroupBuy.sol 92: contribute(currentId, _quantity, _raePrice);
File: src/punks/protoforms/PunksMarketBuyer.sol 37: proxy = IWrappedPunk(wrapper).proxyInfo(address(this)); 64: IERC721(wrapper).safeTransferFrom(address(this), vault, tokenId);
File: src/seaport/modules/OptimisticListingSeaport.sol 362: seaportLister,
File: src/seaport/targets/SeaportLister.sol 38: IERC1155(token).setApprovalForAll(conduit, true);
Mark data types as calldata
instead of memory
where possible. This makes it so that the data is not automatically loaded into memory. If the data passed into the function does not need to be changed (like updating values in an array), it can be passed in as calldata
. The one exception to this is if the argument must later be passed into another function that takes an argument that specifies memory
storage.
Instances (5):
File: src/modules/GroupBuy.sol 166: bytes memory _purchaseOrder, 167: bytes32[] memory _purchaseProof
File: src/punks/protoforms/PunksMarketBuyer.sol 46: function execute(bytes memory _order) external payable returns (address vault) {
File: src/seaport/targets/SeaportLister.sol 26: function validateListing(address _consideration, Order[] memory _orders) external { 51: function cancelListing(address _consideration, OrderComponents[] memory _orders) external {
Source Instead of using error strings, to reduce deployment and runtime cost, you should use Custom Errors. This would save both deployment and runtime cost.
Instances (2):
File: src/lib/MinPriorityQueue.sol 42: require(!isEmpty(self), "nothing to return"); 91: require(!isEmpty(self), "nothing to delete");
Instances (2):
File: src/lib/MinPriorityQueue.sol 98: for (uint256 i = 0; i < curUserBids.length; i++) {
File: src/seaport/modules/OptimisticListingSeaport.sol 390: for (uint256 i = 0; i < _offer.length; ++i) {
++i
costs less gas than i++
, especially when it's used in for
-loops (--i
/i--
too)Saves 5 gas per loop
Instances (4):
File: src/lib/MinPriorityQueue.sol 60: j++; 77: insert(self, Bid(self.nextBidId++, owner, price, quantity)); 98: for (uint256 i = 0; i < curUserBids.length; i++) {
File: src/modules/GroupBuy.sol 74: poolInfo[++currentId] = PoolInfo(
Instances (5):
File: src/lib/MinPriorityQueue.sol 49: while (k > 1 && isGreater(self, k / 2, k)) { 50: exchange(self, k, k / 2); 51: k = k / 2;
File: src/seaport/modules/OptimisticListingSeaport.sol 395: uint256 openseaFees = _listingPrice / 40; 396: uint256 tesseraFees = _listingPrice / 20;
Instances (5):
File: src/modules/GroupBuy.sol 124: if (fillAtAnyPriceQuantity > 0) { 139: if (filledQuantity > 0) minReservePrices[_poolId] = getMinPrice(_poolId); 268: if (pendingBalances[msg.sender] > 0) withdrawBalance(); 297: while (quantity > 0) {
File: src/seaport/modules/OptimisticListingSeaport.sol 469: if (isValidated && !isCancelled && totalFilled > 0 && totalFilled == totalSize) {
For example, those dealing with eth value will basically never overflow:
userContributions[_poolId][msg.sender] += msg.value; totalContributions[_poolId] += msg.value;
filledQuantities[_poolId] += fillAtAnyPriceQuantity;
Also those with explicit check before
lowestBid.quantity -= quantity;
// Transfers remaining contribution balance back to caller payable(msg.sender).call{value: contribution}(""); // Withdraws pending balance of caller if available if (pendingBalances[msg.sender] > 0) withdrawBalance();
Generate merkle tree onchain is expensive espically when you want to include a large set of value. Consider generating it offchain a publish the root when creating a new pool.
bytes32 merkleRoot = (length == 1) ? bytes32(_tokenIds[0]) : _generateRoot(_tokenIds);
The Bid struct can be packed tighter
struct Bid { uint256 bidId; address owner; uint256 price; uint256 quantity; }
Link to code to
struct Bid { uint96 bidId; address owner; uint256 price; uint256 quantity; }
struct Queue { ///@notice incrementing bid id uint256 nextBidId; ///@notice array backing priority queue uint256[] bidIdList; ///@notice total number of bids in queue uint256 numBids; //@notice map bid ids to bids mapping(uint256 => Bid) bidIdToBidMap; ///@notice map addreses to bids they own mapping(address => uint256[]) ownerToBidIds; }
Link to code to
struct Queue { ///@notice incrementing bid id uint96 nextBidId; ///@notice total number of bids in queue uint160 numBids; ///@notice array backing priority queue uint256[] bidIdList; //@notice map bid ids to bids mapping(uint256 => Bid) bidIdToBidMap; ///@notice map addreses to bids they own mapping(address => uint256[]) ownerToBidIds; }
#0 - HickupHH3
2022-12-20T09:14:57Z
Since there are only 2 gas opt reports, it's easy to do a direct comparison
GAS-1 GAS-6 (IIRC N.A. in this case) GAS-9 (IIRC N.A. in this case) GAS-10 GAS-11 GAS-12 GAS-13 / GAS-14
GAS-2 == G-07 GAS-3 == G-04 GAS-4 == G-02 GAS-5 == G-14 GAS-7 == G-10 GAS-8 == G-13
G-01 G-03 G-05 G-06 G-09 G-11 G-12
I like that #28 states the amount of gas saved per finding and as a whole. However, this report edges out in gas savings IMO, notably with the struct reorderings + doing merkle proof generation off-chain recommendations.
#1 - c4-judge
2022-12-20T09:15:07Z
HickupHH3 marked the issue as selected for report
#2 - IllIllI000
2022-12-20T09:22:35Z
Just note that the struct reordering is not just re-ordering - the suggestion changes the sizes of the various fields, which changes functionality
#3 - HickupHH3
2022-12-20T09:24:29Z
Yes, am aware a couple of uint
have been downcasted; seems reasonably large enough to not be a problem.
#4 - c4-sponsor
2023-01-05T18:33:42Z
mehtaculous marked the issue as sponsor confirmed