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: 2/59
Findings: 5
Award: $7,546.85
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: BowTiedWardens
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L312-L318 https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L212-L215
As neither lend(), nor updateLoanParams() functions verify params.oracle, the lend call can be front run by a malicious borrower with changing params.oracle to a non-market one.
The front run will be an updateLoanParams call where borrower introduces fake oracle that will post non-market constant or ever increasing NFT price. This will render void collateral protection as borrower will have it manipulated.
Setting the severity to high as that's a clear violation of the stated logic of the system, which can lead to lender's losses, that were protected by oracle reporting otherwise.
lend() -> lend() call used to accept the loan do not verify params.oracle:
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L312-L318
updateLoanParams() do allow to change any loan parameters in the LOAN_REQUESTED state:
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L212-L215
This way, when lender are to accept the terms the borrower can switch params.oracle to any pre-cooked contract reporting any manipulated non-market prices, so that there effectively will be no mark-to-market collaterization and borrower can go on with not enough collaterized loan, which isn't what lender will expect, for example accepting a lower interest rate because asset price is reported and liquidation in a bad scenario is possible.
As the deals are OTC, the lender has to verify Oracle manually and in order to avoid the front running the simplest solution is to add params.oracle to the verification list in _lend():
require( params.oracle == accepted.oracle && params.valuation == accepted.valuation && ...
Another solution is to forbid changing oracle in updateLoanParams, but it looks to be overly restrictive.
Notice that best practice here is an introduction of oracle white list, so only verified oracles can be used for price discovery. Namely, given that collateral NFT contract is fixed within each NFTPairWithOracle, it is advised to have a short list of verified oracles for that NFT and allow nothing else.
#0 - cryptolyndon
2022-05-06T01:21:48Z
Confirmed. Duplicate of #136
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L287
The system can use stale or even plainly incorrect (due to any technical malfunction) price for decision making.
For example, a malicious lender can setup a bot that tracks incorrect readings (i.e. track the state of the Oracle used and act on observing (false, wrong_price) reply whenever wrong_price is lucrative enough), and end up seizing the collateral with removeCollateral(), while the position is healthy.
Setting the severity to high as that's a clear violation of the logic of the system and asset loss for the borrower.
INFTOracle have success state and rate returned in get():
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/interfaces/INFTOracle.sol#L8
However, the state is ignored in NFTPairWithOracle both usages, the call is (, uint256 rate) = loanParams.oracle.get(address(this), tokenId)
:
In removeCollateral:
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L287
And in _lend:
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L321
As lend can be in borrower favor only, while is called by a lender, there looks to be no malicious usage.
However, removeCollateral is called by a lender and there Oracle malfunction is in her favor, so it is economically viable to her to act on wrong Oracle reading that is passed to the system as a result of ignoring Oracle state.
Notice that it is not Oracle malfunction, but improper Oracle usage situation.
Consider requiring get() success in order to use the Oracle reading:
(bool res, uint256 rate) = loanParams.oracle.get(address(this), tokenId); require(res, "Non-valid price");
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L287
#0 - 0xm3rlin
2022-05-04T14:07:18Z
#1 - 0xean
2022-05-20T22:50:09Z
dupe of #21
🌟 Selected for report: 0x1337
Also found by: BowTiedWardens, GimelSec, catchup, cccz, gzeon, horsefacts, hyh, kenzo
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L205-L211
As updateLoanParams() function do not verify params.oracle, a lender for an already outstanding loan can change params.oracle to a non-market one.
For example, the lender can set oracle to a pre-cooked INFTOracle contract reporting 0 price of the asset and immediately seize it with removeCollateral().
Setting the severity to high as that's a violation of the logic of the system and asset loss for the borrower.
updateLoanParams do not require that params.oracle remains the same:
if (loan.status == LOAN_OUTSTANDING) { // The lender can change terms so long as the changes are strictly // the same or better for the borrower: require(msg.sender == loan.lender, "NFTPair: not the lender"); TokenLoanParams memory cur = tokenLoanParams[tokenId]; require( params.duration >= cur.duration && params.valuation <= cur.valuation && params.annualInterestBPS <= cur.annualInterestBPS && params.ltvBPS <= cur.ltvBPS, "NFTPair: worse params" );
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPairWithOracle.sol#L205-L211
In the same time removeCollateral allows for collateral retrieval anytime as long as Oracle reports low enough price:
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"); }
This way the lender can change the oracle and seize the asset anytime.
The most straightforward solution is to add params.oracle to the verification list in updateLoanParams() for LOAN_OUTSTANDING state:
require( params.oracle == cur.oracle && params.duration >= cur.duration && ...
#0 - cryptolyndon
2022-05-06T04:12:21Z
Duplicate of #37
2510.6083 MIM - $2,510.61
Lender can accept overly restrictive LTV (the lowest possible at the moment), with high enough probability being able to seize the collateral after a short time.
Lender can set ltvBPS to zero with and immediately liquidate with removeCollateral any loan no matter how healthy it is.
In updateLoanParams a lender can set lower LTV for an already outstanding loan than borrowed initiated:
params.ltvBPS <= cur.ltvBPS,
In _lend a lender can accept lower LTV than borrowed anticipates:
params.ltvBPS >= accepted.ltvBPS,
In fact she can accept boundary LTV, equal to (params.valuation * BPS) / rate
, so that the opening condition is met with equality:
require(rate.mul(uint256(params.ltvBPS)) / BPS >= params.valuation, "Oracle: price too low.");
In the same time TVL has 'an accepted part of collateral valuation to cover the total obligations' meaning (i.e. 1 - haircut
), so the bigger part accepted the better, the bigger LTV parameter the better.
Say if LTV parameter is zero no value from collateral is accepted to cover the loan, so it is always underwater and liquidation condition is always true:
require(rate.mul(loanParams.ltvBPS) / BPS < amount, "NFT is still valued");
That is, a lender can updateLoanParams for any healthy loan setting ltvBPS to zero and immediately liquidating it with removeCollateral.
Inverse the requirements for params.ltvBPS: the bigger the better.
#0 - 0xean
2022-05-20T23:27:37Z
duplicate of #55
🌟 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
124.2387 MIM - $124.24
Signature checks in takeCollateralAndLend and requestAndBorrow in NFTPair/NFTPairWithOracle can be passed with ecrecover returning 0x0, executing _requestLoan and _lend with zero borrower and lender correspondingly. It will lead to a low level error on transfer from zero address (given that skim is false) in both cases.
However, the ability to enter _requestLoan and _lend with unintended parameters should't exist and error reporting should generally be as transparent as possible, so relying on low level exceptions aren't advised.
As that's best practice with a small chance of unveiling of yet unseen attack surface (for example, can BentoBox be manipulated to have non-zero 0x0 balance?), setting the severity to be low.
takeCollateralAndLend doesn't check the ecrecover result to be meaningful:
require(ecrecover(_getDigest(dataHash), v, r, s) == borrower, "NFTPair: signature invalid"); _requestLoan(borrower, tokenId, params, borrower, false); _lend(msg.sender, tokenId, params, skimFunds);
Same for requestAndBorrow:
require(ecrecover(_getDigest(dataHash), v, r, s) == lender, "NFTPair: signature invalid"); } _requestLoan(msg.sender, tokenId, params, recipient, skimCollateral); _lend(lender, tokenId, params, false);
In the same time, there is a way to have ecrecover deterministically return zero (which means the function didn't found any answer, so returning the initial empty value):
https://ethereum.stackexchange.com/questions/69328/how-to-get-the-zero-address-from-ecrecover
Consider adding to require(<user> != address(0), "Address cannot be zero")
to takeCollateralAndLend and requestAndBorrow in NFTPair and NFTPairWithOracle (<user> is borrower and lender correspondingly).
This will also unify the approach with BentoBox:
Event will be issued twice, one with arbitrary incorrect parameters supplied by a malicious borrower, the another with correct set of parameters subsequently used.
This is a griefing case as there looks to be no immediate economic benefit for an attacker, however system behaviour is altered in a wrong way.
As events are used for system analysis and some programmatic execution can be based on them, that looks to be a low severity issue.
requestLoan can be reentered:
_requestLoan can be called twice, first time with skim == false
, then with skim == true
:
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;
The second call can be hooked by collateral.transferFrom as ERC721 transfer do have callbacks, for example:
The outcome to be double event emission, with first time event having arbitrary incorrect parameters.
Consider adding nonReentrant modifier to requestLoan (and other skim using user-facing methods).
Collateral claiming access control requirements differ for lender and borrower in removeCollateral, which increases the difficulty of usage of the system and makes it little more error prone.
lender is required to be to
:
require(to == loan.lender, "NFTPair: not the lender");
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPair.sol#L255
While borrower has to be msg.sender
:
require(msg.sender == loan.borrower, "NFTPair: not the borrower");
https://github.com/code-423n4/2022-04-abranft/blob/main/contracts/NFTPair.sol#L251
This way the logic isn't unified, while collateral retrieval action is the same.
Consider unifying the approach, for example require both lender and borrower to be msg.sender
, providing any to
address.
On calling with arrays of different lengths various malfunctions are possible as the arrays are used as given. System then will fail with low level array access message.
cook operate the arrays without validation:
function cook( uint8[] calldata actions, uint256[] calldata values, bytes[] calldata datas ) external payable returns (uint256 value1, uint256 value2) { for (uint256 i = 0; i < actions.length; i++) {
Require that lengths of actions, values and datas arrays match.
If there be any emergency with system contracts or outside infrastructure, there is no way to temporary stop the operations.
Most core user-facing contracts do not have pausing functionality for new operation initiation.
For example, NFTPair and NFTPairWithOracle user-facing requestLoan, lend and cook functions cannot be temporary paused:
requestLoan:
lend:
cook:
Consider making all new actions linked user facing functions pausable, first of all ones without access controls.
For example, by using OpenZeppelin's approach:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/Pausable.sol
#0 - cryptolyndon
2022-05-13T05:47:27Z
Seen, thanks. Reentrancy required malicious/bad collateral. Only one mentioning potential pausability so far