We regularly publish short recaps on a decentralized audit in which we participated. This time, we cover the audit of Blueberry Protocol, a DeFi yield farming protocol.
brainbot is a web3 service provider, offering consulting and development services as well as smart contract audits. To gain more experience in auditing, our security researchers regularly participate in decentralized audits. In this series, we will publish recaps of audits in which we participated in order to provide some insight into the functioning, the smart contract architecture and our findings for the respective protocols.
Blueberry is a defi yield farming protocol. It allows users to deposit collateral into Banks and withdraw debt tokens from it to farm yield (e.g. on Ichi). The goal of this content is to give insight on the structure of the code as audited during the Sherlock contest, as well as a couple of interesting findings.
The main contract of the protocol is BlueBerryBank.sol
, it allows the owner of the protocol to define which tokens can be lent / borrowed via addBank()
.
Each bank has the following fields:
The cToken represents the compound-like token that accrues interests for the users of the bank. The softVault / hardVault are addresses to vaults holding the cTokens for a bank. Each softVault only holds one type of token while hardVaults hold an erc1155 token representing multiple underlying tokens.
Users Interact with the BlueBerryBank via “spells”. They do so using the execute()
function, which checks that the spell is whitelisted at the beginning and
checks whether the user’s impacted / opened position is liquidatable at the end.
Spells will call lend()
, withdrawLend()
, borrow()
, repay()
, putCollateral()
, and takeCollateral()
functions on BlueBerryBank, that will impact the status of the
user’s position and transfer the adequate tokens.
A user’s position is represented with the following struct:
collToken is the collateral token used for the position, it is used to determine if a position is liquidatable. This in effect will be the LP tokens for a deposit in ICHI for example. collId represents the token id used for erc1155 collateral tokens. The underlyingToken is the token provided by the user and deposited into the soft/hard vaults. The underlyingVaultShare is the amount of share returned by the vault upon deposit, it represents the percentage of the vault owned by the user for the corresponding underlying token. debtMap and debtShareOf are used in case the user has debt tokens in multiple banks.
As an example, the lend(token, amount)
function will withdraw the amount of token from the user, set it as the underlyingToken
of the position,
deposit these tokens into the corresponding vault, and set underlyingVaultShare
accordingly for the position.
A basic spell abstract contract is currently defined in BasicSpell.sol
which provides a simple example of how a spell would interact with BlueBerryBank.
IchiVaultSpell.sol
defines more interesting functions to openPosition
/ openPositionFarm
and closePosition
/ closePositionFarm
, among other functions.
The function openPosition()
calls lend()
and borrow'()
on BlueBerryBank
to put the underlying token of the user into the bank and borrow borrowToken from it.
It then deposits the borrowed token on an Ichi Vault to farm yield and puts the vault LP token into the bank via putCollateral()
.
openPositionFarm()
functions similarly, but deposits the LP tokens into an IchiFarm instead of into the BlueBerryBank
and deposits the LP tokens of the IchiFarm into
the bank instead.
closePosition
and closePositionFarm
are the corresponding functions to close the open position. They withdraw the LP tokens back from the bank, burn the LP tokens on
the Ichi vault (after withdrawing from the farm for closePositionFarm
) to get back borrowed tokens, repay the debt to the BlueBerryBank
,
withdraw the underlying tokens lent by the user and refund the user.
The protocol uses multiple oracles to get the price of debt tokens or collateral used in the protocol. This is necessary used to determine if the position of a user
is liquidatable. A position is liquidatable if and only if debtValue - positionValue >= threshold * underlyingValue
, the threshold
is a parameter depending on the
specific underlying token and is in between 80% and 90%.
The project provides adapter for price oracle using UnsiwapV2-V3, Chainlink, Band, and Ichi, as well as an aggregator oracle that takes prices of multiple oracles to
provide a median or average price response.
Actions on a position by a user is reverted if the position after the action is liquidatable.
A liquidatable debt can be liquidated by anyone calling liquidate()
on BlueBerryBank
. The liquidator will repay the debt of the user and receive its whole position:
collateral as well as underlying token.
In this section I'll describe two interesting vulnerabilities found for this audit. The first one that I found myself, the second that I missed and was found by other auditors.
The liquidate()
function allows a liquidator to repay the debt of an underwater user to take part of their collateral. The function is flawed as it considers the share of repaid debt for a single token of the position to repay the equivalent share of collateral to the liquidator. That is, for a position with debt in multiple tokens the liquidator can fully repay the debt in a single token to receive the total collateral of the liquidated user.
The faulty code is:
As we can see, the code divides the share
result of repayInternal
and pos.debtShareOf[debtToken]
representing the debt share and repaid debt share in a single token in the calculation for the liquidated collateral. It does not take into account the other debt tokens of the position.
This vulnerability can be categorised as a protocol / implementation specific bug, as one needs to understand the logic behind what the contract does to exploit it.
The Chainlink adapter oracle uses the following code to get the price of a token:
According to Chainlink's documentation, the return values of latestRoundData
are (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
. The documentation further states that if "answeredInRound is less than roundId, the answer is being carried over. If answeredInRound is equal to roundId, then the answer is fresh." Additionally, "A read can revert if the caller is requesting the details of a round that was invalid or has not yet been answered. If you are deriving a round ID without having observed it before, the round might not be complete. To check the round, validate that the timestamp on that round is not 0. In a best-case scenario, rounds update chronologically. However, a round can time out if it doesn’t reach consensus. Technically, that is a timed out round that carries over the answer from the previous round."
The returned values should be checked for staleness by requiring that answeredInRound >= RoundID
, startedAt != 0
, and answer > 0
.
This vulnerability falls in the category of oracle / price manipulation (an attacker could take advantage of a stale price).
We'll continue to regularly publish audit recaps for different protocols. Meanwhile, you can also have a look at our other publications on Medium.
Decentralized Audit Platform: Sherlock
Audited Protocol: Blueberry Protocol
Security Researcher: Côme du Crest (ranked 6/284 in this contest)