Summary
If a user mows between two consecutive floods and then mows again after the season of the second flood, they will not receive SoP rewards related to the second flood. They will only receive SoP rewards related to the first flood.
However, if the user does not mow between the two consecutive floods, and only mows after the season of the second flood, they will gain SoP rewards related to both floods.
Vulnerability Details
If it rains in a season and the conditions P > 1 and pod rate < 5%
are met, there will be a flood in the next season. If these conditions are still met in the following season, there will be another flood, resulting in two consecutive floods.
When multiple consecutive floods occur, Beanstalk will consider them as a single flood event (i.e., a flood that lasts for multiple seasons).
The issue is that:
Suppose there is a flood in a season, and a user mows in that season. The user will receive the SoP rewards related to this flood. If there is another flood in the next season (resulting in consecutive floods) and the user mows in that season, the user will not receive the SoP rewards related to the second flood.
This issue arises because, in the function _mow
, it checks whether the last update is less than or equal to the rain start season.
If the user has mowed between two consecutive floods, the last update occurs after the rain start season. Consequently, when mowing after the second flood, this check fails, preventing the handleRainAndSops
function from being called again, and the user does not receive SoP rewards related to the second flood.
function _mow(address account, address token) external {
uint32 currentSeason = s.sys.season.current;
if (s.sys.season.rainStart > s.sys.season.stemStartSeason) {
if (lastUpdate <= s.sys.season.rainStart && lastUpdate <= currentSeason) {
LibFlood.handleRainAndSops(account, lastUpdate);
}
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L435
Note that an attacker can do a malicious act due to this vulnerability:
An attacker notices that there is going to be a second flood, then he calls the function mow
for the account of a victim in the season of first flood. This lets the victim get rewards for the first flood. But when the second flood comes and the victim mows again, they get no rewards. This way, the attacker stops the victim from getting rewards for the second flood.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/ClaimFacet.sol#L116
Test cases
In the following test case, there are two tests done:
first: mowing between two consecutive floods, loses the rewards in the second flood (user mows between seasons 8 and 9, then mows and claims plenty in season 9)
second: mowing after two consecutive floods, gain the rewards of both floods (user does not mow between seasons 8 and 9, only mows and claims plenty in season 9)
first: mowing between two consecutive floods, loses the rewards in the second flood
In this test the user deposits in season 5 and mows in season 6. So, the user's roots will be nonzero. Then in season 7, it starts raining. Then, there is a flood in season 8. In this season, the user mows. So, the function LibFlood.handleRainAndSops(account, lastUpdate)
would be called, where the SoP rewards of flood in season 8 are allocated to the user.
Then, the reserves are changed to have positive deltaB
in the next season, resulting in having SoP rewards in the next season again. This step is just to simulate a situation that still the conditions P > 1 and pod rate < %5
are met after the flood in season 8, so there will be another flood in season 9. Then, in season 9, the user mows. This time, the function LibFlood.handleRainAndSops(account, lastUpdate)
will not be called, because the last update season of the user is 8, while the rain start season is 7.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L435
Calling balanceOfPlenty
shows that the user is allocated 102382303659393812034
sop token, but when the function claimPlenty
is called, the user only receives 51191151829696906017
sop token. The received amount is equal to the SoP rewards related to the first flood (in season 8), and the SoP rewards in season 9 is not allocated to the user.
second: mowing after two consecutive floods, gain the rewards of both floods
In the following test the user deposits in season 5 and mows in season 6. So, the user's roots will be nonzero. Then in season 7, it starts raining. Then, there is a flood in season 8.
Then, the reserves are changed to have positive deltaB
in the next season, resulting in having SoP rewards in the next season again. This step is just to simulate a situation that still the conditions P > 1 and pod rate < %5
are met after the flood in season 8, so there will be another flood in season 9. Then, in season 9, the user mows. So, the function LibFlood.handleRainAndSops(account, lastUpdate)
will be called, because the last update season of the user is 6, while the rain start season is 7.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L435
Calling balanceOfPlenty
shows that the user is allocated 102382303659393812034
sop token, and when the function claimPlenty
is called, the user receives 102382303659393812034
sop token. The received amount is equal to the SoP rewards related to sum of both floods (in season 8 and 9).
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");
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 mockBeanstalk.siloSunrise(0);
await bean.connect(user).approve(beanstalk.address, "100000000000");
await bean.connect(user2).approve(beanstalk.address, "100000000000");
await bean.mint(user.address, to6("10000"));
await bean.mint(user2.address, to6("10000"));
[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 mockBeanstalk.siloSunrise(0);
await mockBeanstalk.captureWellE(this.well.address);
await setEthUsdChainlinkPrice("1000");
await setStethEthChainlinkPrice("1000");
await setStethEthChainlinkPrice("1");
await setWstethEthUniswapPrice("1");
await this.well.setReserves([to6("1000000"), to18("1100")]);
await this.pump.setInstantaneousReserves(this.well.address, [to6("1000000"), to18("1100")]);
await mockBeanstalk.siloSunrise(0);
await mockBeanstalk.siloSunrise(0);
});
beforeEach(async function () {
snapshotId = await takeSnapshot();
});
afterEach(async function () {
await revertToSnapshot(snapshotId);
});
describe("consecutive floods scenarios", async function () {
it("mowing between two consecutive floods, loses the rewards in the second flood", async function () {
console.log("current season: ", await beanstalk.season());
await beanstalk.connect(user).deposit(bean.address, to6("1000"), EXTERNAL);
await mockBeanstalk.siloSunrise(0);
await beanstalk.mow(user.address, bean.address);
await mockBeanstalk.rainSunrise();
const rain = await beanstalk.rain();
let season = await beanstalk.time();
console.log("season.rainStart: ", season.rainStart);
console.log("season.raining: ", season.raining);
console.log("rain.roots: ", rain.roots);
await mockBeanstalk.rainSunrise();
await beanstalk.mow(user.address, bean.address);
await this.well.setReserves([to6("1000000"), to18("1100")]);
await this.pump.setInstantaneousReserves(this.well.address, [to6("1000000"), to18("1100")]);
await mockBeanstalk.rainSunrise();
await beanstalk.mow(user.address, bean.address);
const balanceOfPlenty = await beanstalk.connect(user).balanceOfPlenty(user.address, this.well.address);
console.log("user's balanceOfPlenty: ", balanceOfPlenty);
await beanstalk.connect(user).claimPlenty(this.well.address, EXTERNAL);
console.log("balance: ", await this.weth.balanceOf(user.address));
expect(await this.weth.balanceOf(user.address)).to.be.equal(balanceOfPlenty);
});
});
it("mowing after two consecutive floods, gain the rewards of both floods", async function () {
console.log("current season: ", await beanstalk.season());
await beanstalk.connect(user).deposit(bean.address, to6("1000"), EXTERNAL);
await mockBeanstalk.siloSunrise(0);
await beanstalk.mow(user.address, bean.address);
await mockBeanstalk.rainSunrise();
const rain = await beanstalk.rain();
let season = await beanstalk.time();
console.log("season.rainStart: ", season.rainStart);
console.log("season.raining: ", season.raining);
console.log("rain.roots: ", rain.roots);
await mockBeanstalk.rainSunrise();
await this.well.setReserves([to6("1000000"), to18("1100")]);
await this.pump.setInstantaneousReserves(this.well.address, [to6("1000000"), to18("1100")]);
await mockBeanstalk.rainSunrise();
await beanstalk.mow(user.address, bean.address);
const balanceOfPlenty = await beanstalk.connect(user).balanceOfPlenty(user.address, this.well.address);
console.log("user's balanceOfPlenty: ", balanceOfPlenty);
await beanstalk.connect(user).claimPlenty(this.well.address, EXTERNAL);
console.log("balance: ", await this.weth.balanceOf(user.address));
expect(await this.weth.balanceOf(user.address)).to.be.equal(balanceOfPlenty);
});
})
The output result of tests are:
current season: 5
season.rainStart: 7
season.raining: true
rain.roots: BigNumber { value: "2000000000000000000000" }
user's balanceOfPlenty: BigNumber { value: "102382303659393812034" }
balance: BigNumber { value: "102382303659393812034" }
✔ mowing after two consecutive floods, gain the rewards of both floods (108ms)
consecutive floods scenarios
current season: 5
season.rainStart: 7
season.raining: true
rain.roots: BigNumber { value: "2000000000000000000000" }
user's balanceOfPlenty: BigNumber { value: "102382303659393812034" }
balance: BigNumber { value: "51191151829696906017" }
1) mowing between two consecutive floods, loses the rewards in the second flood
1 passing (9s)
1 failing
1) Sop Test Cases
consecutive floods scenarios
mowing between two consecutive floods, loses the rewards in the second flood:
AssertionError: Expected "51191151829696906017" to be equal 102382303659393812034
+ expected - actual
{
- "_hex": "0x058cd702bbc8580642"
+ "_hex": "0x02c66b815de42c0321"
"_isBigNumber": true
}
Impact
Loss of SoP rewards, if the user (or the attacker maliciously) mows between two consecutive floods.
Tools Used
Recommendations
One possible solution is to track the consecutive floods. If it is happening, then during the mowing, the last update season should be compared with the previous SoP season, not the the rain start season. In other words, it should be viewed as if the rain start season of the second flood is equal to the first flood season.
The modifications are as follows:
struct System {
bool paused;
uint128 pausedAt;
uint256 reentrantStatus;
uint256 isFarm;
address ownerCandidate;
uint256 plenty;
uint128 soil;
uint128 beanSown;
uint256 activeField;
uint256 fieldCount;
bytes32[16] _buffer_0;
mapping(uint256 => mapping(uint256 => bytes32)) podListings;
mapping(bytes32 => uint256) podOrders;
mapping(IERC20 => uint256) internalTokenBalanceTotal;
mapping(address => bytes) wellOracleSnapshots;
mapping(address => TwaReserves) twaReserves;
mapping(address => uint256) usdTokenPrice;
mapping(uint32 => uint256) sops;
mapping(uint256 => Field) fields;
mapping(uint256 => ConvertCapacity) convertCapacity;
ShipmentRoute[] shipmentRoutes;
bytes32[16] _buffer_1;
bytes32[144] casesV2;
Silo silo;
Field field;
Fertilizer fert;
Season season;
Weather weather;
SeedGauge seedGauge;
Rain rain;
Migration migration;
bytes32[128] _buffer_2;
mapping(address => Implementation) oracleImplementation;
SeedGaugeSettings seedGaugeSettings;
SeasonOfPlenty sop;
uint32 consecutiveFloods.
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/storage/System.sol#L42
function rewardSop(address well, uint256 amount, address sopToken) private {
AppStorage storage s = LibAppStorage.diamondStorage();
s.sys.sop.sops[s.sys.season.rainStart][well] = s
.sys
.sop
.sops[s.sys.season.lastSop][well].add(amount.mul(C.SOP_PRECISION).div(s.sys.rain.roots));
if(s.sys.season.lastSopSeason == s.sys.season.current - 1){
s.sys.consecutiveFloods = true;
} else {
s.sys.consecutiveFloods = false;
}
s.sys.season.lastSop = s.sys.season.rainStart;
s.sys.season.lastSopSeason = s.sys.season.current;
s.sys.sop.plentyPerSopToken[sopToken] += amount;
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L320
function handleRain(uint256 caseId) external {
AppStorage storage s = LibAppStorage.diamondStorage();
if (caseId.mod(36) < 3 || caseId.mod(36) > 8) {
if (s.sys.season.raining) {
s.sys.season.raining = false;
}
if(s.sys.consecutiveFloods){
s.sys.consecutiveFloods = false;
}
return;
} else if (!s.sys.season.raining) {
s.sys.season.raining = true;
address[] memory wells = LibWhitelistedTokens.getCurrentlySoppableWellLpTokens();
uint32 season = s.sys.season.current;
uint32 rainstartSeason = s.sys.season.rainStart;
for (uint i; i < wells.length; i++) {
s.sys.sop.sops[season][wells[i]] = s.sys.sop.sops[rainstartSeason][wells[i]];
}
s.sys.season.rainStart = s.sys.season.current;
s.sys.rain.pods = s.sys.fields[s.sys.activeField].pods;
s.sys.rain.roots = s.sys.silo.roots;
} else {
floodPodline();
if (s.sys.rain.roots > 0) {
(
WellDeltaB[] memory wellDeltaBs,
uint256 totalPositiveDeltaB,
uint256 totalNegativeDeltaB,
uint256 positiveDeltaBCount
) = getWellsByDeltaB();
wellDeltaBs = calculateSopPerWell(
wellDeltaBs,
totalPositiveDeltaB,
totalNegativeDeltaB,
positiveDeltaBCount
);
for (uint i; i < wellDeltaBs.length; i++) {
sopWell(wellDeltaBs[i]);
}
}
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L55
function _mow(address account, address token) external {
AppStorage storage s = LibAppStorage.diamondStorage();
uint32 lastUpdate = _lastUpdate(account);
uint32 currentSeason = s.sys.season.current;
uint32 rainStart;
if (s.sys.season.rainStart > s.sys.season.stemStartSeason) {
if(s.sys.consecutiveFloods){
rainStart = s.sys.season.lastSopSeason;
} else {
rainStart = s.sys.season.rainStart;
}
if (lastUpdate <= rainStart && lastUpdate <= currentSeason) {
LibFlood.handleRainAndSops(account, lastUpdate);
}
}
if (lastUpdate < currentSeason) {
LibGerminate.endAccountGermination(account, lastUpdate, currentSeason);
}
__mow(account, token);
s.accts[account].lastUpdate = currentSeason;
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L425