#Zunami protocol
#Security audit
#DeFi security
#Yield farming vulnerabilities
#Yield farming aggregator
#Smart contract vulnerabilities
#Blockchain security
#Liquidity providers
#DeFi strategies
#Staking pools
#Yield aggregator
#Crypto audit reports
#Stablecoin collateral
#Algorithmic stablecoins
#DeFi investment risks
#Yield farming strategies
#Zunami protocol vulnerabilities
#Decentralized finance
#DeFi audits
#Smart contract audits
#DeFi protocols
#Yield optimizers
#DeFi security audits
Unveiling Critical Vulnerabilities: OXORIO's In-Depth Audit of Zunami Protocol
14 October, 2024
In the first quarter of this year, our OXORIO team audited the Zunami protocol. This protocol involves issuing stablecoins backed by various assets and used in different investment strategies.
During the audit, we discovered several interesting vulnerabilities. A detailed audit report is available here, and you can also check out our other audits.
Yield Farming
Yield Farming is a DeFi strategy where investors earn rewards by providing liquidity to different protocols. By placing their digital assets in special liquidity pools, which are smart contracts, investors become liquidity providers.
As a reward for their investment, they receive governance tokens of the protocol, giving them voting rights in decision-making, or a share of the fees generated by the protocol.
The main participants in yield farming are:
- Liquidity Providers (LPs): These are users who contribute their assets to liquidity pools. They provide liquidity to platforms, allowing other users to borrow or exchange tokens.
- Lenders: Platforms (like Aave, Compound, etc.) that collect liquidity and lend it out to borrowers.
- Borrowers: These are users who use the liquidity provided by LPs to take out loans. They pay interest on the borrowed funds, which also generate income (APR) for liquidity providers.
Let’s take a closer look at one of the most frequent cases:
What is a yield aggregator?
Yield aggregators, also known as “auto-compounders” or “yield optimizers”, play a key role in the yield economy by bringing together different protocols and strategies to maximize investor returns.
Simply put, yield aggregators pool deposits from investors and then deploy the assets across multiple protocols and yield-generating strategies. This essentially minimizes risk, eliminates trading fees, and optimizes profits - all autonomously.
Let’s dive deeper into this
Yield aggregators work by pooling funds from many investors into shared pools. As proof of participation in these pools, investors receive special tokens called liquidity provider (LP) tokens. Each token represents an investor’s share of the total pool and is proportional to their initial contribution.
Next, by combining user’s investments, yield aggregators automatically distribute them across many different DeFi protocols, such as lending, liquidity-providing platforms, and staking pools. These platforms offer various ways to earn, and the aggregator constantly monitors them, choosing the most profitable ones at the moment.
Then, all the earned profits are automatically reinvested back into the investments, causing the yield to grow exponentially. This process, called auto-compounding, allows investors to get the maximum return on their funds.
Introduction to Zunami
Zunami is a protocol that aggregates stablecoin collateral to generate optimized yields.
Users put up assets as collateral to create a shared pool of funds called an omnipool. This pool acts as a yield aggregator, allowing users to deposit their assets as collateral. These assets provide the liquidity needed for many yield-generating strategies to work effectively.
The profits earned from these strategies are either reinvested for further growth or distributed proportionally to users who stake ZUN tokens. Additionally, the omnipool serves as collateral for issuing the algorithmic stablecoin zunStables, helping to keep its value tied to the underlying currency.
Zunami uses an algorithmic stabilizer called APS Algorithmic Peg Stabilizer to maintain the price stability of zunStables. APS works by automatically managing a liquidity pool on Curve and by minting and burning zunStables. For example, if the price of zunUSD starts to deviate from the dollar, APS will buy or sell USDC and zunUSD to restore balance.
Furthermore, users can participate in stabilization themselves by staking their zunStables. In return, they earn rewards in the form of additional ZUN tokens and other benefits.
Want to know more? Check out this resource for all the details.
Identified Bugs
In close collaboration with the Zunami team, we conducted three security audits (Audit, Reaudit, Reaudit 2). Each audit resulted in the identification and timely mitigation of new vulnerabilities, demonstrating the high level of professionalism and responsiveness of the Zunami team.
Let’s examine the discovered vulnerabilities in more detail.
Staked ZUN
unavailable for withdrawal after vlZUN
ownership change
As we already know, Zunami has a staking system. Users can lock their ZUN
tokens for 4 months. When they do this, they will receive vlZUN
(vote-locked ZUN). These vlZUN
tokens are like a certificate that shows you can help make decisions about the project.
With vlZUN
, you will be able to take part in making key decisions, including omnipool allocation and distribution of ZUN
emissions.
To manage the process of distributing emissions, a ZUN Distributor contract has been implemented.
This contract allows users to deposit and withdraw funds.
In the deposit
function, the user sends ZUN
tokens and receives an equivalent of vlZUN
tokens. The contract storage userLocks
mapping is also updated to save information about the user’s deposit.
function _deposit(uint256 _amount, address _receiver) internal {
//...
uint128 untilBlock = uint128(block.number + BLOCKS_IN_4_MONTHS);
uint256 lockIndex = userLocks[_receiver].length;
userLocks[_receiver].push(LockInfo(uint128(_amount), untilBlock));
emit Deposited(_receiver, lockIndex, _amount, untilBlock);
}
For the withdraw
function, a user (msg.sender
) seeking to withdraw staked ZUN
tokens must have a balance in the userLocks
mapping and vlZUN
tokens.
function withdraw(
uint256 _lockIndex,
bool _claimRewards,
address _tokenReceiver
) external nonReentrant {
LockInfo[] storage locks = userLocks[msg.sender];
if (locks.length <= _lockIndex) revert LockDoesNotExist();
LockInfo storage lock = locks[_lockIndex];
uint256 untilBlock = lock.untilBlock;
if (untilBlock == 0) revert Unlocked();
uint256 amount = lock.amount;
// ...
uint256 transferredAmount = amountReduced;
if (block.number < untilBlock) {
transferredAmount =
(amountReduced * (PERCENT_DENOMINATOR - EXIT_PERCENT)) /
PERCENT_DENOMINATOR;
token.safeTransfer(earlyExitReceiver, amountReduced - transferredAmount);
}
token.safeTransfer(address(_tokenReceiver), transferredAmount);
emit Withdrawn(msg.sender, _lockIndex, amount, amountReduced, transferredAmount);
}
When a vlZUN
token is transferred to a new owner, the staked ZUN
tokens associated with the previous owner will not be available for withdrawal. This is because the userLocks
mapping, which tracks locked ZUN
tokens for each user is not updated when vlZUN
tokens are transferred.
This creates a scenario:
- If Alice transfers
vlZUN
tokens to Bob, Bob holds thevlZUN
tokens but Alice retains the lock information in theuserLocks
mapping. - Bob cannot withdraw the staked ZUN associated with the transferred
vlZUN
tokens because theuserLocks
mapping for Bob’s address is empty.
Our Recommendations:
- Remove Transfer Logic from vlZUN
Token:
- Pros: Simplifies the contract, and reduces potential attack vectors.
- Cons: Limits the flexibility of
vlZUN
tokens, as they cannot be freely transferred.
- Update userLocks
Mapping During vlZUN
Transfer:
- Pros: Allows for more flexibility in
vlZUN
token transfers. - Cons: Increases contract complexity and potential attack surface.
Elevated price in USD leads to money theft from the pool
The ZunamiStratBase contract is part of the Zunami system and is used to manage different investment strategies that interact with the Zunami Pool. This contract lets users add or remove money from the pool, earn rewards, and handle the assets involved.
When you use the deposit function in the strategy, your money is added to the Curve pool. In return, you get LP tokens. The strategy keeps track of how many LP tokens you have in the depositedLiquidity variable. Then, the value of these tokens in US dollars is calculated using the latest price from the oracle and is sent to the zunamiPool.
function deposit(uint256[POOL_ASSETS] memory amounts) external returns (uint256) {
// ...
uint256 liquidity = depositLiquidity(amounts);
depositedLiquidity += liquidity;
return calcLiquidityValue(liquidity);
}
In zunamiPool
, shares are minted based on the USD value of this LP tokens. However, these new shares are allocated without considering the USD value of previously minted shares in the pool according to the new price (processSuccessfulDeposit function):
function processSuccessfulDeposit(
address receiver,
uint256 depositedValue,
uint256[POOL_ASSETS] memory depositedTokens,
uint256 sid
) internal returns (uint256 minted) {
// ...
minted =
((totalSupply() + 10 ** _decimalsOffset()) * depositedValue) /
(totalDeposited + 1);
}
_mint(receiver, minted - locked);
_strategyInfo[sid].minted += minted;
totalDeposited += depositedValue;
emit Deposited(receiver, depositedValue, depositedTokens, minted, sid);
}
To illustrate this, let’s consider the following example:
A first user deposits 10,000 zunUSD
into the strategy, receiving 10,000 LP tokens from the Curve pool. The strategy records these LP tokens as deposited liquidity, valued at 1 USD per token (as per the oracle).
Initial state:
- Token price: 1 USD
- Deposited liquidity: 10,000 LP tokens
- Minted shares: 10,000
- Total deposited: 10,000 USD
A second user deposits 1,000 zunUSD
, receiving 1,000 LP tokens. However, the LP token price has increased to 1.2 USD. The deposit
the function returns 1,200 USD, but the minted shares remain unchanged due to the price increase.
Intermediate state:
- Token price: 1.2 USD
- Deposited liquidity: 11,000 LP tokens
- Minted shares: 11,200
- Total deposited: 11,200 USD
When the second user withdraws their 1,200 shares, the calcRatioSafe function calculates their claim as 10.7% of the total deposited liquidity. This equates to 1,177 LP tokens, which can be exchanged for 1,177 zunUSD
in the Curve pool.
Final state:
- Deposited liquidity: 9,823 LP tokens
- Minted shares: 10,000
- Total deposited: 10,000 USD
In this scenario, the second user profits by 177 zunUSD
due to the price increase and the way shares are calculated. This results in a loss for the first user.
Our Recommendations:
- Adjust Share Allocation Based on Current Share Value:
- To address this issue, we recommend adjusting the share allocation formula in the
zunamiPool
to consider the current USD value of all shares in the pool. This would ensure that new shares are allocated proportionally based on the current value of existing shares.
Claiming rewards is blocked after liquidity removal
The problem we’re talking about is in the ERC4626StratBase contract, and it’s in a part called the claimCollectedRewards function. Let’s break it down.
ERC4626StratBase
is an abstract contract designed to implement tokenized vaults conforming to the ERC-4626 standard. Its primary purpose is to manage and interact with yield-bearing assets.
The claimCollectedRewards
the function gives users the rewards they’ve earned. It checks how many assets are ready to be taken out now (r
) compared to how many were put in at the start (depositedAssets
). If there are more that can be taken out, it means new rewards have been earned, and they are given to the user.
function claimCollectedRewards() internal virtual override {
uint256 redeemableAssets = vault.previewRedeem(depositedLiquidity);
if (redeemableAssets > depositedAssets) {
uint256 withdrawnAssets = redeemableAssets - depositedAssets;
uint256 withdrawnShares = vault.convertToShares(withdrawnAssets);
uint256[5] memory minTokenAmounts;
removeLiquidity(withdrawnShares, minTokenAmounts, false);
depositedLiquidity -= withdrawnShares;
}
The issue arises when the removeLiquidity
operation decreases the total liquidity within the pool, consequently reducing the redeemableAssets
value. However, the depositedAssets
value remains static.
As a result, the condition redeemableAssets > depositedAssets
is no longer met, preventing the claimCollectedRewards
function from distributing rewards.
Our Recommendations:
- Update
depositedAssets
When Removing Liquidity - We recommend updating the
depositedAssets
variable within theremoveLiquidity
function. This will ensure thatdepositedAssets
reflects the actual amount of liquidity deposited in the vault, even after withdrawals.
Incorrect Token Price Retrieval in FrxETHOracle
The FrxETHOracle contract is designed to provide the USD price of frxETH
. However, there’s a fundamental flaw in its implementation.
The problem is that the contract is asking a generic oracle for the price of ETH, but it should be asking for the price of frxETH
. This is wrong because frxETH
is a token that is supposed to have the same value as ETH.
isTokenSupported function checks if the provided token is frxETH
. If the token is frxETH
, it calls the getUSDPrice function of the generic oracle. This function is expected to provide the USD price of ETH.
function getUSDPrice(address token) external view returns (uint256) {
if (!isTokenSupported(token)) revert WrongToken();
return _genericOracle.getUSDPrice(CHAINLINK_FEED_REGISTRY_ETH_ADDRESS);
}
Since the contract is requesting the price of ETH instead of frxETH
, the returned price will be incorrect if `frxETH` is not perfectly pegged to ETH. This means that if the frxETH
exchange rate deviates from the 1.00 target (within the 1% tolerance), the oracle will provide an inaccurate price.
This can have significant consequences, especially in protocols that rely on the accuracy of the oracle’s price data.
Our Recommendations:
- Request
frxETH
Price Directly - - The contract should be modified to request the price of
frxETH
directly from a reliable oracle. This will ensure that the oracle returns the correct price forfrxETH
, reflecting its peg to ETH.
No access control for deposit
the function
The deposit function in the VaultStrat contract has an external
visibility modifier, which means anyone can call it. A hacker could take advantage of this by pretending to deposit money without actually sending any. This would mess up the calculations when someone tries to take their money out, and it could mean honest users lose their money.
Here’s how the attack works:
- Legitimate users deposit funds to
VaultStrat
contract through the standard controller mechanism. - The attacker simulates a minimal deposit via the controller to establish a legitimate user facade.
- Subsequently, the attacker performs a fraudulent deposit directly into the contract, bypassing the controller.
- Finally, the attacker withdraws all funds from the
VaultStrat
, including those belonging to legitimate users.
Our Recommendations:
- Add Access Control to
deposit
- To prevent unauthorized deposits, we recommend adding access control to the
deposit
function. This can be achieved using aonlyAuthorized
modifier or a similar mechanism.
Conclusion
Yield farming aggregators are a big part of the decentralized world. They let users try out different investment strategies to make more money. It’s really important to know where these platforms might have problems so we can keep user’ы money safe.
Always be mindful of potential risks and conduct regular audits of your code!
Contents
YOU MAY ALSO LIKE
Unveiling the Hidden Flaws: OXORIO's Deep Dive into Rho Protocol's DeFi Derivatives
Case Study
OXORIO's audit uncovers critical vulnerabilities in Rho Protocol's DeFi derivatives market. Explore interest rate swaps, perpetual futures, and the security challenges in decentralized finance.
7 October, 2024
Workshop: Using Noir for Building an Anonymization Module
Case Study
Explore the hands-on practicality of the Safe Anonymization Module (SAM) and the unique advantages of the Noir DSL in anonymizing transaction data. This workshop details generating Zero-Knowledge Proofs (ZKP) and managing anonymous transactions efficiently using SAM on the Safe network
5 July, 2024
Noir Explained: Features and Examples
Case Study
Discover the innovative power of Noir, a Domain-Specific Language from Aztec Protocol, transforming SNARK proving systems. Learn how Noir's streamlined syntax and extensive features can elevate your blockchain projects, enabling more secure and efficient cryptographic operations.
18 June, 2024
Lido Explained: Dual Governance
Case Study
Discover Lido's Dual Governance Strategy: Lido Ecosystem Grants Organisation's latest proposal aims to empower stETH holders with veto powers to safeguard their interests against DAO decisions.
17 June, 2024
Have a question?
Stay Connected with OXORIO
We're here to help and guide you through any inquiries you might have about blockchain security and audits.