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: 31/119
Findings: 5
Award: $108.87
🌟 Selected for report: 1
🚀 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#L124 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#L63 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L58
Unexpected Overflow and underflow arithmetic operation operation can revert transaction and DOS the contract
Unexpected Overflow and underflow arithmetic operation operation can revert transaction and DOS the contract
There is one very problematic underflow in LPDA.sol
/// @notice the price of the sale 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); }
note the line:
temp.startPrice - (temp.dropPerSecond * timeElapsed);
if the seller set the startPrice to 1 ETH, and dropPerSecond 0.01 ETH and timeElapsed is 200 second
If the first 100 seconds, the getPrice can return a value because 1 - 100 * 0.01 > 0, User can buy using the fund.
then after 100 seconds, the getPrice revert in underflow because 101 * 0.01 > 100,
Then getPrice revert, which also revert buy and refund because these two function both use getPrice()
/// @notice allow a buyer to get a refund on the current price difference function refund() public { Receipt memory r = receipts[msg.sender]; uint80 price = uint80(getPrice()) * r.amount; uint80 owed = r.balance - price; require(owed > 0, "NOTHING TO REFUND"); receipts[msg.sender].balance = price; payable(msg.sender).transfer(owed); }
and
function buy(uint256 _amount) external payable { uint48 amount = uint48(_amount); Sale memory temp = sale; IEscher721 nft = IEscher721(temp.edition); require(block.timestamp >= temp.startTime, "TOO SOON"); uint256 price = getPrice();
the issue is the seller may not aware of this potential underflow error and think the setting is find and the user can buy for a while as long as temp.dropPerSecond * timeElapsed > temp.startPrice then suddenly underflow happens and the contract is DOSed.
In terms of the overflow:
/// @notice allow a buyer to get a refund on the current price difference function refund() public { Receipt memory r = receipts[msg.sender]; uint80 price = uint80(getPrice()) * r.amount; uint80 owed = r.balance - price; require(owed > 0, "NOTHING TO REFUND"); receipts[msg.sender].balance = price; payable(msg.sender).transfer(owed); }
note the line:
uint80 price = uint80(getPrice()) * r.amount;
a uint256 price can be downcasted to uint80, but max uint80 * a value clearly overflow the uint80 price,
if we run test in LPDA.t.sol
function test_price_conversion() public { uint48 amount = 10; uint80 price = type(uint80).max * amount; console.log(price); }
We run:
forge test -vv --match test_price_conversion
We are getting:
Failing tests: Encountered 1 failing test in test/LPDA.t.sol:LPDATest [FAIL. Reason: Arithmetic over/underflow] test_price_conversion() (gas: 384) Encountered a total of 1 failing tests, 0 tests succeeded
Manual Review
We recommend the project handle arithemic operation carefully:
First, I think it is easier to just use uint256, then when substrating, for example a - b, add sanity check to make sure a > b.
#0 - c4-judge
2022-12-11T11:38:39Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:55:40Z
berndartmueller marked the issue as satisfactory
#2 - c4-judge
2023-01-02T19:55:45Z
berndartmueller changed the severity to 3 (High Risk)
🌟 Selected for report: RaymondFam
Also found by: 0xdeadbeef0x, 0xhacksmithh, AkshaySrivastav, Awesome, Bnke0x0, CRYP70, HollaDieWaldfee, JC, Parth, Rahoz, Tutturu, __141345__, ahmedov, ajtra, asgeir, aviggiano, bin2chen, btk, carrotsmuggler, cccz, chaduke, cryptonue, dic0de, fatherOfBlocks, fs0c, hansfriese, jonatascm, karanctf, ladboy233, lumoswiz, martin, obront, pashov, pauliax, rvierdiiev, shark, simon135, supernova, tourist, yellowBirdy, zapaz, zaskoh
0.6136 USDC - $0.61
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPriceFactory.sol#L12 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L109 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L85 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDAFactory.sol#L12 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEdition.sol#L92 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/OpenEditionFactory.sol#L12
Use payable(address).to to send ETH instead of payable(address).transfer
In the current implemetation in FixedPrice.sol, OpenEdition.sol and LPDA.sol,
after the auction is completed, 5% of the fee is sent to the fee receiver sent in the FixedPriceFactory.sol / OpenEditionFactory.sol and LPDAFactory.sol
Let us use FixedPriceFactory.sol as an example (the OpenEdition.sol and LPDA.sol has the same issue.)
contract FixedPriceFactory is Ownable { using Clones for address; address payable public feeReceiver;
and the feeReceiver can be set in the constructor
constructor() Ownable() { implementation = address(new FixedPrice()); feeReceiver = payable(msg.sender); }
or via the privileged function:
/// @notice set the fee receiver for fixed price editions /// @param fees the address to receive fees function setFeeReceiver(address payable fees) public onlyOwner { feeReceiver = fees; }
then after the auction is completed, the fee receiver received the fee via:
ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20);
which equals to:
payable(address).transfer
https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
transfer use a hardcoded gas limit 2300, but the gas cost can and will change and can break the contract.
Manual Review
Use (bool sent,memory data) = _to.call{value: msg.value}("") instead of the call and use reentrancy guard with avoid reentrancy.
#0 - c4-judge
2022-12-10T12:05:09Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:49:22Z
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#L14 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/FixedPrice.sol#L73 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L81
Lack of endtime in FixedPrice.sol sale setting, if the item never sold out, the ETH is locked in the contract
The expected business requirement is that after the sales finish, 5% of the sales goes to the fee receiver, the rest goes to the Saler.
In the current implemetation in FixedPrice.sol, if all item sold out, the above ETH payment mechanism is triggered.
How do we know it is sold out in the code?
The start nft id and final id is set in the sale setting:
struct Sale { // slot 1 uint48 currentId; uint48 finalId; address edition; // slot 2 uint96 price; address payable saleReceiver; // slot 3 uint96 startTime; }
If the newId == the final id, the _end function is triggered, which forward 5% of the fee to fee receiver and the rest ETH to salers.
function buy(uint256 _amount) external payable { Sale memory sale_ = sale; IEscher721 nft = IEscher721(sale_.edition); require(block.timestamp >= sale_.startTime, "TOO SOON"); require(_amount * sale_.price == msg.value, "WRONG PRICE"); uint48 newId = uint48(_amount) + sale_.currentId; require(newId <= sale_.finalId, "TOO MANY"); for (uint48 x = sale_.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, _amount, msg.value, sale); if (newId == sale_.finalId) _end(sale); }
the code in the _end is:
function _end(Sale memory _sale) internal { emit End(_sale); ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20); selfdestruct(_sale.saleReceiver); }
In we look into the setting in LPDA.sol and OpenEdition.sol
For LPDA.sol, the sale setting is
struct Sale { // slot 1 uint48 currentId; uint48 finalId; address edition; // slot 2 uint80 startPrice; uint80 finalPrice; uint80 dropPerSecond; // slot 3 uint96 endTime; address payable saleReceiver; // slot 4 uint96 startTime; }
For OpenEdition, the setting is:
struct Sale { // slot 1 uint72 price; uint24 currentId; address edition; // slot 2 uint96 startTime; address payable saleReceiver; // slot 3 uint96 endTime; }
In OpenEdition implementation, if the block.timestamp pass the endTime, the _end can be called to transfer the ETH out by calling finalize.
however, because of the Lack of endtime in FixedPrice.sol sale setting, if the item never sold out, the ETH is locked in the contract.
For example, saler set start id to 0 and end id to 10 and the price is 1 ETH.
5 People bought the NFT, 5 ETH is in the contract, but no one is willing to buy now, then the 5 ETH is locked in the FixedPrice.sol contract and neither fee receiver nor the saler is capable of getting this 5 ETH sales revenue.
This issue exists in LPDA.sol as well, although the sale setting as end time, the code requires the item to be sold in order to transfer the sell reveune out, which can lock the ETH in the LPDA.sol
emit Buy(msg.sender, amount, msg.value, temp); 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(); }
Manual Review
We recommend the project also endTime in the FixedPrice.sol setting to make sure after the endTime, whether the item is sold out or not, the saler can receive the sales revenue to avoid fund lock.
#0 - c4-judge
2022-12-12T09:00:53Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:22:37Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: 0x1f8b, Matin, UniversalCrypto, gzeon, karanctf, minhquanym, obront, rvierdiiev, seyni, slvDev, yixxas
74.9156 USDC - $74.92
https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L71 https://github.com/code-423n4/2022-12-escher/blob/5d8be6aa0e8634fdb2f328b99076b0d05fefab73/src/minters/LPDA.sol#L82 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/FixedPrice.sol#L62
Unsafe downcasting operation transcate user's input.
There are a few unsafe downcasting operation that transcate user's input. The impact can be severe or minial.
In FixedPrice.sol,
/// @notice buy from a fixed price sale after the sale starts /// @param _amount the amount of editions to buy function buy(uint256 _amount) external payable { Sale memory sale_ = sale; IEscher721 nft = IEscher721(sale_.edition); require(block.timestamp >= sale_.startTime, "TOO SOON"); require(_amount * sale_.price == msg.value, "WRONG PRICE"); uint48 newId = uint48(_amount) + sale_.currentId; require(newId <= sale_.finalId, "TOO MANY"); for (uint48 x = sale_.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, _amount, msg.value, sale); if (newId == sale_.finalId) _end(sale); }
the amount is unsafely downcasted from uint256 to uint48, note the code:
require(_amount * sale_.price == msg.value, "WRONG PRICE"); uint48 newId = uint48(_amount) + sale_.currentId;
the upper limit for uint48 is 281474976710655,
if user wants to buy more than 281474976710655 amount of nft and pay the 281474976710655 * sale price amount, the user can only receive 281474976710655 amount of nft because of the downcasting.
In LPDA.sol, we are unsafely downcasting the price in the buy function
receipts[msg.sender].amount += amount; receipts[msg.sender].balance += uint80(msg.value); for (uint256 x = temp.currentId + 1; x <= newId; x++) { nft.mint(msg.sender, x); } sale.currentId = newId; emit Buy(msg.sender, amount, msg.value, temp); 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(); }
note the line:
uint80(msg.value) and uint80(price)
In LPDA.sol, same issue exists in the refund function:
/// @notice allow a buyer to get a refund on the current price difference function refund() public { Receipt memory r = receipts[msg.sender]; uint80 price = uint80(getPrice()) * r.amount; uint80 owed = r.balance - price; require(owed > 0, "NOTHING TO REFUND"); receipts[msg.sender].balance = price; payable(msg.sender).transfer(owed); }
note the downcasting: uint80(getPrice())
this means if the price goes above uint80, it will be wrongly trancated to uint80 for price.
The Downcasting in LPDA.sol is damaging because it tracunated user's fund.
Below is the POC:
add test in LPDA.t.sol
function test_LPDA_downcasting_POC() 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 uint256 val = uint256(type(uint80).max) + 10 ether; console.log('msg.value'); console.log(val); sale.buy{value: val}(1); }
and import "forge-std/console.sol" in LPDA.sol
import "forge-std/console.sol";
and add console.log in LPDA.sol buy function.
receipts[msg.sender].amount += amount; receipts[msg.sender].balance += uint80(msg.value); console.log("truncated value"); console.log(receipts[msg.sender].balance);
We run our test:
forge test -vv --match test_LPDA_downcasting_POC
the output is:
Running 1 test for test/LPDA.t.sol:LPDATest [PASS] test_LPDA_downcasting_POC() (gas: 385619) Logs: msg.value 1208935819614629174706175 truncated value 9999999999999999999 Test result: ok. 1 passed; 0 failed; finished in 3.61ms
as we can see, user uses 1208935819614629174706175 to buy in LPDA.sol but the balance is truncated to 9999999999999999999, later user is not able to get the refund they are entitled to because the msg.value is unsafely downcasted.
Also note the downcasting for getPrice in LPDA.sol is also a issue:
the getPrice in LPDA.sol returns a uint256
/// @notice the price of the sale 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); }
but this is downcasted into uint80 in function buy and refund.
uint80 price = uint80(getPrice()) * r.amount;
Manual Review
We recommend the project handle downcasting and use safe casting library to make sure the downcast does not unexpected truncate value.
https://docs.openzeppelin.com/contracts/3.x/api/utils#SafeCast
#0 - c4-judge
2022-12-10T17:06:51Z
berndartmueller marked the issue as primary issue
#1 - c4-sponsor
2022-12-22T22:01:59Z
stevennevins marked the issue as disagree with severity
#2 - c4-sponsor
2022-12-22T22:02:16Z
stevennevins marked the issue as sponsor confirmed
#3 - stevennevins
2022-12-22T22:02:45Z
Scenarios seem pretty unlikely but we should just handle these truncations better
#4 - berndartmueller
2023-01-03T13:48:49Z
While very unlikely, it could result in a loss of funds for the user. Therefore, I consider Medium severity to be appropriate.
#5 - c4-judge
2023-01-03T13:58:16Z
berndartmueller marked the issue as selected for report
🌟 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
All file
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17;
We recommend the project use the fix version instead of fixing version.
When setting the fee receiver, if the owner set the fee receiver to address(0), the 5% of the fee is lost.
/// @notice set the fee receiver for fixed price editions /// @param fees the address to receive fees function setFeeReceiver(address payable fees) public onlyOwner { feeReceiver = fees; }
The factory contract inherits ownable from openzepplein.
contract FixedPriceFactory is Ownable {
In the Ownable, the owenr is transferred by calling
function transferOwnership(address newOwner) public virtual onlyOwner { require(newOwner != address(0), "Ownable: new owner is the zero address"); _transferOwnership(newOwner); }
but we are not sure if the new owner is able to claiming the onlyOwner related function.
We recommend the project implement two step transfer to let the new owner claim the ownership.
/// @notice set the fee receiver for fixed price editions /// @param fees the address to receive fees function setFeeReceiver(address payable fees) public onlyOwner { feeReceiver = fees; }
changing fee receiver changes smart contract states and update the fee receiver address, which should emit event for indexer to capture the state change.
The Escher721 inherits from ERC2981Upgradeable.
contract Escher721 is Initializable, ERC721Upgradeable, AccessControlUpgradeable, ERC2981Upgradeable {
then the Escher721 should call __ERC2981_init() in initialize function.
function initialize( address _creator, address _uri, string memory _name, string memory _symbol ) public initializer { __ERC721_init(_name, _symbol); __AccessControl_init(); tokenUriDelegate = _uri; _grantRole(DEFAULT_ADMIN_ROLE, _creator); _grantRole(MINTER_ROLE, _creator); _grantRole(URI_SETTER_ROLE, _creator); }
OpenEdition.sol, LPDA.sol and FixedPrice.sol inherits from OwnableUpgradeable,
then the contract should call in the initializer function to make sure the owner is properly set.
__Ownable_init()
When creating sales using FixedPrice.sol and OpenEdition.sol and LPDA.sol
Using needs to set the start time and end time
struct Sale { // slot 1 uint72 price; uint24 currentId; address edition; // slot 2 uint96 startTime; address payable saleReceiver; // slot 3 uint96 endTime; }
The code should check if the block.timestamp < startTime < endTime.
start time and end time should not be in the past and start time should be earlier than endTime, otherwise, the buy function would not work properly.
#0 - c4-judge
2023-01-04T10:40:57Z
berndartmueller marked the issue as grade-b