Platform: Code4rena
Start Date: 06/12/2022
Pot Size: $36,500 USDC
Total HM: 16
Participants: 119
Period: 3 days
Judge: berndartmueller
Total Solo HM: 2
Id: 189
League: ETH
Rank: 25/119
Findings: 5
Award: $146.80
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: 0x446576, 0xA5DF, 0xDave, 0xDecorativePineapple, 0xRobocop, 0xbepresent, 8olidity, Aymen0909, Ch_301, Chom, Franfran, HollaDieWaldfee, Madalad, Parth, Ruhum, Tricko, bin2chen, carrotsmuggler, chaduke, danyams, evan, gz627, hansfriese, hihen, imare, immeas, jadezti, jayphbee, jonatascm, kaliberpoziomka8552, kiki_dev, kree-dotcom, ladboy233, lukris02, lumoswiz, mahdikarimi, minhquanym, minhtrng, nameruse, neumo, obront, pauliax, poirots, reassor, rvierdiiev, slvDev, sorrynotsorry, yixxas, zapaz
0.8413 USDC - $0.84
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L124
The impact is that the contract will be on Denial Of Service, not allowing anyone to get ether out, neither the creator or people via refund()
.
The functions buy(uint256 _amount)
and refund()
of the LPDA.sol contract both call the function getPrice()
:
function getPrice() public view returns (uint256) { Sale memory temp = sale; (uint256 start, uint256 end) = (temp.startTime, temp.endTime); if (block.timestamp < start) return type(uint256).max; if (temp.currentId == temp.finalId) return temp.finalPrice; uint256 timeElapsed = end > block.timestamp ? block.timestamp - start : end - start; return temp.startPrice - (temp.dropPerSecond * timeElapsed); }
We can see that if the block.timestamp is greater than the start time and we are not reached the final id, the returned value is calculated by startPrice
minus the difference of time multiplied by dropPerSecond
. The largest difference of time that we can have is end-start
. The Denial of Service can happen because solidity 0.8.0 or greater have overflows and underflows checks by the default. There is no restriction on having certain values on the parameters of startTime
, endTime
and dropPersecond
that ensure this is avoided. For example i provide the next test:
First modify the sale with the following:
function setUp() public virtual override { super.setUp(); lpdaSales = new LPDAFactory(); // set up a LPDA Sale lpdaSale = LPDA.Sale({ currentId: uint48(0), finalId: uint48(10), edition: address(edition), startPrice: uint80(uint256(1 ether)), finalPrice: uint80(uint256(0.1 ether)), dropPerSecond: uint80(uint256(0.21 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 5 days) }); }
Then i wrote this test:
function test_LPDAD_DoS_Due_To_Incorrect_Parameters() public { // make the lpda sales contract sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); //lets buy an NFT sale.buy{value: 1 ether}(1); assertEq(address(sale).balance, 1 ether); vm.warp(block.timestamp + 5 days); // try to buy vm.expectRevert(); sale.buy{value: uint256((0.9 ether + lpdaSale.dropPerSecond) * 9)}(9); // Even refunds will fail vm.warp(block.timestamp + 2 days); vm.expectRevert(); sale.refund(); }
It shows that the sale cannot continue (so it cannot end) nor people can get refunds, due to an underflow on return temp.startPrice - (temp.dropPerSecond * timeElapsed);
Manual and Tests
There is a view function already on the contract function lowest_price() that already takes into account the largest time difference that is end-start
so given a startTime
and endTime
it can tell you which is the minimum dropPerSecond
that will make an underflow. The LPDAFactory must ensure that the dropPerSecond
passed by the creator is below this minimum.
#0 - c4-judge
2022-12-11T11:36:51Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:53:34Z
berndartmueller marked the issue as satisfactory
#2 - c4-judge
2023-01-02T19:53:43Z
berndartmueller changed the severity to 3 (High Risk)
57.6274 USDC - $57.63
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPriceFactory.sol#L30 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDAFactory.sol#L30 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/OpenEditionFactory.sol#L30
A user expect that all the sales created by the sales factories (Fixed, Open and LPDA) are for Escher721 editions contracts from curated creators. They also expect what the logic of this contract is, since they are created using clones on the Escher721Factory. Both of these assumptions can be broken, and the users can be tricked to buy from a not curated edition and whose logic is arbitrary.
The 3 sales factories (Fixed Sale, Open, LPDA) include the following require:
require(IEscher721(_sale.edition).hasRole(bytes32(0x00), msg.sender), "NOT AUTHORIZED");
This is to check that the user creating the sale has the admin role of the Escher721 edition for which the sale is being created for. The problem is that there is no check that the Escher721 edition contract passed as parameter was indeed deployed by the Escher721Factory contract. Any user can create a contract with a function hasRole(bytes32, address)
that returns true and will bypass the check.
Manual
Add a mapping(address => bool) public isEditionDeployedByFactory
on the Escher721Factory so the sales factories can know if a given Escher721 edition was indeed deployed by the Escher721Factory.
#0 - c4-judge
2022-12-13T13:57:15Z
berndartmueller marked the issue as duplicate of #176
#1 - c4-judge
2023-01-03T09:54:24Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: tnevler
Also found by: 0xDecorativePineapple, 0xRobocop, 0xbepresent, Chom, Ruhum, Soosh, imare, lukris02, pashov, yellowBirdy, yixxas
28.8137 USDC - $28.81
Judge has assessed an item in Issue #268 as M risk. The relevant finding follows:
Use of selfdestruct in FixedPrice.sol and OpenEdition.sol
#0 - c4-judge
2022-12-11T18:35:12Z
berndartmueller marked the issue as duplicate of #377
#1 - berndartmueller
2023-01-03T15:33:02Z
Applying partial credit as the warden did not demonstrate a concrete impact
#2 - c4-judge
2023-01-03T15:33:07Z
berndartmueller marked the issue as partial-50
🌟 Selected for report: adriro
Also found by: 0xA5DF, 0xRobocop, AkshaySrivastav, Ch_301, HollaDieWaldfee, KingNFT, bin2chen, carrotsmuggler, cccz, gasperpre, hansfriese, hihen, jonatascm, lumoswiz, neumo
28.357 USDC - $28.36
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L81
The ether earned by the creator and the fees for the platform can get frozen.
The only way the creator can get out the ether earned by the sale (and also the platform to get the feest) is by the next code snippet from the buy funtion at the LPDA.sol contract:
if (newId == temp.finalId) { sale.finalPrice = uint80(price); uint256 totalSale = price * amountSold; uint256 fee = totalSale / 20; ISaleFactory(factory).feeReceiver().transfer(fee); temp.saleReceiver.transfer(totalSale - fee); _end(); }
The conditional is that the final id token has been sold. The Denial of Service can happen if by other ways the collection mints a token id that overlaps with the range of {currentId,finalId}
of the LPDA sale. I prodive a test to show this behavior:
function test_LPDAD_DoS_Due_To_Minting_Overlap() public { // make the lpda sales contract sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); //lets buy an NFT sale.buy{value: 1 ether}(1); assertEq(address(sale).balance, 1 ether); vm.warp(block.timestamp + 1 days); // token id: 2 overlaps with the range of 1-10 of the LPDA sale. edition.mint(address(sale), 2); // try to buy vm.expectRevert('ERC721: token already minted'); sale.buy{value: uint256((0.9 ether + lpdaSale.dropPerSecond) * 9)}(9); }
Manual and Tests
Use a pull payment system for the creator that can be used as a way to pull ether out without depending on selling all the range of token ids for the sale.
#0 - c4-judge
2022-12-13T13:43:38Z
berndartmueller marked the issue as duplicate of #390
#1 - c4-judge
2023-01-02T20:05:38Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0x4non, 0xA5DF, 0xNazgul, 0xRobocop, 0xfuje, Bnke0x0, HollaDieWaldfee, Lambda, RaymondFam, TomJ, _Adam, adriro, ajtra, carrotsmuggler, cccz, danyams, gasperpre, hansfriese, helios, immeas, joestakey, ladboy233, nameruse, obront, oyc_109, rvierdiiev, saian, sakshamguruji, seyni, slvDev, yixxas, zaskoh
31.1555 USDC - $31.16
At LoC 21 the function setURI(string memory _newuri)
is used to set the uri of the collection. This function will set the same uri for all the tokens. The openzeppelin documentation clearly says that is the responsibility of the client fetching the uri to replace the '/id/' part of the uri. The documentation of Escher does not say if this is the behavior they want, or they prefer a different uri for each token id in case they use arweave for example.
At LoC 27 the function addCreator(address _account)
allows only to the curators to grant an account the role of creator. In the current implementation, a curator can give himself the role of creator:
function addCreator(address _account) public onlyRole(CURATOR_ROLE) { _grantRole(CREATOR_ROLE, _account); }
Escher lacks documentation if this is the behavior they want or not.
The documentation says that a creator can call the function setTokenRoyalty()
with three parameters: id, receiver, feeNumerator. This is to set the royalty information for the given id. But the Escher721.sol only declares an external function for setDefaultRoyalty()
which sets the same royalty information for all the tokens. The ERC2981 contract inherited from openzeppelin only contains an internal version of setTokenRoyalty()
, so it needs to declare an external function which invokes the internal one.
#0 - c4-judge
2023-01-04T10:44:11Z
berndartmueller marked the issue as grade-b