Platform: Code4rena
Start Date: 27/04/2022
Pot Size: $50,000 MIM
Total HM: 6
Participants: 59
Period: 5 days
Judge: 0xean
Id: 113
League: ETH
Rank: 4/59
Findings: 5
Award: $4,704.84
🌟 Selected for report: 0
🚀 Solo Findings: 0
interface INFTOracle { /// @notice Get the latest exchange rate. /// @param pair address of the NFTPair calling the oracle /// @param tokenId tokenId of the NFT in question /// @return success if no valid (recent) rate is available, return false else true. /// @return rate The rate of the requested asset / pair / pool. function get(address pair, uint256 tokenId) external returns (bool success, uint256 rate);
(, uint256 rate) = loanParams.oracle.get(address(this), tokenId);
Per the comment of the INFTOracle
, the first return (success
) of INFTOracle.get()
means:
success: if no valid (recent) rate is available, return false else true.
However, in removeCollateral
, the success
return of loanParams.oracle.get()
is ignored. Making it possible for the loan to be liquidated unfairly with an invalid or stale price.
Specifically, if the oracle returns a 0
for rate
or a stale, much lower rate
than the actual value, then the collateral NFT can be impounded by the lender immediately. Which will cause fund loss to the borrower.
Consider adding a check for success
:
(bool success, uint256 rate) = loanParams.oracle.get(address(this), tokenId); require(success);
#0 - 0xm3rlin
2022-05-04T14:05:05Z
Duplicate of #21
1045.8477 MIM - $1,045.85
require( params.duration >= cur.duration && params.valuation <= cur.valuation && params.annualInterestBPS <= cur.annualInterestBPS && params.ltvBPS <= cur.ltvBPS, "NFTPair: worse params" );
As per the comment:
The lender can change terms so long as the changes are strictly the same or better for the borrower.
However, the implementation is wrong for ltvBPS
. In the current implementation, it requires the new ltvBPS
to be lower than the existing ltvBPS
. Which is actually a worse term for the borrower.
A malicious lender can take advantage of this, and set the new ltvBPS
to a lower value or even 0
, and then call removeCollateral()
to impound the NFT collateral from the borrower.
Change to:
require( params.duration >= cur.duration && params.valuation <= cur.valuation && params.annualInterestBPS <= cur.annualInterestBPS && params.ltvBPS >= cur.ltvBPS, "NFTPair: worse params" );
#0 - cryptolyndon
2022-05-05T21:54:11Z
Confirmed. Duplicate of #51
require( params.valuation == accepted.valuation && params.duration <= accepted.duration && params.annualInterestBPS >= accepted.annualInterestBPS && params.ltvBPS >= accepted.ltvBPS, "NFTPair: bad params" );
As per the comment:
Valuation has to be an exact match, everything else must be at least as good for the lender as
accepted
.
However, the implementation is wrong for ltvBPS
. In the current implementation, it requires the new ltvBPS
to be lower than the existing ltvBPS
. Which is actually a worse term for the borrower.
A malicious borrower can take advantage of this, and frontrun the lender's lend()
tx to set ltvBPS to a higher value or even as high as near type(uint256).max
, to prevent the loan from being liquidated even when the price of the collateral drops to below the principal.
requestLoan()
for $30k with a NFT worth $1M with a ltvBPS
of 5000
(50%);lend()
$30k with accepted terms of ltvBPS
of 5000
;lend()
with updateLoanParams()
and set ltvBPS
to 1000000
(10000%).removeCollateral()
as "NFT is still valued".require(rate.mul(loanParams.ltvBPS) / BPS < amount, "NFT is still valued");
Change to:
require( params.valuation == accepted.valuation && params.duration <= accepted.duration && params.annualInterestBPS >= accepted.annualInterestBPS && params.ltvBPS <= accepted.ltvBPS, "NFTPair: bad params" );
#0 - cryptolyndon
2022-05-05T21:55:52Z
Confirmed, duplicate of #55
🌟 Selected for report: kenzo
Also found by: AuditsAreUS, Czar102, GimelSec, WatchPug
NFT is a fragmented standard, for certain non-standard ERC721 implementations, they may have built-in hooks that can be used to re-enter the contract. Just like ERC777 to ERC20.
For example, if the collateral
NFT got a pre-transfer hook to the receiver of the transfer, then it can be used to re-enter the contract and requestLoan
without depositing the collateral.
function removeCollateral(uint256 tokenId, address to) public { TokenLoan memory loan = tokenLoan[tokenId]; if (loan.status == LOAN_REQUESTED) { // We are withdrawing collateral that is not in use: require(msg.sender == loan.borrower, "NFTPair: not the borrower"); } else if (loan.status == LOAN_OUTSTANDING) { // We are seizing collateral towards the lender. The loan has to be // expired and not paid off, or underwater and not paid off: require(to == loan.lender, "NFTPair: not the lender"); if (uint256(loan.startTime) + tokenLoanParams[tokenId].duration > block.timestamp) { TokenLoanParams memory loanParams = tokenLoanParams[tokenId]; // No underflow: loan.startTime is only ever set to a block timestamp // Cast is safe: if this overflows, then all loans have expired anyway uint256 interest = calculateInterest( loanParams.valuation, uint64(block.timestamp - loan.startTime), loanParams.annualInterestBPS ).to128(); uint256 amount = loanParams.valuation + interest; (, uint256 rate) = loanParams.oracle.get(address(this), tokenId); require(rate.mul(loanParams.ltvBPS) / BPS < amount, "NFT is still valued"); } } // If there somehow is collateral but no accompanying loan, then anyone // can claim it by first requesting a loan with `skim` set to true, and // then withdrawing. So we might as well allow it here.. delete tokenLoan[tokenId]; collateral.transferFrom(address(this), to, tokenId); emit LogRemoveCollateral(tokenId, to); }
function _requestLoan( address collateralProvider, uint256 tokenId, TokenLoanParams memory params, address to, bool skim ) private { // Edge case: valuation can be zero. That effectively gifts the NFT and // is therefore a bad idea, but does not break the contract. require(tokenLoan[tokenId].status == LOAN_INITIAL, "NFTPair: loan exists"); if (skim) { require(collateral.ownerOf(tokenId) == address(this), "NFTPair: skim failed"); } else { collateral.transferFrom(collateralProvider, address(this), tokenId); } TokenLoan memory loan; loan.borrower = to; loan.status = LOAN_REQUESTED; tokenLoan[tokenId] = loan; tokenLoanParams[tokenId] = params; emit LogRequestLoan(to, tokenId, params.valuation, params.duration, params.annualInterestBPS, params.ltvBPS); }
The malicious borrower can:
requestLoan()
with a collateral tokenId: 123
;removeCollateral()
for tokenId: 123
:
collateral.transferFrom(address(this), to, tokenId);
, re-enter requestLoan()
with tokenId: 123
and skim: true
As a result, the malicious borrower has effectively created a loan without depositing the collateral NFT.
Consider adding require(collateral.ownerOf(tokenId) == address(this));
in _lend()
to make sure the collateral tokenId is owned by the contract.
#0 - cryptolyndon
2022-05-05T22:19:50Z
Not a bug; duplicate of #61.
#1 - 0xean
2022-05-20T22:33:56Z
dupe of #61 - modifying severity to match.
🌟 Selected for report: IllIllI
Also found by: 0x1337, 0x1f8b, 0xDjango, 0xf15ers, AuditsAreUS, BowTiedWardens, CertoraInc, Funen, GimelSec, MaratCerby, Ruhum, WatchPug, antonttc, berndartmueller, bobi, bobirichman, broccolirob, catchup, cccz, defsec, delfin454000, gs8nrv, gzeon, horsefacts, hubble, hyh, ilan, jah, joestakey, kebabsec, kenta, kenzo, m9800, mics, oyc_109, pauliax, reassor, robee, samruna, sikorico, simon135, throttle, unforgiven, z3s
72.3925 MIM - $72.39
collateral.transferFrom(address(this), loan.borrower, tokenId);
If loan.borrower
is a contract that does not implement the onERC721Received
method, in the current implementation of repay()
, the tx will be successful, and the NFT will be transferred.
This can be a problem if loan.borrower
can not handle ERC721 properly.
As per the documentation of EIP-721:
A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.
Ref: https://eips.ethereum.org/EIPS/eip-721
Consider using safeTransferFrom
in NFTPair.sol#repay())
:
collateral.safeTransferFrom(address(this), loan.borrower, tokenId);
#0 - cryptolyndon
2022-05-05T21:52:43Z
Duplicate of #20
Not a bug
#1 - 0xean
2022-05-21T00:46:23Z
See #20 for rationale on downgrade to QA here.
#2 - JeeberC4
2022-05-23T19:01:36Z
Preserving original title: [WP-M3] NFTPair.sol#repay() loan.borrower can be a contract with no onERC721Received method, which may cause the NFT to be frozen and put user's funds at risk