šŸ“„

Report #13738

Report Date
November 19, 2022

Bug in Root Token Implementation

ā€£
Report Info

Report ID

#13738

Target

(Out of scope)

Report type

Smart Contract

Impacts

  • Theft of unclaimed yield
  • Smart contract unable to operate due to lack of token funds
  • Passive Loss of User FundsĀ (Out of scope)
  • Unbounded Degredation of Token EconomicsĀ (Out of scope)

Has PoC?

Yes

Bug Description

I discovered an issue in the implementation of the Root token. This bug would enable a malicious actor to intentionally extract more Beans from the underlying Root pool than they are entitled to. Alternatively, if this bug is not resolved damage will passively occur via incorrect distribution of value. The magnitude of the faulty logic will slowly grow with time. Eventually I suspect the issue will grow large enough to be detected by daily use (in maybe 3-6 months), though at that time the loss in funds may be too large to correct. I believe it is very likely that the bug has already caused minor misallocations of funds.

Note

I see that the Root token is not covered by this bug bounty. Therefore, I would like to negotiate terms publicly here ahead of releasing the details. Either with the Beanstalk team or with the Root team.

Details

This issue undermines the value all Root tokens (current supply: ~163,000, valued at ~$1 each). The issue could manifest through active exploitation or passive degradation. Due to certain limits, the scale of the active exploitation is limited by the size of the exploiters initial capital. I believe the greater threat is passive degradation, which will likely result in complete failure of the token if it goes unresolved for too long.

Intentional Exploitation: Direct theft of tokens would not be possible, instead a malicious actor would be stealing the underlying BDV (both deposited and unclaimed yield). Theoretically all underlying value could be taken, but it would require time and significant capital (could not be executed by a flashloan). The amount of theft that could take place would be time limited. Slowly increasing as the problem grows.

Passive Exploitation: If no malicious actor sets out to take advantage of this issue, it will likely affect daily users of the Token by missallocating their value. In an extreme situation, if a bank run occurred it is possible that all underlying BDV is gone before all Root tokens are redeemed.

Terms

This report is not technically in scope. So we will need to agree on a price and conditions in the presence of the Immunifi team before I can release more information.

Strictly speaking, this issue is a High priority issue (with relatively low amount of funds) due to the intentional theft of unclaimed yield. Secondary to that, it qualifies as a much more important Medium issue due to contract potentially not having enough funds to operate. Finally, and most importantly, this issue is subjectively critical because it may cause an unbounded decline in the underlying BDV of the Root token. This last point is not properly covered by the terms though.

Technical Terms:

A) Before I release the details of the issue Beanstalk must agree to consider Root contract in scope

B) I shall not be penalized for the lack of PoC, if Beanstalk wants the details upfront. Else we will all wait for development to complete.

C) Beanstalk and Immunifi can determine a fair bounty after you see the issue properly, but it will be no lower than 10,000 Beans, given the following conditions are true:

  1. The submissions reveals a genuine issue.
  2. Said issue results in incorrect distribution of value underlying Roots (regardless of whether the UI hides the issue).
  3. Incorrect distribution could be intentionally or unintentionally triggered.

Impact

Root Token and anything built on top of it (Paragon)

Risk Breakdown

Difficulty to Exploit: Easy to do intentionally and passively unavoidable

Weakness: Root Token

Recommendation

Immediately take down Root UI, fix the contract logic, update contract.

Proof of concept

I will put together a simple script that demonstrates the issue and provide the solidity code to fix it.

A PoC of the active exploitation method is not necessary or prudent. I can do it if it will affect the reward, but you will need to wait while I put the infrastructure together. I am not a seasoned whitehat, nor have I worked my way through all of the relevant Beanstalk logic, so I do not know how long it will take. I will begin this afternoon. However, considering the urgency of the situation, I think it is not the ideal use of time. The bug is provable without a PoC.

In addition to the reasons stated above, this is a reason for us to publicly agree on conditions and payment before moving forward. I am asking for an explicit pre-agreed exception to the PoC requirement/penalty for the sake of time. It is not necessary to understand the problem.

However, if you are not willing to lift the requirement I will begin writing it as soon as possible and will return here to share more details once it is ready.

BIR-4: Root Token Redemptions

BIC Response

(A) The intention is to add the Root token as in-scope as soon as possible, so we are of course happy to consider any bug reports assuming the Root token is in-scope in the meantime.

(B) It is impossible to assess the validity of a bug report without knowing what the bug is. That said, what we primarily hope to receive in terms of a "PoC" is simply a clear set of instructions for reproducing the bug. We are happy to forego the "code implementing the fix" requirement if you can provide this.

(C) We are in agreement that an issue that results in the theft of unclaimed yield and/or the incorrect distribution of value underlying Roots would result in a bounty of at least 10,000 Beans.

Please share the details of the report as soon as possible.

Reporter Response

The fundamental issue is that the calculation of the Roots to redeem in the Solidity code is incorrect, assuming the whitepaper to be the source of truth.

In Section 3.3 of the whitepaper we can see that the amount of Root to redeem for a set of deposits is derived from theĀ maxĀ ratio change in BDV, Stalk or Seeds. In the solidity code, the Roots to redeem is inadvertently being derived from theĀ minĀ ratio. This is because the numerator is using after-transaction values for BDV/Stalk/Seeds, rather than deposit values for BDV/Stalk/Seeds, which changes how the values are conceptually being represented. The same Solidity math logic is used for Mint, but does not cause a problem in the Minting function because the deposit deltas are positive.

The faulty logic is seen in the _transferDeposits() function of the Root.sol contract (~line 529).Ā https://github.com/RootToken/Root/blob/master/Root.sol

As a result, users are able to redeem deposits for less root than they otherwise would. This means they can extract an outsized portion of the BDV. In fact, assuming that the stalk:BDV ratio has been >1, we can assume most redemptions up to this point probably received slightly more BDV than they were supposed to. As the underlying ratio of Stalk:BDV increases, the excess BDV distributed in each transaction will increase. In this way, the issue will gradually scale, eating away at the underlying BDV.

I believe the passive degradation described above is the biggest problem, but this issue could also be exploited intentionally by a malicious actor. Given a set of deposits with accumulated seeds, and assuming the underling Root pool has a excess of Stalk over BDV, a user could mint and then immediately redeem their root for a significantly larger amount of BDV.

I have attached a simple proof of concept here built using your SDK. It is quite messy, but it is what I could do on short notice working with a new library. You can replace theĀ packages/examples/src/test.tsĀ file in the SDK with this test file and run it usingĀ yarn x src/test.ts. When you run it you will see that the Roots the contract redeems matches the amount calculated using theĀ minĀ rather than theĀ max. See console output lines:

redeem roots (from *max* ratio):

redeem roots (from *min* (incorrect) ratio):

11 shares

// import { BeanstalkSDK } from "@beanstalk/sdk"
import { sdk, chain, account } from "./setup"
import { DepositTransferStruct } from "../../sdk/src/constants/generated/Beanstalk/Root";
import { FarmFromMode, FarmToMode, TokenValue, TestUtils, DataSource, Token, ERC20Token } from "@beanstalk/sdk";
import { MAX_UINT256 } from "@beanstalk/sdk/dist/types/constants";
import { parseBytes32String, toUtf8String } from "ethers/lib/utils";
// import { getTestUtils } from "../../sdk/src/utils/TestUtils/provider";
// const { sdk, account, utils } = getTestUtils();


// PROOF OF CONCEPT
// Notice that the amount of Root redeemed is the same as the amount calculated using the
// MINIMUM ratio, rather than the MAXIMUM ratio.



async function main() {
    // const bal = await sdk.tokens.ETH.getBalance(account)
    // console.log(bal.toHuman())

    // await chain.setROOTBalance(account, sdk.tokens.ROOT.amount(30000))
    await chain.setAllBalances(account, "700000");
    // var result = await chain.setAllBalances(account, "500000");
    console.log(await (await sdk.tokens.BEAN.getBalance(account)).toHuman())
    console.log(await (await sdk.tokens.USDT.getBalance(account)).toHuman())
    console.log(await (await sdk.tokens.ROOT.getBalance(account)).toHuman())

    // Get Root deposits.
    // Taken from subgraph
    var high_stalk_deposits = [
        {
        "season": 5735,
        "bdv": "1920240808",
        "token": "0xbea0000029ad1c77d3d5d23ba2d8893db9d1efab",
        "farmer": {
            "id": "0x77700005bea4de0a78b956517f099260c2ca9a26"
        }
        },
        {
        "season": 5948,
        "bdv": "83725855",
        "token": "0xbea0000029ad1c77d3d5d23ba2d8893db9d1efab",
        "farmer": {
            "id": "0x77700005bea4de0a78b956517f099260c2ca9a26"
        }
        }
    ]

    var low_stalk_deposits = [
        {
          "season": 8594,
          "bdv": "60404470",
          "token": "0xbea0000029ad1c77d3d5d23ba2d8893db9d1efab",
          "farmer": {
            "id": "0x77700005bea4de0a78b956517f099260c2ca9a26"
          }
        },
        {
          "season": 8593,
          "bdv": "381219575",
          "token": "0xbea0000029ad1c77d3d5d23ba2d8893db9d1efab",
          "farmer": {
            "id": "0x77700005bea4de0a78b956517f099260c2ca9a26"
          }
        }
    ]

    var deposit_season = 8593
    var deposit_amount = 100000000 // sdk.tokens.BEAN.fromHuman(1000)

    const [currentSeason, estimatedDepositBDV] = await Promise.all([
        sdk.sun.getSeason(),
        sdk.silo.bdv(sdk.tokens.BEAN, sdk.tokens.BEAN.fromBlockchain(100000000))
    ]);

    var deposit_crates = [
    sdk.silo.makeDepositCrate(sdk.tokens.BEAN, deposit_season, "100000000", estimatedDepositBDV.toBlockchain(), currentSeason)
    ]
    var deposit_transfer:DepositTransferStruct = {
        token: sdk.tokens.BEAN.address,
        seasons: [deposit_season],
        amounts: [100000000]
      }

    var estimated_redeem_roots = await sdk.root.estimateRoots(sdk.tokens.ROOT, deposit_crates, false)

    var root_supply:TokenValue = await sdk.tokens.ROOT.getTotalSupply()
    var root_balance = await sdk.tokens.BEAN.getBalance(sdk.contracts.root.address) // Internal and External
    var account_root_balance = await sdk.tokens.ROOT.getBalance(account)
    console.log("Account balance: " + account_root_balance)



    const [rootUnderlyingBdvBefore, rootStalkBefore, rootSeedsBefore] = await Promise.all([
        sdk.root.underlyingBdv(),
        sdk.silo.balanceOfStalk(sdk.contracts.root.address, true), // include grown
        sdk.silo.balanceOfSeeds(sdk.contracts.root.address)
    ]);

    const {
        bdv: totalBdvFromDeposits,
        stalk: totalStalkFromDeposits,
        seeds: totalSeedsFromDeposits
      } = sdk.silo.sumDeposits(sdk.tokens.BEAN, deposit_crates);

    var bdv_ratio = totalBdvFromDeposits.div(rootUnderlyingBdvBefore)
    var stalk_ratio = totalStalkFromDeposits.div(rootStalkBefore)
    var seed_ratio = totalSeedsFromDeposits.div(rootSeedsBefore)

    var max_ratio = Math.max(parseFloat(bdv_ratio.toHuman()), parseFloat(stalk_ratio.toHuman()), parseFloat(seed_ratio.toHuman()));
    var min_ratio = Math.min(parseFloat(bdv_ratio.toHuman()), parseFloat(stalk_ratio.toHuman()), parseFloat(seed_ratio.toHuman()));
    console.log("\n\n\n\nmax ratio: " + max_ratio)
    console.log("mix ratio: " + min_ratio)

    console.log("redeem roots (from *max* ratio): " + (root_supply.mul(Math.max(parseFloat(bdv_ratio.toHuman()), parseFloat(stalk_ratio.toHuman()), parseFloat(seed_ratio.toHuman())))).toBlockchain())
    console.log("redeem roots (from *min* (incorrect) ratio): " + (root_supply.mul(Math.min(parseFloat(bdv_ratio.toHuman()), parseFloat(stalk_ratio.toHuman()), parseFloat(seed_ratio.toHuman())))).toBlockchain())
    console.log("\n\n\n\n")


    var txn = await sdk.contracts.root.redeem([deposit_transfer], FarmToMode.EXTERNAL, account_root_balance.toBigNumber())
    // console.log(txn)
    const receipt = await txn.wait();
    console.log("Transaction executed");


    // const [rootUnderlyingBdvAfter, rootStalkAfter, rootSeedsAfter] = await Promise.all([
    //     sdk.root.underlyingBdv(),
    //     sdk.silo.balanceOfStalk(sdk.contracts.root.address, true), // include grown
    //     sdk.silo.balanceOfSeeds(sdk.contracts.root.address)
    // ]);


    TestUtils.Logger.printReceipt([sdk.contracts.root], receipt);

}


main()

BIC Response

Although the Root token was not previously defined as in-scope, the BIC has decided due to the combination of the following reasons to offer a bounty for discovery of the bug and formally include the Root token contract in the Immunefi bug bounty program moving forward:

  • The BIC had already determined to include the contract in the bug bounty program, but had not formalized it;
  • The contract was audited by Halborn; and
  • The contract has already started to attract a significant amount of Beans/BDV.

As a result of this vulnerability, Root holders were able to Redeem for Bean Deposits with fewer Roots than they otherwise would. However, the scale at which this vulnerability could manifest itself was marginal. About ~8,500 Roots had been Redeemed across 12 transactions and an additional ~226 Beans were received from those Redemptions than expected.

Given that:

  • It would require time and significant capital to steal any meaningful portion of the underlying BDV of Root;
  • The current underlying BDV of Root is about 165k;
  • Extrapolating the loss of Beans in past Redemptions due to this vulnerability to the current underlying BDV results inĀ (165,000 * 226) / 8500 = ~4387Ā Beans; and
  • The minimum reward for High Impact (Theft of unclaimed yield) is 10,000 Beans:

The BIC determined that this bug report be rewarded 10,000 Beans.