Hack Analysis: Beanstalk Governance Attack, April 2022
Beanstalk, a permissionless fiat stablecoin protocol, was the victim of a whopping $181m hack on April 17, 2022, which leveraged the lack of execution delay to push through a malicious governance proposal.
The hacker submitted two Beanstalk Improvement Proposals: Bip18 and Bip19. The first one proposed the full transfer of funds to the attacker, and the second one was a proposal to send $250k worth of $BEAN tokens to Ukraine’s official crypto donation address.
Emergency action on Beanstalk governance proposals can only happen after a 1-day delay period, so the actual hack (the execution of both proposals) happened the day after the Bips were created.
The attacker flashloaned more than $1 billion from Aave, Uniswap, and SushiSwap to gain enough voting power (at least 2/3) to trigger an emergency governance execution. Through this particular mechanism, the attacker was able to execute the proposal in a single transaction, meaning that the flashloan was successful because the loan was able to be paid back immediately. As a result, the attacker made off with a huge amount of illicit profit.
In this article, we will be analyzing the exploited vulnerability in the Beanstalk contract, and then we’ll create our own simplified version of the attack, testing it against a local fork. You can read the full PoC here.
This article was written by gmhacker.eth, an Immunefi smart contract triager.
Beanstalk is a permissionless stablecoin protocol built on the Ethereum blockchain. It aims to create an Ethereum-native economy facilitated by its native fiat currency, a stablecoin called Bean. The protocol’s primary objective is to incentivize independent market participants to cross the price of 1 Bean over its dollar peg.
The Beanstalk protocol uses credit instead of collateral to maintain price stability relative to its value peg of $1. The minting of Bean tokens happens when the stablecoin’s price is too high, distributing the newly created tokens to various ecosystem participants. The overall ecosystem is designed to incentivize capital entering the protocol to continuously maintain exposure to the Bean stablecoin.
A particular aspect of the Beanstalk smart contract is that it’s implementing the Diamond Pattern — EIP-2535 — a multi-facet proxy standard created by Nick Mudge. The main diamond contract uses different implementation contracts — facets — through
delegatecall. Each function signature is mapped so that the right implementation logic is used for the specific function that is being called on the diamond contract. Besides that, facets can be added to or removed from the diamond function registry, allowing for logic upgradeability.
Having a rough understanding of what Beanstalk is, we can dive into the actual smart contract code to explore the root cause vulnerability that the attacker leveraged in the April 2022 hack.
The on-chain transaction can be seen here.
The underlying vulnerability was the existence of the
emergencyCommit function in the
GovernanceFacet implementation contract, which executes an active proposal with at least two thirds of voting percentage. The private
_execute function will use
delegatecall to borrow some logic from a given address (all provided by the proposal creator). The hacker supplied logic that would transfer all funds to the attacker contract.
commit function executes a no longer active proposal, and it will check if it has enough vote percentage to be executed. When the proposal has ended already, stakers can no longer vote on it, meaning that the
commit function is not susceptible to an attack leveraging flashloaned funds.
Through the use of flashloans, an attacker can get enough liquidity pool tokens to deposit to the Beanstalk protocol and get the necessary voting power to call
emergencyCommit, thus being able to vote and execute a Bip in the same transaction. The actual hack that took place in April 2022 leveraged that lack of delay between voting and execution to pass a malicious proposal that transferred deposited funds to the attacker address.
Proof of Concept
Now that we understand the vulnerability that compromised the Beanstalk protocol, we can formulate our own proof of concept (PoC). The hacker flashloaned over $1 billion in stablecoins: $1 billion worth of DAI, USDC and USDT from Aave, around $32m worth of BEAN from UniswapV2, and almost $12m worth of LUSD from SushiSwap. We will stick to a flashloan from Aave to simplify our code.
We’ll start by selecting an RPC provider with archive access. For this demonstration, the free public RPC aggregator provided by Ankr should be sufficient. We will be using block number 14595905 as our fork block, 1 block before the proposal creation transaction (which happened one day prior to the actual hack transaction).
Our PoC needs to run through a number of steps in a single transaction to be successful. Here is a high-level overview of what we will be implementing in our attack PoC:
- Flashloan 350M DAI, 500M USDC and 150M USDT from Aave.
- Deposit all those stablecoins into Curve’s 3pool to get 3Crv tokens.
- Deposit 3Crv into Curve’s BEAN3CRV-f pool to get BEAN3CRV-f tokens.
- Deposit BEAN3CRV-f in the Beanstalk protocol. This will actually increment the depositor’s voting power by calling
LibSilo.incrementBipRoots. Since we are using
emergencyCommit, we will not need to call
emergencyCommitto run the malicious proposal.
- Use Curve again to swap all BEAN3CRV-f to the 3 flashloaned stablecoins. Aave will transfer those funds back to repay the loan.
Since this particular hack involved two transactions separated by a 1-day delay, we will also add the first attacker’s action to the PoC — the creation of Bip18. Our malicious proposal will transfer all BEAN3CRV-f tokens from the protocol to our attacker contract. We will simulate the time delay in the testing as well, so that we can later execute it with flashloaned funds.
Let’s code one step at a time, and eventually look at how the entire PoC looks. We will be using Foundry.
Let’s begin by creating our attacker contract. We will separate the two different transactions into two different functions:
proposeBip will need a good amount of ETH, about 70 ETH, to swap for enough BEAN. This is needed for a user to be able to submit new proposals to Beanstalk. Those funds will be deposited into the protocol and the malicious Bip18 will be created. The
attack function will flashloan from Aave to get enough voting power to pass through the proposal with
GovernanceFacet.emergencyCommit. Let’s develop the internal functions inside
We swap 70 ETH for BEAN using the Uniswap router. That amount of ETH needs to be passed in
msg.value. After having received the tokens, we deposit them into the Beanstalk protocol by calling
depositBeans. Inside that function, the contract will transfer the funds from the attacker contract, hence why one needs to approve that spending first.
The final step of creating Bip18 is the submission of the proposal itself. The relevant part of the
propose function is the arguments
_calldata, which are used in the
delegatecall. We pass the attacker contract address and the encoded selector of the
getProposalProfit function. Noteworthy, since
delegatecall will be used, we should make sure our
getProposalProfit function is not relying on variables from the attacker’s storage.
We proceed to the actual hack transaction. We assume an entire day has passed since the malicious Bip18 was proposed. The first thing we’ll do is call
SafeERC20.safeApprove on various different ERC20s that we are going to handle. The Aave lending pool needs to be able to transfer funds back so that the flashloan gets repaid, and Curve pools add liquidity in a similar fashion.
The usage of OpenZeppelin’s
SafeERC20.safeApprove has to do with the specific logic of the
ER20.approve function on the USDT contract. The Tether USDT stablecoin raises an exception inside
approve if the allowance value is being changed from a non-zero value to another non-zero value. It is implemented this way to mitigate a possible race condition on that particular ERC20 method. The
safeApprove function will prevent the transaction from reverting if an exception is raised inside
We reach the point where we will be doing our flashloan. The Aave protocol allows us to flashloan more than one asset on a single flashloan. We call the
flashLoan function to borrow 350M DAI, 500M USDC and 150M USDT. This amounts to a $1 billion movement from the pool to our attacker contract. The Aave function will transfer those funds to the attacker and then trigger the
executeOperation callback in our contract. After the callback ends, Aave will transfer the funds back. This is where we have an opportunity to leverage our large holdings to get an overwhelming amount of influence in Beanstalk’s governance.
Attacker.executeOperation function runs through 3 steps: use Aave funds to get BEAN3CRV-f tokens, run
GovernanceFacet.emergencyCommit, and swap all received BEAN3CRV-f tokens back to stablecoins.
We need to develop each of these functions.
The different amounts of stablecoins are deposited into Curve’s 3pool through
ICurvePool.add_liquidity, which transfers 3Crv tokens to the attacker. This ERC20 token will, in turn, be deposited into Curve’s BEAN3CRV-f pool. Now, the attacker contract has enough BEAN3CRV-f tokens to pass through the malicious Bip18.
IBeanStalk.deposit to transfer our BEAN3CRV-f tokens into the Beanstalk protocol. Our voting power is now sufficiently large for emergency proposal execution. We call the
IBeanStalk.emergencyCommit function, which checks the attacker’s deposit to assert whether that’s enough (⅔ of vote percentage) to pass through the proposal. Our Bip18 will get executed, which means that
delegatecall to our
Attacker.getProposalProfit function, which will transfer all BEAN3CRV-f tokens to our attacker contract.
We are now in possession of all BEAN3CRV-f that was previously on the protocol (the hacker removes other tokens from Beanstalk as well, but we’ve stuck to this one for simplicity). We remove our liquidity from the BEAN3CRV-f pool by using
remove_liquidity_one_coin. This will allow us to just get one of the underlying tokens from the pool–in this case, 3Crv. We then need to make sure we get the same amount of each stablecoin we borrowed, plus the flashloan fee (premium). We can do this by calling the
ICurvePool.remove_liquidity_imbalance on 3pool. Finally, since we still have a profit in 3Crv tokens, we use
remove_liquidity_one_coin to get underlying USDC in exchange for the 3pool ERC20s.
executeOperation callback will end, returning the execution context back to Aave’s
flashLoan function. That function, as already mentioned, will transfer the borrowed funds back to the Aave protocol (with a fee), leaving the attacker contract with a USDC profit. At the end of our transaction, we transfer our profit to the address calling the
Attacker.attack function. This completes the entire exploit logic.
If we run this PoC against the forked block number, we get a profit of about 42m USDC. Enhancing the exploit magnitude should be fairly straightforward. Together with the proposal submission, the necessary interfaces and Foundry logs, our PoC amounts to 257 lines of code.
Snippet 11 is a Foundry test script showcasing how one can test both transactions by simulating the passing of 1 day in between.
The Beanstalk exploit was one of the biggest hacks of 2022. The attack stresses the importance of proper secure pattern implementation. In this particular case, we’ve learned that being able to vote on and execute a proposal in the same transaction is a functionality that leaves a DAO vulnerable to a governance attack, due to the nature of flashloans. As we’ve seen, protocols on the Ethereum blockchain have enough liquidity for a hacker to flashloan over $1 billion, so protocols need to be mindful of these possibilities.
It should be noted that the hacker only managed to steal around $77m worth of non-Bean assets. The rest was successfully burned by the protocol’s team. Additionally, shortly after the hack, Beanstalk removed the
GovernanceFacet implementation and replaced that on-chain governance mechanism for a community-run multisig wallet.
As previously pointed out, this PoC is a simpler attack than what actually took place in reality. We propose, as an exercise to the reader, to expand this PoC so that all tokens in the Beanstalk protocol are transferred to the attacker. This exercise will hone your ability to interact with many different smart contract legos, as well as test your ability to retrieve on-chain state information.