
How (Not) to Create a DeFi CDP or Lending Protocol
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 asview
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.
*** This is a Security Bloggers Network syndicated blog from Zellic — Research Blog authored by Zellic — Research Blog. Read the original post at: https://www.zellic.io/blog/how-not-to-create-a-cdp-or-lending-protocol