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: 76/119
Findings: 4
Award: $31.15
🌟 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 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDAFactory.sol#L33-L36
It is possible to configure the LPDA.Sale
parameters in LPDAFactory.sol
such that all current checks pass but can lead to underflow of the getPrice()
return variable in LPDA.sol
part way through the sale duration. Some implications:
buy
function, so that the transfers to the saleReceiver
and feeReceiver
cannot occur.refund
.This leads to loss of funds.
endTime
10 days from now and a dropPerSecond
of 0.2 ether / 1 days
, so that with a startPrice = 1 ether
the getPrice
function will underflow after 5 days.temp.currentId != temp.finalId
), then getPrice
will be calculated from temp.startPrice - (temp.dropPerSecond * timeElapsed)
.diff --git a/LPDA.t.sol.orig b/LPDA.t.sol index c360700..939b47d 100644 --- a/LPDA.t.sol.orig +++ b/LPDA.t.sol @@ -188,4 +188,34 @@ contract LPDATest is LPDABase { sale.refund(); assertApproxEqRel(address(this).balance, bal + 0.1 ether, lpdaSale.dropPerSecond); } -} \ No newline at end of file + + function test_underflowGetPrice() public { + // 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.2 ether) / 1 days), + startTime: uint96(block.timestamp), + saleReceiver: payable(address(69)), + endTime: uint96(block.timestamp + 10 days) + }); + + sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); + + edition.grantRole(edition.MINTER_ROLE(), address(sale)); + + sale.buy{value: 1 ether}(1); + sale.buy{value: 4 ether}(3); + + vm.warp(block.timestamp + 6 days); + + vm.expectRevert(); + sale.buy{value: 1 ether}(1); + + vm.expectRevert(); + sale.refund(); + } +}
Foundry
getPrice
function of LPDA, calculate timeElapsed
before the final check on L121. Then change this final check to: if (temp.currentId == temp.finalId || (temp.dropPerSecond * timeElapsed) >= temp.startPrice) return temp.finalPrice;
.LPDAFactory.createLPDASale
on L36 to: require(sale.dropPerSecond > 0 && sale.dropPerSecond <= (sale.startPrice - sale.finalPrice) / (sale.endTime - sale.startTime), "INVALID DROP PER SECOND" );
.#0 - c4-judge
2022-12-11T11:38:14Z
berndartmueller marked the issue as duplicate of #392
#1 - c4-judge
2023-01-02T19:55:00Z
berndartmueller marked the issue as satisfactory
🌟 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/main/src/minters/LPDA.sol#L86 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L105 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol#L109 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/OpenEdition.sol#L92
Use of transfer()
in LPDA.sol
, FixedPrice.sol
and OpenEdition.sol
. ETH may be lost if the saleReceiver
, msg.sender
or feeReceiver
are smart contracts that:
References that recommend avoiding the use of transfer()
can be found here and here.
.call{value: msg.value}("")
with checks-effects-interaction pattern and/or using a re-entrancy guard modifier.
#0 - c4-judge
2022-12-10T00:30:06Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:46:54Z
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/main/src/minters/LPDA.sol#L81-L87 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol#L73
The sale ends when the buy
function is called and the newly minted tokenId
equals the Sale.finalId
parameter set in the factory contract. If there isn't sufficient demand for a given target finalId
, then the only way for the saleReceiver
or feeReceiver
to retrieve funds will be to buy up the remaining tokens.
Potential loss of funds if finalId
and price is sufficiently high.
currentId = 0
, finalId = 1000
and price = 1 ether
, and only manage to sell 50 tokens, then there will be no way to recover the 50 ether. To unlock the funds, the remaining 950 units will need to be bought up, costing 950 ether.block.timestamp
exceeds the endTime
, enabling recovery of funds. Note: FixedPrice.sol
will require some way to determine the endTime
.#0 - c4-judge
2022-12-12T09:04:17Z
berndartmueller marked the issue as duplicate of #328
#1 - c4-judge
2023-01-02T20:22:55Z
berndartmueller marked the issue as satisfactory
🌟 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/Escher721.sol#L44 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L74 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/FixedPrice.sol#L66
The creator is granted MINTER_ROLE
in the Escher721
contract initialization. If the creator sets up a sale and, at some point throughout the sale, mints a tokenId
through the Escher721
contract such that: currentId + 1 < tokenId <= finalId
, this prevents users from being able to call the buy
function.
Results in loss of funds because:
newId == temp.finalId
(for LPDA) and newId == sale_.finalId
(for fixed price) never eventuate.buy
functions.diff --git a/LPDA.t.sol.orig b/LPDA.t.sol index c360700..8720f5c 100644 --- a/LPDA.t.sol.orig +++ b/LPDA.t.sol @@ -188,4 +188,33 @@ contract LPDATest is LPDABase { sale.refund(); assertApproxEqRel(address(this).balance, bal + 0.1 ether, lpdaSale.dropPerSecond); } -} \ No newline at end of file + + function test_creatorMintsTokenIdWithinSaleRangeDuringSale() public { + sale = LPDA(lpdaSales.createLPDASale(lpdaSale)); + + // sale granted MINTER_ROLE + edition.grantRole(edition.MINTER_ROLE(), address(sale)); + + // creator mints tokenId = 9 to this address (here the creator is also address(this)). + edition.mint(address(this), 9); + + // user buys up to (and including) tokenId = 8 + sale.buy{value: 8 ether}(8); + + uint256 saleETHBalance = address(sale).balance; + + // skip forward 1 day + vm.warp(block.timestamp + 0.5 days); + + // tokenId = 9 has already been minted + vm.expectRevert(); + sale.buy{value: 1 ether}(1); + + // user can still refund + sale.refund(); + + uint256 unrecoverableETHOwedToCreator = saleETHBalance - address(sale).balance; + + emit log_named_uint("unrecoverable ETH", unrecoverableETHOwedToCreator); + } +}
Foundry
MINTER_ROLE
in the Escher721
contract either:
_grantRole(MINTER_ROLE, creator)
from the initialize
function; orrevokeRole(MINTER_ROLE, creator)
before the sale commences.#0 - zobront
2022-12-10T02:53:59Z
This seems to pretty explicitly be the system's intended behavior.
#1 - c4-judge
2022-12-13T10:56:41Z
berndartmueller marked the issue as duplicate of #390
#2 - c4-judge
2023-01-02T20:04:47Z
berndartmueller changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-01-02T20:05:32Z
berndartmueller marked the issue as satisfactory