Introduction
The global cryptocurrency market today has become a trillion-dollar industry, and over the past few years, there have been numerous innovations within the space. One of the most significant developments is the emergence of decentralized finance (DeFi). At the core of DeFi are collateralized debt positions (CDPs) and lending protocols. These protocols allow users to lend and borrow cryptocurrencies without relying on traditional financial institutions and leverage smart contracts to automate the process. In this post, we will go through the major hacks on CDPs and lending protocols, emphasize best practices, and provide actionable advice on how to avoid these security vulnerabilities.
What Are CDPs and Lending Protocols?
CDPs and lending protocols are fundamental components of the DeFi ecosystem on the blockchain.
CDPs are smart contracts that allow users to take a loan on an asset in exchange for depositing collateral of a different asset. The deposited collateral is then locked in the contract until the loan is repaid. This way, CDPs mint a number of stablecoins equal to the requested loan amount. The smart contracts ensure that the stablecoin is always backed by a sufficient amount of collateral. When the value of the collateral falls below a certain threshold, decided by the protocol, the user’s position can be liquidated.
Lending protocols, on the other hand, enable users to lend and borrow from a pool of assets. Users who provide assets in such pools are provided rewards that are taken from borrowers as fees.
From a security perspective, there are a lot of things that can go wrong while creating CDPs or lending protocols. In what follows, we will go through the most common bugs and how to prevent them.
Common Bugs in CDP and Lending Protocols
1. Price Manipulation
Price manipulation is one of the most exploited issues for CDPs and lending protocols. These attacks exploit smart contracts’ reliance on external data sources, known as oracles, to function accurately. Oracles bridge the gap between the blockchain and the outside world by providing real-time data such as price information. Price-manipulation attacks occur when an attacker artificially inflates or deflates the price of a token within a protocol. For instance, if a DeFi protocol uses a decentralized exchange (DEX) as its oracle to fetch the price of a particular asset, an attacker could artificially inflate or deflate the asset’s price on the DEX. There are many ways to manipulate the price coming from an oracle depending upon how the price is fetched. Here we will go through some major hacks and how these can be avoided.
Spot Price Manipulation
Spot price–manipulation attacks are a form of market manipulation where attackers attempt to artificially alter the spot price of an asset. The spot price represents the current market price at which an asset can be instantly bought or sold. In the context of blockchain and DeFi, this manipulation typically targets the price of a token on a DEX (e.g., Uniswap).
Visor Finance Hack
Lost: $500K
Visor Finance got hacked in November 2021 due to reliance on spot prices
from Uniswap. These spot prices can be easily manipulated by swapping
token0
for token1
. This takes token1
out of the AMM, raising the
price of token1
. During the hack, the attacker took a flash loan to
manipulate the spot price to issue shares and then withdrew more tokens
than expected.
uint160 sqrtPrice = TickMath.getSqrtRatioAtTick(currentTick()); //currentTick fetches the currect tick value
uint256 price = FullMath.mulDiv(uint256(sqrtPrice).mul(uint256(sqrtPrice)), PRECISION, 2**(96 * 2));
Here, the value of price
would be the price of the token at current
tick.
It is recommended to use Chainlink price feeds or time-weighted average price (TWAP) to fetch the price of a token. It is worth noting that short TWAPs may still be atomically manipulated in some scenarios.
See another hack due to a similar spot price reliance:
bZx↗ — Lost: $8M
Liquidity Pool–Token Price Manipulation
Numerous hacks come from using the wrong formula to calculate the price of liquidity pool (LP) tokens. Although it may seem obvious to calculate the price of LP tokens by dividing the token value locked in the pool by the total supply of LP tokens, this is the formula that leads to million-dollar hacks. The wrong equation would be this:
where = price of token i
and = token i
reserve amount.
This method of pricing LP tokens is susceptible to manipulation, as and can be drastically moved with flash loans.
Alpha Venture presented fair Uniswap LP token pricing↗, which can be used instead of the above formula. The formula is as follows:
where is the true price of the asset; in other words, it is not susceptible to spot price manipulation (e.g., from a high-quality oracle).
Fair LP token pricing evaluates the LP token based on the values of the fair reserve amounts of the AMM. The fair reserve amounts are calculated based on the AMM constant and the fair price ratio.
Now, let’s go over some hacks due to the wrong LP token price formula.
Warp Finance Hack
Lost: $7.8M
Warp Finance was hacked due to the use of the wrong formula to calculate the price of LP tokens. Here is the code responsible for the hack:
function getUnderlyingPrice(address _lpToken) public returns (uint256) {
address[] memory oracleAdds = LPAssetTracker[_lpToken];
//retreives the oracle contract addresses for each asset that makes up a LP
UniswapLPOracleInstance oracle1 = UniswapLPOracleInstance(
oracleAdds[0]
);
UniswapLPOracleInstance oracle2 = UniswapLPOracleInstance(
oracleAdds[1]
);
(uint256 reserveA, uint256 reserveB) = UniswapV2Library.getReserves(
factory,
instanceTracker[oracleAdds[0]],
instanceTracker[oracleAdds[1]]
);
uint256 value0 = oracle1.consult(
instanceTracker[oracleAdds[0]],
reserveA
);
uint256 value1 = oracle2.consult(
instanceTracker[oracleAdds[1]],
reserveB
);
// Get the total supply of the pool
IERC20 lpToken = IERC20(_lpToken);
uint256 totalSupplyOfLP = lpToken.totalSupply();
//code skipped..
uint256 totalValue = value0 + value1;
uint16 shiftAmount = supplyDecimals;
uint256 valueShifted = totalValue * uint256(10)**shiftAmount;
uint256 supplyShifted = supply;
uint256 valuePerSupply = valueShifted / supplyShifted;
return valuePerSupply;
}
In the above code, the variable value0
is equal to p0 * r0
and
value1
is p1 * r1
. It’s clear that the wrong formula is used to
price the LP tokens.
Inverse Finance Hack
Lost: $1.26M
A similar attack on Inverse Finance was due to the LP token price manipulation of a tri-pool.
function latestAnswer() public view returns (uint256) {
uint256 crvPoolBtcVal = WBTC.balanceOf(address(CRV3CRYPTO)) * uint256(BTCFeed.latestAnswer()) * 1e2;
uint256 crvPoolWethVal = WETH.balanceOf(address(CRV3CRYPTO)) * uint256(ETHFeed.latestAnswer()) / 1e8;
uint256 crvPoolUsdtVal = USDT.balanceOf(address(CRV3CRYPTO)) * uint256(USDTFeed.latestAnswer()) * 1e4;
uint256 crvLPTokenPrice = (crvPoolBtcVal + crvPoolWethVal + crvPoolUsdtVal) * 1e18 / crv3CryptoLPToken.totalSupply();
return (crvLPTokenPrice * vault.pricePerShare()) / 1e18;
}
Again, the formula used to calculate the LP token price was incorrect, which led to the hack.
To price the LP tokens, use the formula↗ presented by Alpha Venture. It is important to note that if the LP token is used both as a collateral and borrow token, the formula can still be manipulated.
Following are some other hacks caused by the use of the same formula:
Cheese Bank↗ — Lost: $3.3M
Themis Protocol↗ — Lost: $370k
NXUSD Protocol↗ — Lost: $371k
Donation-Based Price Manipulation Attacks
Another common way to manipulate the price or exchange rate is to directly donate to a pool. To illustrate, consider a scenario where the exchange rate is determined by the ratio of a specific token’s balance to the total supply. In such a case, a potential threat arises when an attacker donates tokens to the pool, thereby altering the exchange rate to their advantage.
A well-known attack for this bug is on Compound forks with markets that
have zero total supply. The underlying issue is also due to a rounding
error in the exchangeRateStoredInternal
function:
function exchangeRateStoredInternal() virtual internal view returns (uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
/*
* If there are no tokens minted:
* exchangeRate = initialExchangeRate
*/
return initialExchangeRateMantissa;
} else {
/*
* Otherwise:
* exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
*/
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
return exchangeRate;
}
}
function getCashPrior() virtual override internal view returns (uint) {
EIP20Interface token = EIP20Interface(underlying);
return token.balanceOf(address(this));
}
The attack involves donating to the market where totalSupply
is zero
and significantly increasing the exchange rate to borrow against it.
Some donation-based price manipulation attacks are successful due to the
totalSupply
of the pool being zero. This way, the attackers mint one
share to increase the totalSupply
to 1 and then donate to the pool to
inflate the exchange rate. An easy way to fix these kind of attacks is
for protocol admins to be the first to mint some shares so that
totalsupply
can never be zero. Other similar attacks can possibly be
mitigated by keeping an internal account of tokens to account for the
tokens that are directly donated to the pool.
Here are other hacks due to a similar underlying issue:
C.R.E.A.M. Finance↗ — Lost: $130M
Hundred Finance↗ — Lost: $7M
Midas Capital↗ — Lost: $600k
OVIX↗ — Lost: $4M
2. Read-Only Reentrancy
Read-only reentrancy attacks occur when a view
function is used to
read an inconsistent state of the protocol while it’s being reentered.
If the view
function that is reentered is used to calculate critical
data such as the price of a token, it can be used to manipulate the data
when the function is reentered. As view
functions are typically not
protected using non-reentrant modifiers, they can be reentered without
being reverted.
A typical read-only reentrancy attack flow looks like this:
- The attacker calls a reentrant contract. The called function (1) modifies the contract’s state, then (2) returns control flow to the attacker but without finalizing/committing updates to its own state yet.
- When control flow is returned to the attacker, the reentrant contract is in an internally inconsistent state and is not safe to interact with. However, the reentrant contract itself has reentrancy mutex and isn’t a viable attack target.
- Instead, the attacker calls a third-party victim contract. The
victim contract is not aware that the reentrant contract is in an
unsafe, inconsistent state and interacts with it in a read-only
manner. No errors are thrown as
view
functions are typically not protected by reentrancy guards. - The victim contract reads erroneous data and relies on it, leading to faulty operation of the victim contract for the attacker’s benefit.
One common scenario for read-only reentrancy: The reentrant contract is often a DeFi primitive that’s used as an oracle by another protocol. This other protocol is generally the victim contract.
Here are some common read-only reentrancy bugs exploited in the past:
Curve’s get_virtual_price
One of the most common functions used to exploit read-only reentrancy in
CDPs and lending protocols is get_virtual_price
when smart contracts
integrating with Curve pools are used to estimate the price of LP
tokens. This function can be reentered during a raw_call
made by
remove_liquidity
. During the raw_call
, the control flow would be
transferred to the recipient’s fallback function. If the function
get_virtual_price
is reentered during this state, the value of D would
be inconsistent, leading to an inconsistent return value and hence the
price of the LP token. The function get_virtual_price
can also be
reentered if one of the tokens removed is an ERC-777/ERC-677 token.
@external
@nonreentrant('lock')
def remove_liquidity(
_amount: uint256,
_min_amounts: uint256[N_COINS],
) -> uint256[N_COINS]:
amounts: uint256[N_COINS] = self._balances()
lp_token: address = self.lp_token
total_supply: uint256 = ERC20(lp_token).totalSupply()
CurveToken(lp_token).burnFrom(msg.sender, _amount) # dev: insufficient funds
for i in range(N_COINS):
value: uint256 = amounts[i] * _amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
amounts[i] = value
if i == 0:
raw_call(msg.sender, b"", value=value)
else:
assert ERC20(self.coins[1]).transfer(msg.sender, value)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount)
return amounts
@view
@external
def get_virtual_price() -> uint256:
"""
@notice The current virtual price of the pool LP token
@dev Useful for calculating profits
@return LP token virtual price normalized to 1e18
"""
D: uint256 = self.get_D(self._balances(), self._A())
token_supply: uint256 = ERC20(self.lp_token).totalSupply()
return D * PRECISION / token_supply
Here are some hacks due to read-only reentrancy in get_virtual_price
:
dForce Protocol↗ — Lost: $3.4M
Midas Capital↗ — Lost: $660K
Sturdy Finance↗ — Lost: $880K
Jarvis Network↗ — Lost: ~$660K
Balancer’s _joinOrExit
Balancer’s read-only reentrancy has also been a root cause of several
hacks in the past. The issue is in the function _joinOrExit
, where the
transfer (and hence the callback) is made before the pool balance is
updated and thus makes the accounting inconsistent.
Preventing read-only reentrancy attacks involves careful design and implementation of the protocol. It is important to ensure that read-only functions cannot be manipulated to modify state variables. During protocol integration, it is important to verify that such read-only functions are reentrancy protected. If not, the reentrancy locks of these external protcols should be verified to not be in any reentrancy scenario during any read-only function calls. Given the recent hacks on the Curve pool resulting from the Vyper compiler bug, it is of utmost importance to implement robust testing for these contracts. This will ensure that potential bugs do not impact the protocol.
Here are several hacks due to read-only reentrancy in _joinOrExit
:
Sturdy Finance↗ — Lost: $800K
Sentiment↗ — Lost: $1M
3. Read-Write Reentrancy
Reentrancy was famously exploited in the 2016 DAO hack, where $50 million in Ether was stolen by recursively draining the DAO’s balance. Reentrancy issues arise when a vulnerable contract calls an external contract without properly managing the state changes that occur during its execution. The receiving contract can make a recursive call back to the sending contract, repeating this process multiple times, which can either drain tokens or change the state of the contract in an unexpected manner.
In CDPs and lending protocols, there are many reentrancy and read-only reentrancy issues. We’ll go over common cases of reentrancy in this section and delve into read-only reentrancy in the next.
Reentrancy in Compound Forks
Rari Capital
Lost: ~$80M
Rari is a Compound fork, and the protocol Compound had an issue with
reentrancy attacks when cTokens were borrowed through the borrowFresh
function. See below:
doTransferOut(borrower, borrowAmount);
/* We write the previously calculated values into storage */
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrowsNew;
The function doTransferOut
makes a low-level call to the borrower
address, which can then be used to make a reentrant call to exitMarket
and withdraw the collateral. Here, the checks-effects-interactions (CEI)
pattern is violated, which led to the attack.
See some other hacks due to the same underlying issue:
DeFiPIE Protocol↗ — Lost: $269K
Paribus↗ — Lost: $67K
Reentrancy Due to ERC-777/ERC-677 Tokens
Caution should be made while using these tokens in the protocol as these tokens can cause reentrancy attacks. Some Compound forks that used ERC-777/ERC-677 were hacked due to the combination of two issues. For example, the CEI pattern was not followed and the protocol used tokens that have callbacks.
These are some Compound forks hacked due to the use of ERC-777/ERC-677 tokens:
Hundred Finance↗ — Lost: $6.2M
Voltage Finance↗ — Lost: $4.6M
Agave DAO↗ — Lost: $5.5M
C.R.E.A.M. Finance↗ — Lost: $18M
There were a few more hacks (not on Compound forks) due to the use of such tokens. Here are some examples.
Bacon Protocol Hack
Lost: ~$1M
The root cause of the bug was a reentrancy due to the ERC-777–token
callback function tokensReceived
, which led to reentry in the lend
function, as shown below.
It is very important to always follow the CEI pattern and put non-reentrant modifiers to functions susceptible to reentrancy attacks. Again, we’d like to bring to attention the FREI-PI↗ pattern. The main idea behind this pattern is to write protocol invariants at the end of functions, such that if they are ever violated during a transaction, it would be reverted.
See another hack on a lending protocol due to reentrancy issues:
Arcadia Finance↗ — Lost: $460k
4. Insufficient Input Validation
This bug arises when the smart contract fails to properly validate and trusts the input data without properly sanitizing user inputs. The underlying issue is the lack of proper input validation mechanisms. Here are some of the major hacks due to these issues:
Auctus Hack
Lost: $726K
On March 29th, Auctus was exploited by hackers to profit about $726,000 from users who did not revoke the approvals. Here is the code snippet that led to the hack:
function write(address acoToken, uint256 collateralAmount, address exchangeAddress, bytes memory exchangeData)
nonReentrant setExchange(exchangeAddress) public payable
{
require(msg.value > 0, "ACOWriter::write: Invalid msg value");
require(collateralAmount > 0, "ACOWriter::write: Invalid collateral amount");
address _collateral = IACOToken(acoToken).collateral();
if (_isEther(_collateral)) {
IACOToken(acoToken).mintToPayable{value: collateralAmount}(msg.sender);
} else {
_transferFromERC20(_collateral, msg.sender, address(this), collateralAmount);
_approveERC20(_collateral, acoToken, collateralAmount);
IACOToken(acoToken).mintTo(msg.sender, collateralAmount);
}
_sellACOTokens(acoToken, exchangeData);
}
/**
* @dev Internal function to sell the ACO tokens and transfer the premium to the transaction sender.
* @param acoToken Address of the ACO token.
* @param exchangeData Data to be sent to the exchange.
*/
function _sellACOTokens(address acoToken, bytes memory exchangeData) internal {
uint256 acoBalance = _balanceOfERC20(acoToken, address(this));
_approveERC20(acoToken, erc20proxy, acoBalance);
(bool success,) = _exchange.call{value: address(this).balance}(exchangeData);
require(success, "ACOWriter::_sellACOTokens: Error on call the exchange");
address token = IACOToken(acoToken).strikeAsset();
if(_isEther(token)) {
uint256 wethBalance = _balanceOfERC20(weth, address(this));
if (wethBalance > 0) {
IWETH(weth).withdraw(wethBalance);
}
} else {
_transferERC20(token, msg.sender, _balanceOfERC20(token, address(this)));
}
if (address(this).balance > 0) {
msg.sender.transfer(address(this).balance);
}
}
The function write
is public, and the parameters are not validated.
The attacker exploited it by setting exchangeAddress
to the address of
the USDC token and exchangeData
as transferFrom
. All the other
conditions can be easily bypassed by creating a fake acoToken
and
giving it as input in the write
function.
Fortress Protocol Hack
Lost: $3M
Fortress Protocol was hacked on May 9th, 2022, due to a few different vulnerabilities. Here we will focus only on the insufficient input validation bug that led to manipulation of the Umbrella Network oracle.
function submit(
uint32 _dataTimestamp,
bytes32 _root,
bytes32[] memory _keys,
uint256[] memory _values,
uint8[] memory _v,
bytes32[] memory _r,
bytes32[] memory _s
) public { // it could be external, but for external we got stack too deep
...
...
for (; i < _v.length; i++) {
address signer = recoverSigner(affidavit, _v[i], _r[i], _s[i]);
uint256 balance = stakingBank.balanceOf(signer);
require(prevSigner < signer, "validator included more than once");
prevSigner = signer;
if (balance == 0) continue;
emit LogVoter(lastBlockId + 1, signer, balance);
power += balance; // no need for safe math, if we overflow then we will not have enough power
}
require(i >= requiredSignatures, "not enough signatures");
// we turn on power once we have proper DPoS
// require(power * 100 / staked >= 66, "not enough power was gathered");
squashedRoots[lastBlockId + 1] = _root.makeSquashedRoot(_dataTimestamp);
blocksCount++;
emit LogMint(msg.sender, lastBlockId + 1, staked, power);
}
The submit
function used to update the price can be called by anyone.
The function only verifies if the number of signatures is greater than
requiredSignatures
. It does not check the power
as that verification
(require(power * 100 / staked >= 66, "not enough power was gathered");
)
was commented out from the code.
It is crucial to verify every single parameter that is passed in a function. Only the parameters expected by the functions should be allowed, and all other cases should be reverted. Here, we’d also like to bring attention to the function requirements–effects–interactions + protocol invariants (FREI-PI↗) pattern.
Other hacks due to similar issues:
Visor Finance↗ - Lost: $8.2M
Deus DAO Hack↗ — Lost: $6.5M
5. Insufficient Access Control
These issues occur when there are insufficient limitations on who can access or edit critical data within the smart contract and when the access control mechanisms in the contract are inadequately designed. This can be fixed by implementing role-based access control and ensuring that sensitive functions are only accessible by authorized addresses. This is an example of one such attack on a lending protocol:
Rikkei Finance
Lost: $1.1M
The underlying issue was that the function setOracleData
lacked any
access control mechanism and could be called by anyone. The attacker
changed the oracleData
mapping to manipulate the price feed of the
protocol.
function setOracleData(address rToken, oracleChainlink _oracle) external { **//vulnerable**
oracleData[rToken] = _oracle;
}
It is recommended to review every function and carefully decide if it is critical that the function be protected by access control mechanisms so that only the owner of the contract or governance can access/call such functions successfully.
The Takeaway
Price manipulation, insufficient access control and input validation, and reentrancy issues are some of the most major issues for CDPs and lending protocols. However, the DeFi ecosystem is in a constant state of change, paving way for more security vulnerabilities to emerge and watch out for in the future. Neglecting preventative measures against hacks such as these is the first step in how not to create a CDP or lending protocol.
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.