Badger Citadel contest - Ruhum's results

Bringing BTC to DeFi

General Information

Platform: Code4rena

Start Date: 14/04/2022

Pot Size: $75,000 USDC

Total HM: 8

Participants: 72

Period: 7 days

Judge: Jack the Pug

Total Solo HM: 2

Id: 110

League: ETH

BadgerDAO

Findings Distribution

Researcher Performance

Rank: 15/72

Findings: 3

Award: $1,220.73

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Ruhum

Also found by: TrungOre, VAD37, cccz, danb, gs8nrv, kyliek, minhquanym, rayn, shenwilly, wuwe1

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

896.081 USDC - $896.08

External Links

Lines of code

https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L291-L295 https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L772-L776 https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L881-L893

Vulnerability details

Impact

The StakedCitadel contract's balance() function is supposed to return the balance of the vault + the balance of the strategy. But, it only returns the balance of the vault. The balance is used to determine the number of shares that should be minted when depositing funds into the vault and the number of shares that should be burned when withdrawing funds from it.

Since most of the funds will be located in the strategy, the vault's balance will be very low. Some of the issues that arise from this:

You can't deposit to a vault that already minted shares but has no balance of the underlying token:

  1. fresh vault with 0 funds and 0 shares
  2. Alice deposits 10 tokens. She receives 10 shares back (https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L887-L888)
  3. Vault's tokens are deposited into the strategy (now balance == 0 and totalSupply == 10)
  4. Bob tries to deposit but the transaction fails because the contract tries to divide by zero: https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L890 (pool == balance())

You get more shares than you should

  1. fresh vault with 0 funds and 0 shares
  2. Alice deposits 10 tokens. She receives 10 shares back (https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L887-L888)
  3. Vault's tokens are deposited into the strategy (now balance == 0 and totalSupply == 10)
  4. Bob now first transfers 1 token to the vault so that the balance is now 1 instead of 0.
  5. Bob deposits 5 tokens. He receives 5 * 10 / 1 == 50 shares: https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L890

Now, the vault received 15 tokens. 10 from Alice and 5 from Bob. But Alice only has 10 shares while Bob has 50. Thus, Bob can withdraw more tokens than he should be able to.

It simply breaks the whole accounting of the vault.

Proof of Concept

The comment says that it should be vault's + strategy's balance: https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L291-L295

Here's another vault from the badger team where the function is implemented correctly: https://github.com/Badger-Finance/badger-vaults-1.5/blob/main/contracts/Vault.sol#L262

Tools Used

none

Add the strategy's balance to the return value of thebalance() function like here.

#0 - GalloDaSballo

2022-04-23T01:40:52Z

Agree balance must have been changed by mistake or perhaps earn should not transfer to a strategy either would work

Findings Information

🌟 Selected for report: Ruhum

Also found by: 0xBug, 0xDjango, IllIllI, MaratCerby, TrungOre, danb, hyh, m9800, minhquanym, pedroais, remora, shenwilly

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

184.248 USDC - $184.25

External Links

Lines of code

https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/Funding.sol#L177 https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/Funding.sol#L202 https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/Funding.sol#L184 https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L769

Vulnerability details

Impact

The Funding contract's deposit() function uses the getAmountOut() function to determine how many citadel tokens the user should receive for their deposit. But, if no discount is set, the function always returns 0. Now the deposit() function tries to deposit 0 tokens for the user through the StakedCitadel contract. But, that function requires the number of tokens to be != 0. The transaction reverts.

This means, that no deposits are possible. Unless there is a discount.

Proof of Concept

Funding.deposit() calls getAmountOut(): https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/Funding.sol#L177

Here's the getAmountOut() function:

    function getAmountOut(uint256 _assetAmountIn)
        public
        view
        returns (uint256 citadelAmount_)
    {
        uint256 citadelAmountWithoutDiscount = _assetAmountIn * citadelPriceInAsset;

        if (funding.discount > 0) {
            citadelAmount_ =
                (citadelAmountWithoutDiscount * MAX_BPS) /
                (MAX_BPS - funding.discount);
        }

        // unless the above if block is executed, `citadelAmount_` is 0 when this line is executed.
        // 0 = 0 / x
        citadelAmount_ = citadelAmount_ / assetDecimalsNormalizationValue;
    }

Call to StakedCitadel.depositFor(): https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/Funding.sol#L184

require statement that makes the whole transaction revert: https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L769

Tools Used

none

Change the getAmountOut() function to:

    function getAmountOut(uint256 _assetAmountIn)
        public
        view
        returns (uint256 citadelAmount_)
    {

        uint256 citadelAmount_ = _assetAmountIn * citadelPriceInAsset;

        if (funding.discount > 0) {
            citadelAmount_ =
                (citadelAmount_ * MAX_BPS) /
                (MAX_BPS - funding.discount);
        }

        citadelAmount_ = citadelAmount_ / assetDecimalsNormalizationValue;
    }

#0 - GalloDaSballo

2022-04-23T00:57:35Z

Agree, also dup

Report

Check 0-address consistently or not at all

There are several initialize() function where a couple inputs are checked for the 0-address and others are not. The usefulness is up to debate but if you decide to do it, you should do it consistently. For example, the address of the GlobalAccessControl contract is checked in some functions but not in others.

KnightingRound doesn't initialize ReentrancyGuard

The KnightingRound contract's initialize() function doesn't initialize the inherited ReentrancyGuard.

Relevant code:

Add the following to the initialize() function:

__ReentrancyGuard_init();

Funding should verify that minPrice is always smaller than maxPrice

The Funding contract's minCitadelPriceInAsset and maxCitadelPriceInAsset values can be set through the setCitadelAssetPriceBounds() function. There, the contract doesn't verify that min < max. If, by accident, min > max, subsequent calls to updateCitadelPriceInAsset() will trigger the citadelPriceFlag which will pause all deposits until the issue is resolved.

Relevant code:

Add the following to the setCitadelAssetPriceBounds() function:

function setCitadelAssetPriceBounds(uint256 _minPrice, uint256 _maxPrice)
    external
    gacPausable
    onlyRole(CONTRACT_GOVERNANCE_ROLE)
{
    require(_minPrice < _maxPrice);

    // ...
}

SupplySchedule contract is not ready for production

The SupplySchedule contract still has some things left from testing. In the Funding contract there's also a test function which is properly commented as such. The SupplySchedule contract doesn't have that so I thought it's worth mentioning here.

inherits DSTest library

The SupplySchedule contract inherits a library used for testing purposes: ds-test

sets hardcoded epoch rates on initialization

In its initialize() function it calls the _setEpochRates() function which uses hardcoded values

multiple unused imports

The file imports multiple contracts/libraries that are not used.

open TODO to constraint the viable epochs

There's an open TODO to constraint the provided epoch in the setEpochRate() function to be in the future.

StakedCitadel keeps the treasury fees in the vault instead of the strategy

When a user withdraws funds from StakedCitadel, a portion of it is allocated to the treasury in form of fees. If the vault doesn't have enough tokens, it withdraws the necessary amount from the strategy. The user receives their share of the Citadel tokens while the treasury receives their share in form of xCitadel tokens. The underlying Citadel tokens of the treasury's xCitadel tokens are kept in the vault. They are not deposited back in to the strategy.

So this is not an actual vulnerability. Just from an economic standpoint it would make more sense to keep the treasuries Citadel tokens in the strategy so they earn yield. The best thing would be to withdraw only the user's Citadel tokens while leaving the treasuries amount in the strategy. Then mint xCitadel tokens corresponding to the fee to the treasury.

Relevant code:

KnightingRound's sale start, duration, and price shouldn't be modifiable after the sale started

Currently, the governance contract can modify the KnightingRound's saleStart. saleDuration, tokenOutPrice, and tokenInLimit state variables.

It's not a vulnerability. But, I believe it would provide for trust in the contract if these critical parameters are immutable after a sale has started. Otherwise, the governance is able to prolong a sale indefinitely. Or change the price midway through.

As far as I have understood it, this is a one time sale. In that case, I don't see the value in being able to update those values after it has started.

Relevant code:

A simple check for whether a sale has started would be:

require(saleStart == 0 || saleStart > block.timestamp, "sale has already started");

Contracts don't check for fee-on-transfer tokens consistently

For example, the StakedCitadel contract does check for fee-on-transfer tokens. It uses the actual number of tokens it received for the subsequent computation: https://github.com/code-423n4/2022-04-badger-citadel/blob/main/src/StakedCitadel.sol#L773-L776

But, the KnightingRound and Funding contracts don't do that:

The assets will be determined by the deployer so again, not a direct vulnerability. But, as a best-practice I'd suggest adding the same check to the Funding and KnightingRound contracts.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter