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: 38/119
Findings: 4
Award: $90.97
🌟 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/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L63 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L101 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L124
All ether sent to an LPDA contract can become stuck due to an arithmetic underflow error. This error stems from getPrice( ). Specifically, this function will throw an error if sale.startPrice < ((sale.endTime - sale.startTime) * sale.dropPerSecond) and a sufficcieint amount of time has passed.
function getPrice() public view returns (uint256) { Sale memory temp = sale; (uint256 start, uint256 end) = (temp.startTime, temp.endTime); ... uint256 timeElapsed = end > block.timestamp ? block.timestamp - start : end - start; return temp.startPrice - (temp.dropPerSecond * timeElapsed); }
Since both refund( ) and buy( ) both call getPrice( ), both functions will throw errors and will not execute.
function buy(uint256 _amount) external payable { ... if (block.timestamp < temp.startTime) revert TooSoon(); uint256 price = getPrice(); ... } function refund() public { Receipt memory r = receipts[msg.sender]; uint80 price = uint80(getPrice()) * r.amount; ... }
sale.startPrice < ((sale.endTime - sale.startTime) * sale.dropPerSecond)
Here is the proof of concept from the given test suite:
--- a/test/LPDA.t.sol +++ b/test/LPDA.t.sol @@ -22,7 +22,7 @@ contract LPDABase is EscherTest { dropPerSecond: uint80(uint256(0.1 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), - endTime: uint96(block.timestamp + 1 days) + endTime: uint96(block.timestamp + 10 days + 1) }); } } @@ -70,9 +70,10 @@ contract LPDATest is LPDABase { sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); // authorize the lpda sale to mint tokens edition.grantRole(edition.MINTER_ROLE(), address(sale)); + vm.warp(lpdaSale.endTime); + vm.expectRevert(stdError.arithmeticError); //lets buy an NFT sale.buy{value: 1 ether}(1); - assertEq(address(sale).balance, 1 ether); } function test_RevertsWhenTooSoon_Buy() public { @@ -136,9 +137,15 @@ contract LPDATest is LPDABase { } function test_Refund() public { - test_Buy(); + 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(lpdaSale.endTime + 1); + vm.warp(lpdaSale.endTime); + vm.expectRevert(stdError.arithmeticError); sale.refund(); }
Foundry
In place of attempting to do subtraction, getPrice( ) should return 0 if temp.dropPerSecond * timeElapsed > temp.startPrice.
--- a/src/minters/LPDA.sol +++ b/src/minters/LPDA.sol @@ -121,25 +131,26 if (temp.currentId == temp.finalId) return temp.finalPrice; uint256 timeElapsed = end > block.timestamp ? block.timestamp - start : end - start; - return temp.startPrice - (temp.dropPerSecond * timeElapsed); + return temp.dropPerSecond * timeElapsed > temp.startPrice ? 0 : temp.startPrice - (temp.dropPerSecond * timeElapsed); }
Alternatively, a require statement can be added in createLPDASale, so that these types of sales are not possible.
--- a/src/minters/LPDAFactory.sol +++ b/src/minters/LPDAFactory.sol @@ -34,6 +34,7 @@ contract LPDAFactory is Ownable { require(sale.finalId > sale.currentId, "INVALID FINAL ID"); require(sale.startPrice > 0, "INVALID START PRICE"); require(sale.dropPerSecond > 0, "INVALID DROP PER SECOND"); + require(sale.startPrice >= (sale.endTime - sale.startTime) * sale.dropPerSecond, "INVALID PARAMS"); clone = implementation.clone(); LPDA(clone).initialize(sale);
#0 - c4-judge
2022-12-11T11:38:18Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:55:02Z
berndartmueller marked the issue as satisfactory
57.6274 USDC - $57.63
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPriceFactory.sol#L30 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEditionFactory.sol#L30 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDAFactory.sol#L30
Anyone can create an open edition, fixed price, or LDSA sale without being a creator. The msg.sender only needs to provide a create a contract that returns true when hasRole(bytes32, address) is queried.
Considering that no contract keeps track of active sales, sales are likely being tracked through events. This means anyone could feign an Escher721 sale on Escher's frontend depending on the implementation.
Create a contract that contains an external function, hasRole(bytes32, address), that always returns true
Call createFixedSale( ), createLPDASale( ), or createOpenEdition( ) from FixedPriceFactory, LPDAFactory, or OpenEditionFactory, respectively, with a valid sale and the aforementioned contract as the sale's edition
import "forge-std/Test.sol"; import {LPDAFactory, LPDA} from "src/minters/LPDAFactory.sol"; import {OpenEditionFactory, OpenEdition} from "src/minters/OpenEditionFactory.sol"; import {FixedPriceFactory, FixedPrice} from "src/minters/FixedPriceFactory.sol"; contract ExploiterContract { function hasRole(bytes32, address) external view returns(bool) { return true; } } contract CreateSaleBugs is Test { function testCreateInvalidFixedPriceSale() public { FixedPriceFactory _factory = new FixedPriceFactory(); address _exploiter = address(new ExploiterContract()); FixedPrice.Sale memory _validSale = FixedPrice.Sale({ currentId: uint48(0), finalId: uint48(1), edition: _exploiter, price: uint96(0), saleReceiver: payable(address(1)), startTime: uint96(block.timestamp) }); _factory.createFixedSale(_validSale); } function testCreateInvalidLPDASale() public { LPDAFactory _factory = new LPDAFactory(); address _exploiter = address(new ExploiterContract()); LPDA.Sale memory _validSale = LPDA.Sale({ currentId: uint48(0), finalId: uint48(1), edition: _exploiter, startPrice: uint80(1 ether), finalPrice: uint80(0), dropPerSecond: 1, startTime: uint96(block.timestamp), saleReceiver: payable(address(1)), endTime: uint96(block.timestamp + 1 days) }); _factory.createLPDASale(_validSale); } function testCreateInvalidOpenEditionSale() public { OpenEditionFactory _factory = new OpenEditionFactory(); address _exploiter = address(new ExploiterContract()); OpenEdition.Sale memory _validSale = OpenEdition.Sale({ price: uint72(0), currentId: uint24(1), edition: _exploiter, startTime: uint96(block.timestamp), saleReceiver: payable(address(1)), endTime: uint96(block.timestamp + 1 days) }); _factory.createOpenEdition(_validSale); } }
Check if msg.sender is a creator from the Escher contract. Relying on the NFT contract, itself, for authorization will enable exploiters to create sales without being a creator.
--- a/src/minters/FixedPriceFactory.sol +++ b/src/minters/FixedPriceFactory.sol @@ -5,12 +5,14 @@ import {FixedPrice} from "./FixedPrice.sol"; import {Ownable} from "openzeppelin/access/Ownable.sol"; import {Clones} from "openzeppelin/proxy/Clones.sol"; import {IEscher721} from "../interfaces/IEscher721.sol"; +import {Escher} from "../Escher.sol"; contract FixedPriceFactory is Ownable { using Clones for address; address payable public feeReceiver; address public immutable implementation; + Escher public immutable escher; event NewFixedPriceContract( address indexed creator, @@ -19,15 +21,16 @@ contract FixedPriceFactory is Ownable { FixedPrice.Sale saleInfo ); - constructor() Ownable() { + constructor(address _escher) Ownable() { implementation = address(new FixedPrice()); feeReceiver = payable(msg.sender); + escher = Escher(_escher); } /// @notice create a fixed sale proxy contract /// @param _sale the sale info function createFixedSale(FixedPrice.Sale calldata _sale) external returns (address clone) { - require(IEscher721(_sale.edition).hasRole(bytes32(0x00), msg.sender), "NOT AUTHORIZED"); + require(escher.hasRole(escher.CREATOR_ROLE(), msg.sender), "NOT AUTHORIZED"); require(_sale.startTime >= block.timestamp, "START TIME IN PAST"); require(_sale.finalId > _sale.currentId, "FINAL ID BEFORE CURRENT");
#0 - c4-judge
2022-12-14T10:45:55Z
berndartmueller marked the issue as duplicate of #176
#1 - c4-judge
2023-01-03T09:54:29Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: AkshaySrivastav
Also found by: 0x52, 0xA5DF, 0xdeadbeef0x, KingNFT, Madalad, Parth, Soosh, _Adam, adriro, csanuragjain, danyams, eyexploit, gasperpre, gz627, gzeon, hansfriese, hihen, immeas, jadezti, jonatascm, kiki_dev, kree-dotcom, ladboy233, lukris02, lumoswiz, mahdikarimi, minhtrng, nalus, nameruse, obront, reassor, rvierdiiev, seyni, tnevler, wait, yixxas
1.3417 USDC - $1.34
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L50-L53 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L73
Funds in a fixed-price sale will become stuck if demand is overestimated. Under the current implementation, funds are only ever transferred out of the contract when the internal function, _end( ), is called. However, this function is only callable under 2 conditions: in cancel() when the sale start time is less than the current block timestamp, and in buy() when the last id of the purchased NFTs is equal to the sale's final id.
Cancel() is never callable once the sale starts. The primary vulnerability occurs when the sale's final id is never reached from overestimating demand.
Delete the require statement in cancel( ) that checks if block timestamp is less than the sale's start time.
@@ -48,7 +55,6 @@ contract FixedPrice is Initializable, OwnableUpgradeable, ISale { /// @notice Owner can cancel current sale function cancel() external onlyOwner { - require(block.timestamp < sale.startTime, "TOO LATE"); _end(sale); }
This makes it so that the owner can cancel the sale at any time and transfer the ether to both the fee receiver and sale receiver.
Alternatively, add a withdraw( ) function that transfers the fee to the fee receiver and the remaining balance to the sale receiver.
@@ -109,4 +117,10 @@ contract FixedPrice is Initializable, OwnableUpgradeable, ISale { ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20); selfdestruct(_sale.saleReceiver); } + + + function withdraw() external onlyOwner { + ISaleFactory(factory).feeReceiver().transfer(address(this).balance/20); + sale.saleReceiver.transfer(address(this).balance); + } }
#0 - c4-judge
2022-12-12T09:03:04Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:21:03Z
berndartmueller changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-01-02T20:22:52Z
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
If an LPDA sale has greater demand than expected, a user may never be refunded. This occurs since the balance of the contract will be transferred out in the buy( ) function once the sale's final id is reached.
Recommended Mitigation Steps:
A solution would be to separate the transfers into a separate function and make it callable only after the sale's end time has been reached.
Referenced Code: https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L81-L88
OpenEdition.sol has a struct Sale, that contains 2^72 for its member variable, price. 2^72 wei is roughly 4722 ether, meaning that this is the max limit that an Open Edition NFT can be sold for.
Recommended Mitigation Steps:
Change price to a uint80, which would lead to a max price of 1208925 ether.
Referenced Code: https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEdition.sol#L15
Using safeMint( ) rather than mint( ) leads to better composability and expected behavior when the caller is another contract.
It is better to use call( ) to send Ether since gas costs of opcodes are susceptible to change.
block.timestamp and block.number are used for entropy in Generative.sol. This will lead to two Generative contracts that both call setSeedBase() in the same block to have the same seedbase. An oracle is a more reliable source of entropy.
Referenced Code: https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/uris/Generative.sol#L22-L25
All casting of uints should use a safe cast library, such as Solmate's SafeCastLib. Not doing so leads to strange behavior when buy( ) is called in a FixedPrice sale.
A function parameter, _amount, is a uint256. _amount is later casted to a uint48, but this casted value is not used when emitting an event. This means that 2^48 can be passed in as an argument, which will later cast to 0 and no NFTs being purchased. However, an event will be emitted that 2^48 NFTs were bought.
Referenced Code: https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L57-L71
Numerous functions throughout the codebase have a visibility modifier of public when they should use external
There are many reference types that are passed in as function arguments do not change in value. They should be marked as calldata in place of memory. Specifically, this occurs frequently when a struct of type Sale is passed in as a function argument
OpenEdition, FixedPrice, LPDA do not need to inherit Initializable since it is already inherited in OwnableUpgradable. Escher721 also does not need to inherit Initilizable since it is already inherited in ERC721Upgradable.
Referenced Code:
tokenToSeed( ) in Generative.sol has visibility modifier internal but is not used internally
The return values in createOpenEdition( ), createLPDASale( ), and createFixedSale( ), and createContract( ) are not used. If the return value is meant to be used to keep track of the new clone's address, that information can be found in the emitted event.
Referenced Code:
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/Escher721Factory.sol#L31 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPriceFactory.sol#L29 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDAFactory.sol#L29 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEditionFactory.sol#L29
#0 - c4-judge
2023-01-04T10:36:25Z
berndartmueller marked the issue as grade-b