Unveiling Critical Vulnerabilities: OXORIO's In-Depth Audit of Zunami Protocol

14 October, 2024
article image
Case Study

Contents

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.

StakeZUN 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 the vlZUN tokens but Alice retains the lock information in the userLocks mapping.
  • Bob cannot withdraw the staked ZUN associated with the transferred vlZUN tokens because the userLocks 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 the removeLiquidity function. This will ensure that depositedAssets 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 for frxETH, 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 a onlyAuthorized 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!

Telegram
Case Study

Contents

Telegram

Have a question?

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.