skip to content
zeroknots.eth

SlotMachine(tool) & EVM Storage Management

Deep dive into EVMs storage management. How i stopped worrying and learned to love SSTORE

Motivation

I’ve been working on a modular Account Abstraction project that takes advantage of an innovative architecture for modules. This novel approach aims to improve contract upgradability, code organization, and facilitate sharing of functionality among contracts. It tackles the limitations of traditional proxy contracts and monolithic architectures by allowing contracts to consist of multiple modules. To make this possible, the proxy uses delegatecall to execute functions from various logic implementations while keeping the proxy’s storage and context intact.

Security Issues of Delegatecall

delegatecall is a powerful feature in Solidity that enables a contract to run a function from another contract while maintaining its own storage and context. However, using it also brings several potential security risks:

  1. Reentrancy attacks: Using delegatecall may expose a contract to reentrancy attacks if not properly protected. When a contract calls an external contract using delegatecall, the callee contract can access the caller’s storage and potentially call back into the calling contract, causing unexpected behavior or exploitation.

  2. Storage layout clashes: Since delegatecall works within the context of the calling contract’s storage, storage layout clashes between the calling contract and the called contract can happen. If the storage variables in the called contract don’t align with the calling contract’s storage layout, unintended data overwriting or corruption may occur.

  3. Malicious called contracts: When a contract uses delegatecall to execute code from another contract, it’s essential to trust the called contract’s code. A malicious called contract can tamper with the calling contract’s storage or carry out other harmful actions.

  4. Upgradability risks: Although using delegatecall for upgradable contracts has its perks, it also brings the risk of accidentally breaking the contract during upgrades. If a new version of the called contract contains incompatible changes in the storage layout or modifies the original functions’ expected behavior, it could cause unintended consequences or even make the calling contract non-functional.

In this blog post, I’ll mainly focus on Point 2: Storage Layout Clashes.

delegatecall Storage Layout Clashes

Storage layout clashes are a significant concern when working with smart contract architectures that involve delegatecall or similar mechanisms to share functionality among contracts. These clashes happen when the storage variables in the called contract don’t align with the calling contract’s storage layout, leading to unintended overwriting or corruption of data. Since delegatecall operates within the context of the calling contract’s storage, it’s crucial for developers to ensure both contracts share a consistent storage layout. Failing to do so can result in unpredictable behavior, security vulnerabilities, and potential data loss. To reduce the risk of storage layout clashes, developers should use well-defined interfaces, carefully plan and document their contracts’ storage layout, and thoroughly test and audit the interaction between contracts to prevent unintended consequences.

In the context of modular Account Abstraction, the plugins don’t necessarily have to conform to the shared storage layout with the proxy contract. However, they often still need to use their own unique storage slots.

This raises an interesting question: How can an auditor effectively detect dangerous storage slots?

How are Storage Slots calculated?

In a simple contract that doesn’t use mapping, storage access is quite straightforward. It’s possible to create a test that clearly demonstrates the storage access pattern within such a contract.


contract SimpleStorage {
    uint256 public storedData; // slot 0
    mapping(address owner => uint256) public balances; // slot 1

    function set(uint256 x) public {
        storedData = x;
        balances[msg.sender] = x;
    }

    function get() public view returns (uint256) {
        return storedData;
    }
}

contract SimpleStorageTest is Test {

    SimpleStorage simpleStorage;

    function setUp() public {
        simpleStorage = new SimpleStorage();
    }

    function testStorageGet() public {
        vm.record();
        simpleStorage.set(42);
        (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(simpleStorage));

        for(uint256 i = 0; i < writes.length; i++) {
            console2.logBytes32(writes[i]);
        }
    }
}

[PASS] testStorageGet()
Logs:
  0x0000000000000000000000000000000000000000000000000000000000000000
  0xb38645331535d8a24250bd866c230d0d85c11b90105fdba49ccc7bb4d9c6bc96

Upon examining the logs, it is evident that the access of storedData reveals its storage slot 0x0000000000000000000000000000000000000000000000000000000000000000, while the mapping utilizes 0xb38645331535d8a24250bd866c230d0d85c11b90105fdba49ccc7bb4d9c6bc96.

Auditing the access of storedData to determine any potential hazardous impact on the proxy might be a simple task. However, the key for the mapping appears random. This raises the questions: How can one figure out whether this write access would collide with the proxy storage? And, how is this value even calculated?

Apparently, there is some clever manipulation happening at the bytecode level that calculates the storage slots in a deterministic way, avoiding collision within the contract itself.

As the Solidity Documentation states:

Due to their unpredictable size, mappings and dynamically-sized array types cannot be stored “in between” the state variables preceding and following them. Instead, they are considered to occupy only 32 bytes and the elements they contain are stored starting at a different storage slot that is computed using a Keccak-256 hash.

In our code example, the translates to:

keccak256(uint256(42) . uint256(1)) //42 value // 1 mapping slot

For the normal solidity developer, this process is abstracted away. The solidity compiler takes care of all of these aspects.

How can we inspect this procedure in code?

Intermediate Representation (yul)

You may already be familiar with Yul, a low-level assembly language often utilized by developers seeking to optimize their gas efficiency.

Additionally, you might have encountered the --via-ir parameter in Foundry. This command enables the compilation or transpilation of Solidity into Yul, allowing for the inspection of the low-level assembly prior to compiling it into bytecode.

solc --ir src/$1.sol -o src/$1.yul

Note: Find the output here (gist)

Let’s have a look at the function set(uint256 x):

/// "function set(uint256 x) public ..."
function fun_set_24(var_x_9) {
    let _1 := var_x_9
    let expr_13 := _1
    update_storage_value_offset_0t_uint256_to_t_uint256(0x00, expr_13)
    let expr_14 := expr_13
    let _2 := var_x_9
    let expr_20 := _2
    /// @src 0:276:284  "balances"
    let _3_slot := 0x01
    let expr_16_slot := _3_slot
    ///  "msg.sender"
    let expr_18 := caller()
    /// @src 0:276:296  "balances[msg.sender]"
    let _4 := mapping_index_access_t_mapping$_t_address_$_t_uint256_$_of_t_address(expr_16_slot,expr_18)
    update_storage_value_offset_0t_uint256_to_t_uint256(_4, expr_20)
    let expr_21 := expr_20

}

In this instance, we observe that to access the balances, the function mapping_index_access_t_mapping$_t_address_$_t_uint256_$_of_t_address(expr_16_slot, expr_18) is invoked.

Let us proceed to examine this particular function in greater detail:

function mapping_index_access_t_mapping$_t_address_$_t_uint256_$_of_t_address(slot , key) -> dataSlot {
    mstore(0, convert_t_address_to_t_address(key))
    mstore(0x20, slot)
	dataSlot := keccak256(0, 0x40) // <- here it is
}

Bingo! This is the slot calculation that uses keccak to calculate the deterministic storage slot of mapping(address owner => uint256) public balances

How can we log these accesses?

I considered several ideas for logging storage slot calculations. The most straightforward approach involved utilizing Foundry’s console2.log feature.

By manually injecting console2.logBytes32() for slot, key, and dataSlot(keccak result) into the mapping_index_access_t_mapping$_t_address_$_t_uint256_$_of_t_address(expr_16_slot, expr_18) function, I saw the output on my terminal 🎉.

Tip: you can get the bytecode of any contract directly in Foundry Tests like this:

 bytes memory bytecode = abi.encodePacked(vm.getCode("Counter.yul:Counter_1337"));

Nonetheless, the goal was to automate this process in order to detect, test, and log all storage accesses made by a contract.

But I wanted to automate this, to detect, test, and log all storage accesses a contract makes.

Introducing: SlotMachine.sol

SlotMachine (GitHub Link) is a specialized security testing framework designed to detect hazardous storage writes in Solidity contracts. By leveraging the power of this innovative tool, developers can ensure their contracts maintain a high level of security and reliability.

Slot Machine

SlotMachine streamlines the testing process by performing the following steps:

  1. Transpiling any Solidity contract to Yul using solc --via-ir
  2. Injecting a Yul function into the file, responsible for logging
  3. Injecting the logging function call into all Yul functions that implement the keccak calculation for a mapping
  4. Compiling the Yul code into EVM bytecode
  5. Attaching the SlotMachine contract to a predetermined address on the revm.

By simply inheriting from SlotMachine, developers can effortlessly create test cases that flag hazardous or unexpected SSTORE operations. This powerful tool not only enhances the security of your contracts but also provides invaluable insights into potential vulnerabilities.

How to Use

To ensure secure and efficient storage testing in your smart contracts, follow the steps below to effectively use SlotMachine:

  1. Inherit from the SlotMachine contract in your Foundry tests.
  2. Call super.setUp() to initiate SlotMachine.
  3. Whitelist any storage positions and offsets that should be ignored by SlotMachine.
  4. Deploy the target contract using deployContract(contractName).

Here is an example of how to structure your test contract:

contract CounterStorageTest is SlotMachine {
    ICounter exampleContract;

    function setUp() public override {
        // setup SlotMachine
        super.setUp();
        // define the storage slot are 'OK' to be used by the target contract
        bytes32 defaultSlot = keccak256("counter.storage");
        slotWhitelist(defaultSlot, 100);

        // name of target contract
        string memory name = "Counter";
        // get the bytecode of the target contract, depliyed at this address
        exampleContract = ICounter(deployContract(name));
    }

    // write your tests here
    function testMapping() public useSlotMachine(address(exampleContract)) {
        exampleContract.setBalance();
    }
}

To execute the tests, run the following commands:

./slotmachine.sh Counter  # replace with your target contract name
forge test

How does it work?

The script will inject the following snipped in your target code:

function fun__logSlot_1337(mappingSlot, mappingKey, keccakSlot) {

    /// @src 0:805:817  "0x11119696969696969696"
    let slotMachine_Addr := 0x11119696969696969696
    //    function logSlot(bytes32 slot, bytes32 key, bytes32 keccakResult) internal
    let functionHash := 0x05dfbcc06611f25ca8dbfdaa208a18820e782332ec41e97b86b3a65797df1cbd
    // let expr_70 := convert_t_bytes32_to_t_bytes4(expr_69)
    let functionSig := and(functionHash, 0xffffffff00000000000000000000000000000000000000000000000000000000)
    {
        let usr$ptr := mload(0x40)
        mstore(usr$ptr, functionSig)
        mstore(add(usr$ptr, 0x04), mappingSlot)
        mstore(add(usr$ptr, 0x24), mappingKey)
        mstore(add(usr$ptr, 0x44), keccakSlot)
        let usr$success := call(gas(), slotMachine_Addr, 0, usr$ptr, 0x64, 0, 0)
        if iszero(usr$success) { revert(0, 0) }
    }
}

And invoke the fun__logSlot_1337() for every mapping reference

fun__logSlot_1337(slot,key,dataSlot)

In case of an SSTORE violation, you will receive an error message:

[FAIL. Reason: SLOTMACHINE_SSTORE_ALERT(0xed45ccd09931c6e422f2b385da45ff76edb71aea2a6e834fb421dde2ffe89b76)] testMapping() (gas: 1051182)

By running the tests in verbose mode using forge test -vvvvv, you can view all mapping SSTOREs for in-depth analysis.

forge test -vvvvv
[...]
--------

[SLOT] 0x0bec8ab077af1e12783ac8c970bca6dada042613c1ff87b4d0953dea6b011600
[VALUE] 0x0000000000000000000000000000000000000000000000000000000000000000
=> [keccack] 0x5935cfefbbd8605fc2d5027f8ea298804734a63f6e8acc97a88d646e14f53be0 [!OK!]


--------

[SLOT] 0x5935cfefbbd8605fc2d5027f8ea298804734a63f6e8acc97a88d646e14f53be0
[VALUE] 0x0000000000000000000000000000000000000000000000000000000000000000
=> [keccack] 0xe0a0cdcf88a393a2db0b6bdb7abb6d52da1e1500c3523dc023b9046db782d20f [!OK!]


--------

[SLOT] 0x2031468f0c30f7087de4da9398818763b546d7f89935fa65485c24ff1df26bf4
[VALUE] 0x0000000000000000000000000000000000000000000000000000000000077777
=> [keccack] 0xed45ccd09931c6e422f2b385da45ff76edb71aea2a6e834fb421dde2ffe89b76 [!ALERT!]