Platform: Code4rena
Start Date: 07/04/2023
Pot Size: $47,000 USDC
Total HM: 20
Participants: 120
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 230
League: ETH
Rank: 7/120
Findings: 5
Award: $1,283.90
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: Voyvoda
Also found by: AkshaySrivastav, Haipls, aviggiano, teddav
820.1517 USDC - $820.15
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L244 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L274
The PrivatePool.buy
function intends to distribute royalty amount whenever NFTs are bought. Its implementation looks like this:
function buy(uint256[] calldata tokenIds, uint256[] calldata tokenWeights, MerkleMultiProof calldata proof) public payable returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // ... // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { // transfer the NFT to the caller ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { // get the royalty fee for the NFT (uint256 royaltyFee,) = _getRoyalty(tokenIds[i], salePrice); // add the royalty fee to the total royalty fee amount royaltyFeeAmount += royaltyFee; } } // add the royalty fee amount to the net input aount netInputAmount += royaltyFeeAmount; // ... if (payRoyalties) { for (uint256 i = 0; i < tokenIds.length; i++) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } // emit the buy event emit Buy(tokenIds, tokenWeights, netInputAmount, feeAmount, protocolFeeAmount, royaltyFeeAmount); } function _getRoyalty(uint256 tokenId, uint256 salePrice) internal view returns (uint256 royaltyFee, address recipient) { // get the royalty lookup address address lookupAddress = IRoyaltyRegistry(royaltyRegistry).getRoyaltyLookupAddress(nft); if (IERC2981(lookupAddress).supportsInterface(type(IERC2981).interfaceId)) { // get the royalty fee from the registry (recipient, royaltyFee) = IERC2981(lookupAddress).royaltyInfo(tokenId, salePrice); // revert if the royalty fee is greater than the sale price if (royaltyFee > salePrice) revert InvalidRoyaltyFee(); } }
It should be noted that the collection of royalty amount from buyer (L237 - L269) and distribution of royalty amounts to royalty recipient (L271 - L285) happens separately. Also while distributing the royalty amount to recipient the royalty amount value is queried again.
This implementation can be misused to steal baseToken
funds from the pool. In the case where the royalty amount returned in the second _getRoyalty
call is greater than the amount return in the first _getRoyalty
call, the sufficient royalty amount won't be collected from the buyer so the extra royalty amount needed will be sent from the PrivatePool's baseToken
balance.
In the case where the royalty recipient has influence over the royaltyRegistry
, lookupAddress
or the royaltyInfo
call, he can manipulate the result of second _getRoyalty
call. If the result of second query is greater than the first one then the pool's own baseToken
balance will transferred to the royalty recipient.
This bug will cause loss of funds to all the depositors of the pool as the originally deposited baseToken
s and the pool's swap fee reserves can be stolen using this vulnerability.
It should be noted that Caviar protocol intends to support open creation of private pools with any configuration possible as desired by the user. Hence the likelyhood of this exploit is high.
Consider this scenario:
royaltyRegistry
, lookupAddress
or the royaltyInfo
call invokes the buy
function.PrivatePool.buy
function queries and calculates the royalty amount, the royaltyFeeAmount
comes out to be 10 WETH._getRoyalty
function before the royalty amount transfer. But this time the amount returned comes out to be 20 WETH.Note that the funds required to initiate the buy call and its recovery was omitted to make the scenario easier to understand. Any funds needed for the buy call can be recovered by performing further swaps.
Manual review
Cosnider performing the royalty amount calculation, amount transfer from buyer to pool and amount transfer from pool to royalty recipient in a single for
loop, just like how it is done in the sell
function.
function sell( ... ) public returns (uint256 netOutputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // ... uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { // transfer each nft from the caller ERC721(nft).safeTransferFrom(msg.sender, address(this), tokenIds[i]); if (payRoyalties) { // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netOutputAmount + feeAmount + protocolFeeAmount) / tokenIds.length; // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // tally the royalty fee amount royaltyFeeAmount += royaltyFee; // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } // subtract the royalty fee amount from the net output amount netOutputAmount -= royaltyFeeAmount; // ... }
#0 - c4-pre-sort
2023-04-20T19:02:07Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-04-21T18:00:16Z
outdoteth marked the issue as sponsor confirmed
#2 - outdoteth
2023-04-24T12:34:36Z
Fixed in: https://github.com/outdoteth/caviar-private-pools/pull/12
Proposed fix is to move royalty payments and calculations into a single loop. And if the baseToken is an ERC20 then to transfer the netInputAmount before the royalty amounts are paid, and transfer all royalty payments directly from the msg.sender to the royalty recipient.
If the base token is native ETH then it's checked that AFTER all royalty payments have been made the msg.value is greater than or equal to the total netInputAmount (inclusive of royalties).
(long code snippet containing the totality of the fix, apologies):
if (baseToken != address(0)) { // transfer the base token from the caller to the contract ERC20(baseToken).safeTransferFrom(msg.sender, address(this), netInputAmount); // if the protocol fee is set then pay the protocol fee if (protocolFeeAmount > 0) ERC20(baseToken).safeTransfer(address(factory), protocolFeeAmount); } // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { // transfer the NFT to the caller ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); if (royaltyFee > 0 && recipient != address(0)) { // add the royalty fee amount to the net input amount netInputAmount += royaltyFee; // transfer the royalties to the recipient if (baseToken != address(0)) { ERC20(baseToken).safeTransferFrom(msg.sender, recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } if (baseToken == address(0)) { // check that the caller sent enough ETH to cover the net required input if (msg.value < netInputAmount) revert InvalidEthAmount(); // if the protocol fee is set then pay the protocol fee if (protocolFeeAmount > 0) factory.safeTransferETH(protocolFeeAmount); // refund any excess ETH to the caller if (msg.value > netInputAmount) msg.sender.safeTransferETH(msg.value - netInputAmount); }
#3 - c4-judge
2023-04-30T16:04:58Z
GalloDaSballo marked the issue as duplicate of #320
#4 - c4-judge
2023-05-01T07:01:22Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xLanterns
Also found by: AkshaySrivastav, Bason, CodingNameKiki, DadeKuma, DishWasher, Dug, ElKu, J4de, MiloTruck, Nyx, RaymondFam, Ruhum, T1MOH, Voyvoda, abiih, adriro, aviggiano, bshramin, sashik_eth, savi0ur, yixxas
9.3258 USDC - $9.33
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L236-L249 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L274 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L335-L341
The buy
and sell
functions of PrivatePool intends to pay royalty amounts to royalty recipients on every token swap. The calculation of royalty amount is done as follows:
function buy(...) public payable returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // ... // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { (uint256 royaltyFee,) = _getRoyalty(tokenIds[i], salePrice); royaltyFeeAmount += royaltyFee; } } // ... if (payRoyalties) { for (uint256 i = 0; i < tokenIds.length; i++) { (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } // ... }
It can be seen that the a common salePrice
value is taken for all NFTs. The comment also mentions that
calculate the sale price (assume it's the same for each NFT even if weights differ)
This assumption is wrong and results in incorrect royalty amounts sent to royalty recipient. It should never be assumed that the royalty percentage of all NFTs will be equal. The profit/loss of royalty recipient is counter balanced with the funds of the trader.
A detailed example is provided in the PoC section.
The bug results in direct loss of funds to the users interacting with the buy
and sell
functions, or the royalty recipient. As the calculations are similar in both buy
and sell
functions, both of them contain this bug.
Consider the scenario in which two NFts (NFT1 and NFT2) are present in the pool. Assumptions:
Now suppose a buy
transaction for these two NFTs happens in the pool.
In the ideal case, the royalty amounts for each NFT should be:
But this is what happens with the current implementation of PrivatePool.
salePrice
is calculated as: (10 ETH + 2 ETH) / 2 = 6 ETHThis royalty amount including the incorrect & extra 0.4 ETH is collected from the buyer of the NFTs. Hence this results in loss of funds to the buyer. This lost amount gets transferred to the royalty recipient.
If the royalty percentages of the two NFTs are switched, then that will result in profit to trader and loss to royalty recipient.
Manual review
Consider calculating the royalty amounts with respect to the individual price of each NFTs like _getRoyalty(tokenIds[i], salePrice of tokenIds[i])
.
#0 - c4-pre-sort
2023-04-17T16:06:28Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T17:42:51Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-21T18:02:12Z
outdoteth marked the issue as sponsor acknowledged
#3 - outdoteth
2023-04-22T14:00:03Z
#4 - c4-judge
2023-04-30T16:30:29Z
GalloDaSballo marked the issue as duplicate of #669
#5 - c4-judge
2023-05-01T07:26:13Z
GalloDaSballo changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-05-01T07:27:15Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0xRobocop, Koolex, adriro, bin2chen, bshramin, chaduke, cryptonue, rbserver, rvierdiiev, saian, sashik_eth, tallo
52.9573 USDC - $52.96
https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L237-L281 https://github.com/code-423n4/2023-04-caviar/blob/main/src/PrivatePool.sol#L328-L355
The PrivatePool.buy
and PrivatePool.sell
functions intend to distribute royalty amount whenever NFTs are traded. The implementation of buy and sell looks like this:
function buy(uint256[] calldata tokenIds, uint256[] calldata tokenWeights, MerkleMultiProof calldata proof) public payable returns (uint256 netInputAmount, uint256 feeAmount, uint256 protocolFeeAmount) { // ... // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netInputAmount - feeAmount - protocolFeeAmount) / tokenIds.length; uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { // transfer the NFT to the caller ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]); if (payRoyalties) { // get the royalty fee for the NFT (uint256 royaltyFee,) = _getRoyalty(tokenIds[i], salePrice); // add the royalty fee to the total royalty fee amount royaltyFeeAmount += royaltyFee; } } // add the royalty fee amount to the net input aount netInputAmount += royaltyFeeAmount; // ... if (payRoyalties) { for (uint256 i = 0; i < tokenIds.length; i++) { // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } // emit the buy event emit Buy(tokenIds, tokenWeights, netInputAmount, feeAmount, protocolFeeAmount, royaltyFeeAmount); } function sell( ... ) public returns (...) { // ... uint256 royaltyFeeAmount = 0; for (uint256 i = 0; i < tokenIds.length; i++) { // transfer each nft from the caller ERC721(nft).safeTransferFrom(msg.sender, address(this), tokenIds[i]); if (payRoyalties) { // calculate the sale price (assume it's the same for each NFT even if weights differ) uint256 salePrice = (netOutputAmount + feeAmount + protocolFeeAmount) / tokenIds.length; // get the royalty fee for the NFT (uint256 royaltyFee, address recipient) = _getRoyalty(tokenIds[i], salePrice); // tally the royalty fee amount royaltyFeeAmount += royaltyFee; // transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } } } } // subtract the royalty fee amount from the net output amount netOutputAmount -= royaltyFeeAmount; if (baseToken == address(0)) { // transfer ETH to the caller msg.sender.safeTransferETH(netOutputAmount); // if the protocol fee is set then pay the protocol fee if (protocolFeeAmount > 0) factory.safeTransferETH(protocolFeeAmount); } else { // transfer base tokens to the caller ERC20(baseToken).transfer(msg.sender, netOutputAmount); // if the protocol fee is set then pay the protocol fee if (protocolFeeAmount > 0) ERC20(baseToken).safeTransfer(address(factory), protocolFeeAmount); } // ... }
It should be noted that while calculating royaltyFeeAmount
the the recipient
address returned from _getRoyalty
function is ignored and the returned royaltyFee
is added to the royaltyFeeAmount
. This cumulative royalty amount is then collected from the trader.
However while performing the actual royalty transfer to the royalty recipient the returned recipient
address is validated to not be equal to 0. The royalty is only paid when the recipient
address is non-zero.
This inconsistency between royalty collection and royalty distribution can cause loss of funds to the traders. In the cases when royaltyFee
is non-zero but recipient
address is zero, the fee will be collected from traders but won't be distributed to royalty recipient. Hence causing loss of funds to the traders.
As the creation of private pools is open to everyone, the likelyhood of this vulnerability is high.
Consider this scenario:
buy
call for an NFT.PrivatePool.buy
function queries the _getRoyalty
function which returns 10 WETH as the royaltyFee
and 0x00
address as the royalty recipient.royaltyFeeAmount
amount and will be collected from the buyer.0x00
, the 10 WETH royalty amount will not be distributed.A similar scenario is possible for the NFT sell
flow.
Manual review
Consider collecting royalty amount from traders only when the royalty recipient is non-zero.
if (royaltyFee > 0 && recipient != address(0)) { royaltyFeeAmount += royaltyFee; }
#0 - c4-pre-sort
2023-04-17T13:52:29Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T16:47:54Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-21T16:55:47Z
outdoteth marked the issue as sponsor confirmed
#3 - outdoteth
2023-04-24T12:17:12Z
Fixed in: https://github.com/outdoteth/caviar-private-pools/pull/11
Proposed fix is to only increment the total royalty fee amount in sell() and buy() when the recipient address is not zero.
In sell():
// transfer the royalty fee to the recipient if it's greater than 0 if (royaltyFee > 0 && recipient != address(0)) { // tally the royalty fee amount royaltyFeeAmount += royaltyFee; if (baseToken != address(0)) { ERC20(baseToken).safeTransfer(recipient, royaltyFee); } else { recipient.safeTransferETH(royaltyFee); } }
In buy():
if (royaltyFee > 0 && recipient != address(0)) { // add the royalty fee to the total royalty fee amount royaltyFeeAmount += royaltyFee; }
#4 - GalloDaSballo
2023-04-28T11:27:03Z
The Warden has shown how, due to a logic discrepancy, a non-zero royalty would be removed from total paid, but wouldn't be transferred
The behaviour is inconsistent, so the finding is valid
Technically the tokens will be left in the pool, meaning the owner will be able to retrieve them
Factually this would end up being an additional cost to the buyer, more so than a loss of funds
Because the finding shows an inconsistent behavior, that doesn't cause a loss of funds beside the royalty fee, I believe Medium Severity to be the most appropriate
#5 - c4-judge
2023-04-28T11:27:09Z
GalloDaSballo changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-04-28T11:27:18Z
GalloDaSballo marked the issue as selected for report
#7 - GalloDaSballo
2023-05-04T09:23:40Z
At this time, with the information that I have available, the finding highlights the fact that the royalties may be paid, but no transferred to the recipient if the recipient is the address(0)
While the OZ implementation addresses this, I don't believe older implementations would
I also have to concede that having non-zero royalties sent to address(0) should not be common
However, I maintain that the issue with the finding is not the address(0) per-se which would have been rated as Low, but the fact that in that case the behaviour is inconsistent with other cases, and that will cause a cost to the payer although the royalties will not be forwarded.
By contrast, if the royalties were sent to address(0) I would be arguing around the idea that the royalty recipient may wish to burn such tokens and that would have been within their rights to do so
For the reasons above, am maintaining Med Severity
🌟 Selected for report: AkshaySrivastav
Also found by: 0xTheC0der, Dug, GT_Blockchain, Haipls, adriro, bin2chen, carlitox477, dingo2077, fs0c, hasmama, hihen, holyhansss_kr, juancito, ladboy233, philogy, saian, said, sashik_eth, yixxas, zion
14.112 USDC - $14.11
https://github.com/code-423n4/2023-04-caviar/blob/main/src/Factory.sol#L92
The Factory.create
function is responsible for creating new PrivatePool
s. It does this using the LibClone.cloneDeterministic
function.
function create( ... bytes32 _salt, ... ) public payable returns (PrivatePool privatePool) { if ((_baseToken == address(0) && msg.value != baseTokenAmount) || (_baseToken != address(0) && msg.value > 0)) { revert PrivatePool.InvalidEthAmount(); } // deploy a minimal proxy clone of the private pool implementation privatePool = PrivatePool(payable(privatePoolImplementation.cloneDeterministic(_salt))); // ... }
The address of the new PrivatePool depends solely upon the _salt
parameter provided by the user. Once the user's create transaction is broadcasted, the _salt
parameter can be viewed by anyone watching the public mempool.
This public readability of _salt
parameter creates two issues:
Stealing of user's deposit amount. If a user intends to create new pool and deposit some funds in it then an attacker can frontrun the user's txns and capture the deposit amounts. Here is how this can happen:
XXX
as the salt and second one to deposit some ETH into the new pool.XXX
salt.DoS for Factory.create
.
If a user intends to create a PrivatePool, his create txn can be forcefully reverted by an attacker by deploying a pool for himself using the user's salt. Here is how this can happen:
XXX
.XXX
salt.Factory.create
function.These test cases were added to test/PrivatePool/Withdraw.t.sol
file and were ran using forge test --ffi --mp test/PrivatePool/Withdraw.t.sol --mt test_audit
function test_audit_create_stealDeposit() public { address user1 = makeAddr("user1"); vm.deal(user1, 10 ether); vm.startPrank(user1); address predictedAddress = factory.predictPoolDeploymentAddress(bytes32(0)); // tries to create pool and deposit funds // 1. factory.create(...) // 2. pool.deposit(...) // but user2 frontruns the txns address user2 = makeAddr("user2"); changePrank(user2); uint baseTokenAmount = 0; PrivatePool pool = factory.create{value: baseTokenAmount}( baseToken, nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, bytes32(0), tokenIds, baseTokenAmount ); assertEq(predictedAddress, address(pool)); assertEq(factory.ownerOf(uint256(uint160(address(pool)))), address(user2)); changePrank(user1); vm.expectRevert(LibClone.DeploymentFailed.selector); factory.create{value: baseTokenAmount}( baseToken, nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, bytes32(0), tokenIds, baseTokenAmount ); pool.deposit{ value: 10 ether }(tokenIds, 10 ether); assertEq(address(pool).balance, 10 ether); changePrank(user2); pool.withdraw(address(0), tokenIds, address(0), 10 ether); assertEq(address(pool).balance, 0); assertEq(user2.balance, 10 ether); } function test_audit_create_DoS() public { address user1 = makeAddr("user1"); vm.deal(user1, 10 ether); vm.startPrank(user1); address predictedAddress = factory.predictPoolDeploymentAddress(bytes32(0)); // user1 tries to create pool // factory.create(...) // but user2 frontruns the txn address user2 = makeAddr("user2"); changePrank(user2); uint baseTokenAmount = 0; PrivatePool pool = factory.create{value: baseTokenAmount}( baseToken, nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, bytes32(0), tokenIds, baseTokenAmount ); assertEq(predictedAddress, address(pool)); assertEq(factory.ownerOf(uint256(uint160(address(pool)))), address(user2)); changePrank(user1); vm.expectRevert(LibClone.DeploymentFailed.selector); factory.create{value: baseTokenAmount}( baseToken, nft, virtualBaseTokenReserves, virtualNftReserves, changeFee, feeRate, merkleRoot, true, false, bytes32(0), tokenIds, baseTokenAmount ); }
Foundry
Consider making the upcoming pool address user specific by combining the salt value with user's address.
privatePool = PrivatePool(payable(privatePoolImplementation.cloneDeterministic( keccak256(abi.encode(msg.seender, _salt)) )));
#0 - c4-pre-sort
2023-04-17T17:59:59Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T17:16:28Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-04-22T08:54:44Z
outdoteth marked the issue as sponsor confirmed
#3 - outdoteth
2023-04-24T11:57:53Z
Fixed in: https://github.com/outdoteth/caviar-private-pools/pull/9
The proposed fix is to include the msg.sender in the salt of the proxy deployment:
// deploy a minimal proxy clone of the private pool implementation bytes32 salt = keccak256(abi.encode(msg.sender, _salt)); privatePool = PrivatePool(payable(privatePoolImplementation.cloneDeterministic(salt)));
#4 - GalloDaSballo
2023-04-28T11:20:14Z
This boils down to whether we think a front-run is possible / reasonable And whether the griefing can be considered protracted in time
I have already judged similar issues, so I'll link those here and share my thoughts
#5 - GalloDaSballo
2023-04-30T08:58:02Z
Per my comments on a similar issue: https://github.com/code-423n4/2022-09-nouns-builder-findings/issues/182
I believe that the DOS is possible and fairly easy to achieve, however, there are ways to sidestep it
By creating a new pool with new salt
s it's possible to prevent the DOS, the NFT can then be transferred to the intended owner
For this reason am downgrading to Medium Severity
#6 - c4-judge
2023-04-30T08:58:10Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - GalloDaSballo
2023-04-30T08:58:51Z
For context, if pool weren't transferable then the DOS would have been permanent and I would have raised severity
#8 - c4-judge
2023-05-01T07:22:37Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: AkshaySrivastav
Also found by: 0x5rings, 0xbepresent, ABA, Bauchibred, BenRai, DadeKuma, ElKu, RaymondFam, Rolezn, adriro, btk, chaduke, devscrooge, dingo2077, minhtrng, nemveer, p0wd3r, rbserver, ulqiorra
387.3496 USDC - $387.35
The Factory.create
function is susceptible to re-entrancy as it performs a _safeMint
before initializing the pool.
function create( ... ) public payable returns (PrivatePool privatePool) { // ... privatePool = PrivatePool(payable(privatePoolImplementation.cloneDeterministic(_salt))); // mint the nft to the caller _safeMint(msg.sender, uint256(uint160(address(privatePool)))); // initialize the pool privatePool.initialize(...); // ... }
The Factory.create
function performs plain transfer of funds instead of calling the deposit
function. This way the Deposit
event is not emitted.
function create( ... ) public payable returns (PrivatePool privatePool) { // ... privatePool.initialize(...); if (_baseToken == address(0)) { // transfer eth into the pool if base token is ETH address(privatePool).safeTransferETH(baseTokenAmount); } else { // deposit the base tokens from the caller into the pool ERC20(_baseToken).transferFrom(msg.sender, address(privatePool), baseTokenAmount); } // deposit the nfts from the caller into the pool for (uint256 i = 0; i < tokenIds.length; i++) { ERC721(_nft).safeTransferFrom(msg.sender, address(privatePool), tokenIds[i]); } // emit create event emit Create(address(privatePool), tokenIds, baseTokenAmount); }
There is no fee cap on the Factory.setProtocolFeeRate
function. A value greater then 10000 can break the fee calculations in private pool. Consider validating that the input is less than 10000.
function setProtocolFeeRate(uint16 _protocolFeeRate) public onlyOwner { protocolFeeRate = _protocolFeeRate; }
Factory.tokenId
does not validate the input id
parameter. Consider validating that the id
exist and the respective pool is created by the factory.
function tokenURI(uint256 id) public view override returns (string memory) { return PrivatePoolMetadata(privatePoolMetadata).tokenURI(id); }
Once initialization is done the PrivatePool.feeRate
variable can never be changed. Consider adding an owner
restricted function to update feeRate
.
In buy
and sell
functions consider validating that the length
of all input arrays are equal (tokenIds
& tokenWeights
).
Consider adding a check in PrivatePool.sumWeightsAndValidateProof
function to validate that every element of tokenWeights
array is greater than or equal to 1e18
.
function sumWeightsAndValidateProof( uint256[] memory tokenIds, uint256[] memory tokenWeights, MerkleMultiProof memory proof ) public view returns (uint256) { // ... for (uint256 i = 0; i < tokenIds.length; i++) { require(tokenWeights[i] >= 1e18); <------------ // create the leaf for the merkle proof leafs[i] = keccak256(bytes.concat(keccak256(abi.encode(tokenIds[i], tokenWeights[i])))); // sum each token weight sum += tokenWeights[i]; } // ... }
In the PrivatePoolMetadata.tokenURI
function consider using Strings.toHexString(address(tokenId)))
for the name
field.
function tokenURI(uint256 tokenId) public view returns (string memory) { // forgefmt: disable-next-item bytes memory metadata = abi.encodePacked( "{", '"name": "Private Pool ',Strings.toString(tokenId),'",', '"description": "Caviar private pool AMM position.",', '"image": ','"data:image/svg+xml;base64,', Base64.encode(svg(tokenId)),'",', '"attributes": [', attributes(tokenId), "]", "}" ); return string(abi.encodePacked("data:application/json;base64,", Base64.encode(metadata))); }
#0 - GalloDaSballo
2023-05-01T08:24:25Z
The Factory.create
function is susceptible to re-entrancy as it performs a _safeMint
before initializing the pool.L
The Factory.create
function performs plain transfer of funds instead of calling the deposit
function. This way the Deposit
event is not emitted.
R
There is no fee cap on the Factory.setProtocolFeeRate
function. A value greater then 10000 can break the fee calculations in private pool. Consider validating that the input is less than 10000.
L
Factory.tokenId
does not validate the input id
parameter. Consider validating that the id
exist and the respective pool is created by the factory.
L
Once initialization is done the PrivatePool.feeRate
variable can never be changed. Consider adding an owner
restricted function to update feeRate
.
R
In buy
and sell
functions consider validating that the length
of all input arrays are equal (tokenIds
& tokenWeights
).
R
Consider adding a check in PrivatePool.sumWeightsAndValidateProof
function to validate that every element of tokenWeights
array is greater than or equal to 1e18
.
L
In the PrivatePoolMetadata.tokenURI
function consider using Strings.toHexString(address(tokenId)))
for the name
field.
NC
#1 - GalloDaSballo
2023-05-01T08:25:38Z
6L from dups
4L 3R 1NC
#2 - GalloDaSballo
2023-05-01T08:25:40Z
10L
#3 - c4-judge
2023-05-01T09:16:14Z
GalloDaSballo marked the issue as grade-a
#4 - c4-judge
2023-05-01T09:22:42Z
GalloDaSballo marked the issue as selected for report
#5 - GalloDaSballo
2023-05-01T09:22:49Z
Awarding best due to consistent high quality