Solidity Security - Lesson 5: Lending/Borrowing DeFi Attacks
Author: Dacian
In Web3 DeFi, smart contracts have been used to implement a wide range of lending & borrowing platforms, where market participants can:
lend tokens to receive interest
borrow tokens to conduct other activities while paying interest
Borrowers have to provide collateral that is stored in a smart contract within the DeFi system, which can be liquidated either by the Lender or by other market participants if the Borrower does not meet repayment schedule deadlines or if the value of their collateral drops below a required threshold. This deep dive aims to categorize the types of vulnerabilities that auditors & developers should be aware of in lending & borrowing platforms.
Liquidation Before Default
Liquidation allows a Borrower's collateral to be seized and either given to the Lender as compensation or paid to a liquidator (or shared in some manner between them). Liquidation should only be possible if:
the Borrower has failed to meet their repayment schedule obligations, by being late on a scheduled repayment,
the value of the Borrower's collateral has fallen below a set threshold
If the Lender, Liquidator or another market participant can liquidate a Borrower's collateral before the Borrower is in default, this results in a critical loss of funds vulnerability for the Borrower. Consider this simplified example from Sherlock's TellerV2 audit contest:
function lastRepaidTimestamp(Loan storage loan) internal view returns (uint32) {
return
// @audit if no repayments have yet been made, lastRepaidTimestamp()
// will return acceptedTimestamp - time when loan was accepted
loan.lastRepaidTimestamp == 0
? loan.acceptedTimestamp
: loan.lastRepaidTimestamp;
}
function canLiquidateLoan(uint loanId) public returns (bool) {
Loan storage loan = loans[loanId];
// Make sure loan cannot be liquidated if it is not active
if (loan.state != LoanState.ACCEPTED) return false;
return (uint32(block.timestamp) - lastRepaidTimestamp(loan) > paymentDefaultDuration);
// @audit if no repayments have been made:
// block.timestamp - acceptedTimestamp > paymentDefaultDuration
// doesn't check paymentCycleDuration (when next payment is due)
// if paymentDefaultDuration < paymentCycleDuration, can be liquidated
// *before* first payment is due. If paymentDefaultDuration is very small,
// can be liquidated very soon after taking loan, way before first payment
// is due!
}
canLiquidateLoan() doesn't check when the next repayment is due; if the loan is new and the first repayment hasn't been made (as it won't be due for some time "paymentCycleDuration"), the Borrower can be liquidated before their first repayment is due if paymentDefaultDuration < paymentCycleDuration.
If paymentDefaultDuration is small, the Borrower could be liquidated very soon after taking the loan! The liquidation threshold paymentDefaultDuration should always be calculated as an offset from when the next repayment is due; only once the next repayment is late by paymentDefaultDuration should the Borrower be able to be liquidated. More examples: [1, 2, 3, 4, 5]
Borrower Can't Be Liquidated
Another serious vulnerability occurs if the Borrower can devise a loan offer that results in their collateral not being able to be liquidated. Examine this simplified example also from Sherlock's TellerV2 audit:
// AddressSet from https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
// a loan must have at least one collateral
// & only one amount per token is permitted
struct CollateralInfo {
EnumerableSetUpgradeable.AddressSet collateralAddresses;
// token => amount
mapping(address => uint) collateralInfo;
}
// loanId -> validated collateral info
mapping(uint => CollateralInfo) internal _loanCollaterals;
function commitCollateral(uint loanId, address token, uint amount) external {
CollateralInfo storage collateral = _loanCollaterals[loanId];
// @audit doesn't check return value of AddressSet.add()
// returns false if not added because already exists in set
collateral.collateralAddresses.add(token);
// @audit after loan offer has been created & validated, borrower can call
// commitCollateral(loanId, token, 0) to overwrite collateral record
// with 0 amount for the same token. Any lender who accepts the loan offer
// won't be protected if the borrower defaults since there's no collateral
// to lose
collateral.collateralInfo[token] = amount;
}
This code contains an unchecked return value vulnerability as the return value of AddressSet.add() is never checked; this will return false if the token is already in the set. As this isn't checked the code will continue to execute and the existing collateral token's amount can simply be overwritten with a new value, 0! More examples: [1, 2, 3, 4, 5, 6]
Debt Closed Without Repayment
Normally to get their collateral back, the Borrower has to repay the Lender their principal + interest. If the Borrower can close the debt without repaying the full amount and keep their collateral, this results in a critical loss of funds vulnerability for the Lender. Examine this code [1,2] from code4rena's DebtDAO audit:
// amount of open credit lines on a Line of Credit facility
uint256 private count;
// id -> credit line provided by a single Lender for a given token on a Line of Credit
mapping(bytes32 => Credit) public credits;
// @audit attacker calls close() with non-existent id
function close(bytes32 id) external payable override returns (bool) {
// @audit doesn't check that id exists in credits, if it doesn't
// exist an empty Credit with default values will be returned
Credit memory credit = credits[id];
address b = borrower; // gas savings
// @audit borrower attacker will pass this check
if(msg.sender != credit.lender && msg.sender != b) {
revert CallerAccessDenied();
}
// ensure all money owed is accounted for. Accrue facility fee since prinicpal was paid off
credit = _accrue(credit, id);
uint256 facilityFee = credit.interestAccrued;
if(facilityFee > 0) {
// only allow repaying interest since they are skipping repayment queue.
// If principal still owed, _close() MUST fail
LineLib.receiveTokenOrETH(credit.token, b, facilityFee);
credit = _repay(credit, id, facilityFee);
}
// @audit _closed() called with empty credit, non-existent id
_close(credit, id); // deleted; no need to save to storage
return true;
}
function _close(Credit memory credit, bytes32 id) internal virtual returns (bool) {
if(credit.principal > 0) { revert CloseFailedWithPrincipal(); }
// return the Lender's funds that are being repaid
if (credit.deposit + credit.interestRepaid > 0) {
LineLib.sendOutTokenOrETH(
credit.token,
credit.lender,
credit.deposit + credit.interestRepaid
);
}
delete credits[id]; // gas refunds
// remove from active list
ids.removePosition(id);
// @audit calling with non-existent id still decrements count, can
// keep calling close() with non-existent id until count decremented to 0
// and loan marked as repaid!
unchecked { --count; }
// If all credit lines are closed the the overall Line of Credit facility is declared 'repaid'.
if (count == 0) { _updateStatus(LineLib.STATUS.REPAID); }
emit CloseCreditPosition(id);
return true;
}
The Borrower can simply call close() with a non-existent id, and every call will end up decrementing count. Doing this until count == 0 results in the loan being marked as repaid! This is also an example of the unexpected empty inputs vulnerability, where the developer is not expecting a non-existent value to be passed so hasn't correctly handled that. More examples: [1, 2, 3]
Repayments Paused While Liquidations Enabled
Lending & Borrowing DeFi platforms should never be able to enter a state where repayments are paused but liquidations are enabled, since this would unfairly prevent Borrowers from making their repayments while still allowing them to be liquidated. If repayments can be paused then liquidations must also be paused at the same time. Examining the repay() function from BlueBerry's Sherlock audit shows that repayments can be turned on/off, but there is no similar check within liquidate().
function repay(address token, uint256 amountCall)
external
override
inExec
poke(token)
onlyWhitelistedToken(token) {
if (!isRepayAllowed()) revert REPAY_NOT_ALLOWED();
Developers of Lending & Borrowing platforms should ensure that if repayments are paused then liquidations must also be paused, and auditors should examine whether this invariant can be violated. More examples: [1]
Collateral Pause Stops Existing Repayment & Liquidation
Some Lending & Borrowing platforms allow governance to pause accepting certain types of collateral. If this also stops existing loans using that collateral from being repaid or liquidated, this can result in a critical loss of funds vulnerability for the Lender and/or the protocol.
The value of the paused collateral may dramatically fall but with liquidation being impossible the loan will become heavily under-collateralized. After the collateral is unpaused the loan will be immediately liquidated for huge losses to the Lender and/or protocol.
Hence governance pausing of collateral should only apply to new loans but existing loans using that collateral must continue to be able to be repaid and liquidated.
Liquidator Takes Collateral With Insufficient Repayment
When the Borrowers is in default, two things can happen:
Lender liquidates the Borrower by forgoing repayment of the loan and seizing the collateral,
Liquidator repays the Borrower and seizes the collateral
In the second case, advanced platforms allow a Liquidator to partially repay the Borrower's bad debt and receive a proportional amount of the collateral. If the Liquidator can take the collateral with an insufficient (or no) repayment, this represents a critical loss of funds vulnerability for the Lender. Consider this collateral share calculation from Blueberry's Sherlock audit:
function liquidate(uint256 positionId, address debtToken, uint256 amountCall)
external override lock poke(debtToken) {
// checks
if (amountCall == 0) revert ZERO_AMOUNT();
if (!isLiquidatable(positionId)) revert NOT_LIQUIDATABLE(positionId);
// @audit get position to be re-paid by liquidator, however
// borrower may have multiple debt positions
Position storage pos = positions[positionId];
Bank memory bank = banks[pos.underlyingToken];
if (pos.collToken == address(0)) revert BAD_COLLATERAL(positionId);
// @audit oldShare & share proportion of the one position being liquidated
uint256 oldShare = pos.debtShareOf[debtToken];
(uint256 amountPaid, uint256 share) = repayInternal(
positionId,
debtToken,
amountCall
);
// @audit collateral shares to be given to liquidator calculated using
// share / oldShare which only correspond to the one position being liquidated,
// not to the total debt of the borrower (which can be in multiple positions)
uint256 liqSize = (pos.collateralSize * share) / oldShare;
uint256 uTokenSize = (pos.underlyingAmount * share) / oldShare;
uint256 uVaultShare = (pos.underlyingVaultShare * share) / oldShare;
// @audit if the borrower has multiple debt positions, the liquidator
// can take the whole collateral by paying off only the lowest value
// debt position, since the shares are calculcated only from the one
// position being liquidated, not from the total debt which can be
// spread out across multiple positions
share / oldShare is the proportion of the one debt position being paid off by the Liquidator, not the entire debt of the Borrower which can be spread across multiple positions. Hence if the Borrower's debt is spread across multiple positions, a Liquidator can take all of the collateral by repaying only the smallest debt position.
Infinite Loan Rollover
If the Borrower can rollover their loan, the Lender must also be able to limit rollover either by limiting the number of times, the length of time, or through other parameters. If the Borrower can infinitely rollover their loan, this represents a critical loss of funds risk for the Lender who may never be repaid and never be able to liquidate the Borrower to take their collateral.
Repayment Sent to Zero Address
Care must be taken when implementing the repayment code such that the repayment is not lost by sending it to the zero address. Examine this code from Cooler's Sherlock audit:
function repay (uint256 loanID, uint256 repaid) external {
Loan storage loan = loans[loanID];
if (block.timestamp > loan.expiry)
revert Default();
uint256 decollateralized = loan.collateral * repaid / loan.amount;
// @audit loans[loanID] is deleted here
// which means that loan which points to loans[loanID]
// will be an empty object with default/0 member values
if (repaid == loan.amount) delete loans[loanID];
else {
loan.amount -= repaid;
loan.collateral -= decollateralized;
}
// @audit loan.lender = 0 due to the above delete
// hence repayment will be sent to the zero address
// some erc20 tokens will revert but many will happily
// execute and the repayment will be lost forever
debt.transferFrom(msg.sender, loan.lender, repaid);
collateral.transfer(owner, decollateralized);
}
"loan" points to storage loans[loanID], but loans[loanID] is deleted then afterward the repayment is transferred to loan.lender which will be 0 due to the previous deletion. Some ERC20 tokens will revert but many will happily execute causing the repayment to be sent to the zero address and lost forever. More examples: [1]
Borrower Permanently Unable To Repay Loan
If the system can enter a state where the Borrower permanently can't repay their loan because the repay() function reverts, this represents a critical loss of funds vulnerability for the Borrower who will be liquidated losing their collateral and also for the Lender who can never be repaid. Developers should test & auditors should verify that Borrowers can repay loans at various stages of the loan (active, overdue etc) unless the loan has been liquidated.
Borrower Repayment Only Partially Credited
Borrowing & Lending systems can allow Borrowers to take out multiple loans. Borrowers can then attempt to repay as much as possible with one call to the repay() function, the idea being that if the repayment amount can pay off the first loan, then any repayment amount should be used to pay off the second loan and so on.
A critical loss of funds error occurs for the Borrower if once the first loan has been paid off, the overflow is not used to at least partially pay off the second loan but the Lender receives the full amount, resulting in the Borrower's repayment only being partially credited.
Developers should test & auditors should verify that bulk repayment functionality does indeed pay off as many of the loans as possible and that none of the repayment amount is lost.