Manipulating the Total Stalk/Roots Ratio for Gain.
Report ID
#27979
Report type
Smart Contract
Has PoC?
Yes
Target
https://etherscan.io/address/0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5
Impacts
- Theft of unclaimed yield
- Illegitimate minting of protocol native assets
- Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
This report consists of 4 different bugs, each has a certain impact but when combined together, they are quite devastating and that is why I chose to combine it all to a single report
Bug 1
Protocol's tolerance in allowing withdrawals even when a user has a roots debt.
Impacts
- Contributes to Bug 3 And Bug 4 severity Most Important
- A malicious actor can mint extra roots (Illegitimate Minting)
- The protocol's
stalk/rootsbalance can be damaged balanceOfPlentyis impacted, as it depends on root ownership.- Long-term, users receive fewer or no rewards due to inflated
totalRoots.
Root Cause
Let's examine the burnStalk function used during withdrawals more closely:
When the protocol calculates the required roots as being greater than the roots held by the account, it adjusts the roots to match the amount the account owns, essentially accepting a roots debt.
This creates a loophole. For example, if User A exploits a method (like the Stalk Inflate bug which will be detailed later as Bug 3) to transfer some of their roots to User B without transferring the corresponding stalk, a problem arises during withdrawals. The roots debt incurred by User A remains within the protocol. As a result, these roots are effectively and illegitimately minted to User B, bypassing the intended safeguards of the system.
Bug 2
Vesting period Returns true When it Shouldn't
Impact
Even in seasons where there are no new Earned Beans, the vesting period returns true, and ewEarnedStalk remains positive. Consequently, this allows the attacks possible via Bug 3 and Bug 4 to be carried out in any season, regardless if it's above or below the peg.
Root Cause
Based on the docs, the function inVestingPeriod()
Returns if Earned Beans from the previous gm call are still vesting. Vesting Earned Beans cannot be received via plant until the Vesting Period is over, and will be forfeited if a Farmer Withdraws during the Vesting Period.
However, a closer examination of the code reveals a discrepancy:
function inVestingPeriod() internal view returns (bool) {
AppStorage storage s = LibAppStorage.diamondStorage();
return block.number - s.season.sunriseBlock <= VESTING_PERIOD;
}This implementation shows that inVestingPeriod merely checks if the elapsed block count since the last gm call is less than VESTING_PERIOD, disregarding whether any new Earned Beans were accrued in the latest gm call.
Bug 3
Stalk Inflation
Impacts
- A malicious actor could gain voting power they don't deserve. (Voting Manipulation)
- A malicious actor could exploit this to steal all of the
Earned Beans. (Theft of Yield) - A malicious actor can mint extra stalk without minting roots.
- Minting of Roots (By using
Bug 1)
Root Cause
During the vesting period, there is an inconsistency in the number of roots minted when depositing to the protocol compared to the number of roots burnt when withdrawing. When depositing, roots are minted as follows in the mintStalk function:
function mintStalk(address account, uint256 stalk) internal {
// ...
// Calculate the amount of Roots for the given amount of Stalk.
uint256 roots;
if (s.s.roots == 0) {
roots = uint256(stalk.mul(C.getRootsBase()));
} else {
roots = s.s.roots.mul(stalk).div(s.s.stalk);
}
// ...
}The formula for minting roots is roots = (totalRoots * stalk) / totalStalk, where stalk is the deposit amount (for BEAN it will always be times 10000 of the deposited BEAN amount).
When withdrawing:
the formula for burning roots is roots = min(accountRoots, ceil((totalRoots * stalk) / (totalStalk - newEarnedStalk))), which is affected by the newEarnedStalk, and capped by the roots owned by the account.
This creates a discrepancy when newEarnedStalk > 0, requiring less stalk to burn the same amount of roots previously minted. The formula to determine the stalk needed for this is inputStalk = (accountRoots * (totalStalk - newEarnedStalk) / totalRoots.
The Attack Vectors
The 2 possible Attack Vectors are:
- Gain extra Voting Power. (Voting Manipulation)
- Theft of
Earned Beans. (Theft of Yield)
Both vectors involve the same core approach:
By depositing and withdrawing only the required amount to burn previously generated roots,
an attacker can gradually increase the total stalk without changing the
total roots. This undermines a fundamental principle of the protocol:
* Total Roots User Roots
* ------------- = ------------
* Total Stalk User StalkInflate balanceOfEarnedBeans
The balanceOfEarnedBeans function calculates earned beans as follows:
EarnedBeans = (TotalStalk * userRoots / TotalRoots) - userStalkTo maximize EarnedBeans, one can:
- Increase
totalStalk-> achievable through the core issue. - Increase
userRoots-> also exploitable via the same issue. - Decrease
totalRoots - Decrease
userStalk-> also exploitable via the same issue.
Increase totalStalk
This can be done by repeating the core exploit with sufficient capital, a flashloan can be used for the needed capital.
Decrease userStalk / Increase userRoots
By exploiting the totalStalk increase, we can also generate roots for ourselves.
This is due to the calculation of roots to transfer during a transfer:
The formula for the transferred roots is approximately (totalRoots * stalk) / (totalStalk - newEarnedStalk)). So if totalStalk increases, we'll send to the receiver less roots for the same amount of stalk, allowing an attacker to have more roots for the same amount of stalk (or less stalk for the same amount of roots, depends on how you see it).
In contrast to the totalStalk minted before, now we don't need to burn the roots to pay the flashloan back, as we can withdraw all of our deposit except for 1 stalk to keep the roots. So the illegally minted roots can remain at the attacker possession.
Voting power gain
The first step is different depending on if the attacker uses Bug 1 as well
- An attacker calls a flashloan for amount
X + YOR An attacker calls a flashloan for amountXand has a capital onY - Deposits amount
Y(This will be used for the illegal extra roots) - Transfers
Xto a different address controlled by the attacker (smart contract A) - Smart contract A deposits
Xto Beanstalk - Smart contract A withdraws
(accountRoots * (totalStalk - newEarnedStalk) / totalRoots * 10000from Beanstalk into their internal balance (GAS optimization) - Repeat steps 4-5 as much as needed
- Now the
totalStalkis inflated - Transfer
Y - 1from the attackers deposit to a different address controlled by the attacker (smart contract B) - Withdraw
Y - 1and Repeat steps 2-9 as much as needed - Withdraw from all of the attacker contracts and repay the flashloan (1 wei of BEAN remains deposited in the attackers deposit to keep the minted roots)
Now the attacker has in Smart Contract B when calling the function balanceOfEarnedBeans some big number while only having a deposit of a single wei of Bean. Upon a thorough examination of the voting [Strategies](https://snapshot.org/#/beanstalkdao.eth/settings) implemented by the DAO on Snapshot, the strategy uses the function balanceOfStalk to determine a user's voting power. Here's how the function is defined:
function balanceOfStalk(address account) public view returns (uint256) {
return s.a[account].s.stalk.add(balanceOfEarnedStalk(account));
}Let's dive into the balanceOfEarnedStalk function:
function balanceOfEarnedStalk(address account)
public
view
returns (uint256)
{
return balanceOfEarnedBeans(account).mul(C.STALK_PER_BEAN);
}Via this attack vector, it is possible to inflate the return value of balanceOfEarnedBeans. This inflation, in turn, leads to an unjustified increase in the voting power for a user, resulting in voting manipulation that does not reflect the legitimate stake or contribution of the user in question.
Bug 1 Contribution
The first step is different depending on if the attacker uses Bug 1 as well
Without Bug 1
Without Bug 1 the attacker can't withdraw their own funds after transferring it to Smart Contract B, So the gain in Voting Power in capped by the attacker's own capital.
With Bug 1
With Bug 1 the attacker can withdraw their funds after transferring it to Smart Contract B, thus able to use the flashloan funds for the Voting Power gained, enabling the attacker to repeat this attack and gain infinite voting power!
Making this flow much more serious! And Critical
Theft of Earned Beans pseudo example
- An attacker calls a flashloan for amount
X + Y - Deposits amount
Y(This will be used for the illegal extra roots) - Transfers
Xto a different address controlled by the attacker (smart contract A) - Smart contract A deposits
Xto Beanstalk - Smart contract A withdraws
(accountRoots * (totalStalk - newEarnedStalk) / totalRoots * 10000from Beanstalk into their internal balance (GAS optimization) - Repeat steps 4-5 as much as needed
- Now the
totalStalkis inflated - Transfer
Y - 1from the attackers deposit to a different address controlled by the attacker (smart contract B) - Withdraw
Y - 1and Repeat steps 2-9 as much as needed - Call
plantand enjoy the stolen Earned Beans. - Withdraw from all of the attacker contracts and repay the flashloan (1 wei of BEAN remains deposited in the attackers deposit to keep the minted roots)
Bug 1 Contribution
There is a difference in how the attacker withdraws their funds in step 11 based on if they use or don't use Bug 1.
Withdrawing without Bug 1
- Smart contract B transfers their 1 wei of bean, all stalk and all roots to the attacker (So the attacker won't run into root debt issues when withdrawing to repay the debt)
- Attacker withdraws all of the deposit and repays the flashloan.
No extra Roots were minted in this process
Withdrawing with Bug 1
- Attacker withdraws all of the deposit except for the 1 wei of bean, and repays the flashloan.
Roots were minted in this process
Making this flow much more serious!!!
Bug 4
Change User's Root/Stalk ratio to profit
Impacts
- Gain of Additional Voting Power (Voting Manipulation)
- Theft of
Earned Beans - Minting of Roots (By using
Bug 1)
Root Cause
There is an inconsistency in the number of roots transfer during and after the Vesting Period:
During Vesting Period The formula for the transferred roots is approximately (totalRoots * stalk) / (totalStalk - newEarnedStalk)). After Vesting Period The formula for the transferred roots is approximately (totalRoots * stalk) / totalStalk).
Since the denominator after the Vesting Period in bigger, a user will transfer less roots for the same amount of stalk after the Vesting Period.
The Attack Vector
The attacker can abuse the discrepancy between the roots transferred during a vesting period and. Here's a step-by-step breakdown:
- Attacker A deposits amount
Xduring vesting period. - Attacker A transfers a portion of their deposit
X - Yduring vesting period. - Attacker B transfers amount
X - Y - 1after the vesting period. Attacker B returns fewer roots than they initially received.
With Bug 1
- Attacker A withdraws the entire initial amount
X. In doing so, the protocol inadvertently resolves Attacker A'srootsdebt. - As a consequence of the withdrawal in step 4, the
rootsdebt that was resolved by the protocol is effectively transferred to Attacker B, thereby enabling them to illegitimately mint roots
This attack is now repeatable in every season.
Voting Power Gain
After step 3, Attacker B has a balanceOfEarnedBeans greater than they should since we decreased their userStalk variable, and together with Attacker A stalk, they gained extra Voting Power.
Theft
After step 3, Attacker B has a balanceOfEarnedBeans greater than they should, Attacker B can call plant and steal the additional Earned Beans.
Suggested fixes
This is based on my current understanding of the protocol. Some issues may require more complex fixes
Bug 1
The root of this problem was introduced as part of [ebip-0](https://github.com/BeanstalkFarms/Beanstalk/pull/80/files#diff-fbd431ce899b0679e919b2bf1fa828e50eeddd381e4efd2012eca396bb7758daR95). Given its complexity, finding a solution might be challenging, as we need to consider the protocol desired behavior.
A potential solution could involve standardizing the calculation of roots to be transferred in the transferStalk function and during mint and burn of stalk to ensure consistency.
Bug 2
A straightforward fix would be to include a check for the current season's abovePeg status.
The revised function would be:
function inVestingPeriod() internal view returns (bool) {
AppStorage storage s = LibAppStorage.diamondStorage();
return (block.number - s.season.sunriseBlock <= VESTING_PERIOD) && s.season.abovePeg;
}Bug 3
The Root Cause here is the inconsistency in the number of roots minted when depositing to the protocol compared to the number of roots burnt when withdrawing. To address this, I recommend:
- Reducing the
rootsminted during deposits in the Vesting Period. - Disallowing withdrawals during the Vesting Period.
Additionally, I suggest implementing preventing withdrawals or transfers in the same block as a deposit. This precaution could significantly reduce the risk of potential flashloan attacks.
Bug 4
The Root Cause here is the inconsistency in the number of roots transferred during and after Vesting Period. To address this, I recommend Disallowing transfers during the Vesting Period.
POC
There is a function in this test to demonstrate each bug and each bug combination
List of Tests
- testBug3 -> Demonstrates Bug 3 (Stalk Inflation)
- testBug3WithBug1 -> Demonstrates Bug 3 (Stalk Inflation with Roots minting)
- testBug4 -> Demonstrates Bug 4 (User Roots/Stalk ratio)
- testBug4WithBug1 -> Demonstrates Bug 4 (User Roots/Stalk ratio with Roots minting)
Gas Costs
Bug 3 will cost more gas as more profit is gained, to address that I've made some gas/profit calculations and some gas optimizations:
Gas Optimizations
- Utilizing a single account for the
Stalk Inflationattack (rather than multiple accounts with fund transfers). - Leveraging Beanstalk's Internal Balances to reduce gas costs for
approveandtransferFromoperations.
Gas Calculations
My test results indicates that an attacker could potentially gain a profit of $1523 Beans for 5M gas. To determine the cost of 5M gas, I utilized two methods:
- Checked the Ethereum Gas Price tracker at [ethereum-gas-price](https://www.datawallet.com/ethereum-gas-price).
- Analyzed a recent transaction as a reference point [Example](https://etherscan.io/tx/0x5a757e729184a8b9ca2ddae9358c9eb531db0d94686252bfcbe3958366ee7ce4).
The current gas price stands at approximately 10 gwei, equivalent to roughly 0.00000001 ETH ([Eth-Converter](https://eth-converter.com/)).
In my test, Multiplying this by 5M gives us 5_000_000 * 0.00000001 = 0.05 ETH, translating to about $115. The net profit for an attacker would be $1523 - $115 = $1408 From the plant function, and 1523$ worth of Voting Power.
In addition, when running Bug 3 with more iterations loops = 300 the attack required 20M gas (400$ cost) and made a profit of 8165$.
The Actual POC
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 18990675 --gas-price 0 --via-ir -vv
BIR-12: Earned Beans Theft
BIC Response
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. There is a minimum reward of USD 10 000 for High severity smart contract bug reports.
However, we don't believe it is practical to precisely quantify the practicable economic damage in this report as a result of the exploit being more or less profitable at different times.
The report points out that during Seasons over the last few months, the attack has been profitable by various amounts, including a particular Season (and following Seasons until certain conditions change) where up to 20,000 Beans could have been stolen in each block during the 10 block Vesting Period.
Given this economic damage that could occur at various points in time (and for many of the reasons outlined earlier in the thread), the BIC has determined that this bug report be rewarded 50,000 Beans.