Research on the Collisions issues between EVM Storage Layout and Upgrade ProxyΒ Pattern

Goal 🏁

  1. storage layout of EVM Solidity type structure

  2. storage slot structure based on target contract analysis

  3. storage slot structure utilizing delegatecall and fallback

  4. storage slot structure within the upgrade proxy pattern

  5. Mitigation and addressing storage collision vulnerabilities.

Resource

  • The sample code for this example can be found in the following git repository.

Solidity Type-Specific Storage Layout

Storage Internal State Variable Layout Structure

  • State variables are stored in storage in a compact manner, using the same storage slot for multiple values.

  • The size of each variable is determined by its type.

    • Except for dynamic-type arrays and mappings, data is stored sequentially, starting with the first state variable.
  • Types less than 32 bytes are packed according to the following rules

    • The first item in the storage slot is ordered by lower-order byte.

    • Only as many bytes as are necessary to store the value type are used.

    • If the value type does not fit in the remaining part of the storage slot, it is stored in the next storage slot.

    • Struct and array data always starts on a new slot.

contract SimpleStorageLayout {
  // slots: 0
  uint16 a;
  uint16 b;
  uint16 c;
  uint16 d;
  uint16 e;
  uint16 f;
  uint16 g;
  uint16 h;
  uint16 i;
  uint16 j;
  uint16 k;
  uint16 l;
  uint16 m;
  uint16 n;
  uint16 o;
  uint16 p;
  // slots: 1
  bytes12 calice;
  address alice;
  // slots: 2
  address bob;
  // slots: 3
  uint256 balance;
}
SimpleStorageLayout contract Storage
SimpleStorageLayout contract Storage
  • State variables "a-p" specified as uint16 type use 2 bytes each, for a total of 16 variables that are packed into a single 32-byte sized slot using the 32 byte context. (slots: 0)

  • State variables specified as bytes12 and address types use 12 bytes and 20 bytes respectively. Similarly, they are packed into a single 32-byte context slot. (slots: 1)

  • Next, state variables specified as uint256 type use a fixed-size 32-byte context slot. (slots: 3)

  • 256 bit can be used for all 256-bit hash addresses in Solidity storage and the data storage access method is different from what can be applied to webassembly.

Context Inheritance Storage

  • In the case of contracts using inheritance, the order of state variables starts with the most basic contract and is determined by the C3 Linearization, so that a derived variable should come after its base variable. The state variables of other contracts will share the same storage slots.

Dynamic Type Structure Storage Layout

  • Due to sizes that cannot be predicted at compile-time, array types cannot be stored in between other state variables in storage slots.

  • Instead, they are treated as taking up only 32 bytes (header slots) and the elements within them are stored in other storage slots, starting with a keccak hash calculation.

  • Contracts start storing slots from slot 0 and as previously mentioned, basic fixed types occupy one slot (e.g. uint256), if an array is to be stored, the number of elements in the array is stored in a slot referred to as a header slot which is 32 bytes.

  • You can access the internal data by adding the number of the header slot to the keccak hashing of the element's address. This is similar to the way dynamic arrays are stored in C/C++ etc.

  • Solidity does not maintain these pointers anywhere and as a result, memory can be written to all storage locations without allocating memory.

contract ArrayStorage {
	uint256 a;
	uint256 b;
	uint256[] blocks;
	
	function init() public {
		blocks[0] = 0x41414141;
		blocks[1] = 0x42424242;
		blocks[2] = 0x43434343;
	}
}
ArrayStorage contract Storage
ArrayStorage contract Storage
  • As seen in the diagram, state variables a and b have fixed size slots of 32 bytes and therefore occupy slots 0 and 1.

  • The dynamic array uint256[] state variable first forms 'header slots' based on the number of internal elements and occupies a slot to store the total length value.

  • The internal element data can be accessed in the form of keccak(slots(2)) + 0. Similarly, data that exists at index 0, 1 and 2 can be accessed in the same way.

  • Mappings have similar mechanisms, with the exception that all values are located separately and the key value is included in the hash calculation.

  • Next, using the example of the struct pos being declared and being managed in the 2-dimensional mapping structure positions. There is a state variable dimension of 32 byte size in the middle.

contract StructStorage {
  struct pos {
      uint128 x;
      uint128 y;
      uint256 z;
  }
  uint256 dimension;
  mapping(uint256 => mapping(uint256 => pos)) positions;
}
  • In the above structure, to reference the storage slot for data[2][3].z, it can be calculated using the following method.
0. slots:0 => uint256 dimension 
1. positions[2] == keccak256(uint256(2).uint256(1))
	- reference: index 2
	- first state variable is slot 0
2. positions[2][3] == keccak256(uint256(3).keccak256(uint256(2).uint256(1)))
3. positions[2][3].z == keccak256(uint256(3).keccak256(uint256(2).uint256(1))) + 1
        - Since uint128 x and y are 16 bytes each, we can compress them to 32 bytes and use +1

Storage Layout of bytes and string types

  • Bytes and strings are encoded in the same way, and the encoding generally uses the storage slot for the array itself and the data stored at the corresponding storage slot position, which is similar to bytes1[].

  • If the length of the element is less than 32 bytes, it is stored in the same slot with the length.

  • If the length of the data is a maximum of 31 bytes, the element is stored in the upper byte (left-aligned) and the least significant byte stores the value of length * 2.

  • For byte arrays that store data, if the length is 32 or more bytes, the main storage slot p stores length * 2 + 1, and the data is stored in the usual way in keccak256(p).

Internal Memory Layout Structure

  • In Solidity, 4 32-byte slots are used with a specific byte range(including endpoint) that is used as follows:
0x00 ~ 0x3f (64 byte) => hashing method scratch space
0x40 ~ 0x5f (32 byte) => allocated memory size (free memory space)
0x60 ~ 0x7f (32 byte) => zer0 slots
  • Scratch space can be used in between instructions (inside inline assembly).

  • Slot 0 is used as the initial value of dynamic memory arrays and must never be written to. (Available memory pointers point to the initial address of 0x80)

  • New objects are always placed in spare memory pointers and memory is never released.

  • Elements in memory arrays in Solidity always occupy a multiple of 32 bytes (this also applies to bytes1[], but not for bytes and string types)

  • Multidimensional memory arrays consist of a pointer to the memory array, the length of the dynamic array is stored in the first slot of the array and the array elements follow.

Management of Temporary Memory Area

  • Temporary memory areas larger than 64 bytes are needed.

  • There are some tasks that do not fit in the scratch space.

  • Use of memory arrays or dynamic memory allocation is required.

  • In this case, memory is allocated using a free memory pointer and the size is stored in the first slot.

  • The memory pointer is incremented by the size of the allocated memory and the next allocation is done using this memory pointer.

  • The allocated memory is cleared when the function exits.

Differences in Code Level Structure of Memory and Storage Layout

Array Type Layout

uint8[4] a;
  • Storage => 32 bytes (1 slot)

  • Memory => 128 bytes (4 slots, 32 bytes each)

Struct Type Layout

struct s {
	uint a;
	uint b;
	uint8 c;
	uint8 d;
}
  • Storage => 96 bytes (3 slots: 32 bytes each)

  • Memory => 128 bytes (4 slots: 32 bytes each)

Upgradable Contract Storage Management

  • When operating EVM-based smart contracts on various chains, various bugs may occur or updates may be necessary. However, since it is on the blockchain block, it is impossible to modify. In the past, the process of directly deploying new code and entering the newly created contract address for all associated services was cumbersome due to lack of flexibility, but now various proxy design patterns have emerged, making it possible to use updatable smart contracts.

  • The idea is to keep the smart contract address and code in an unchangeable state and have the code executed by another contract and return the result.

  • It is easy to change the contract state as much as the variable that saves the other contract address, but the code cannot be changed.

  • Because there may be several smart contract versions, recording a new version address while keeping the old version address allows users to switch to a new version at any time.

  • Next, we will learn the basic delegatecall, fallback commands that are essential to understand in Upgradable Proxy Pattern, and analyze the storage slots on a unit basis.

  • We will use the foundry framework to analyze the code structure and conduct transaction tests.

Code Level State Variable Storage Layout

  • The next contract will implement the storage layout discussed earlier in a more clear manner by using state variables and dynamic type storage slot allocation at the code level, in order to better understand it.
contract StorageLayout {
  address immutable owner;
  uint256 public flag;
  mapping(address => uint256) public users;
  bytes public key;

  constructor(bytes memory _key) {
      owner = msg.sender;
      key = _key;
  }

  function setFlags(address _user, uint256 _flag) external {
      require(msg.sender == owner, "[ERR-01] Only owner can set flags");
      users[_user] = _flag;
  }
}
StorageLayout Contract Storage
StorageLayout Contract Storage
  • As seen in the above contract storage layout, the address immutable owner variable is not stored in storage as it uses the immutable keyword, instead it is stored in the contract's memory during execution.

  • The next variable, flag, occupies 1 storage slot and stores its data. The next mapping type, users, occupies 1 storage slot as well, with the dynamic type rule resulting in the use of a header slot, which stores the length of elements within the users mapping.

slots:0 ------- flag
sltos:1 ------- users <header slots:length>
;; users[addr] reference addr index
...
slots:keccak256(users)+1 ------- users[addr]
  • The "users" variable, which corresponds to the mapping and dynamic array type, uses address index as a key and references a uint256 value, which is padded with 32 bytes. Then, it uses the first index, represented by the value 1, padded with 32 bytes and performs a keccak256 hash to determine the slot number, allowing it to reference the corresponding mapping data.
contract StorageLayoutTest is DSTest {
  Vm internal immutable vm = Vm(HEVM_ADDRESS);

  StorageLayout internal storageLayout;
  address internal minsu;

  function setUp() public {
      minsu = address(uint160(uint256(keccak256(abi.encodePacked("owner")))));
      vm.label(minsu, "Owner");
  }

  function testSetFlags() public {
      vm.startPrank(address(minsu));
      storageLayout = new StorageLayout("BBBB");
      storageLayout.setFlags(minsu, uint256(0x41414141));
      vm.stopPrank();
      bytes32 minsuStorageSlot = keccak256(
          abi.encodePacked(uint256(uint160(minsu)), uint256(1))
      );

      uint256 minsuStorageSlotValue = uint256(
          vm.load(address(storageLayout), minsuStorageSlot)
      );

      assertEq(minsuStorageSlotValue, storageLayout.users(minsu));
  }
}
  • The setFlags function is used to map the value of 0x41414141 to the address "minsu" in the users mapping variable. Afterwards, users[address(minsu)] will contain the value 0x41414141.

  • Therefore, we can directly reference the slot number by calculating it through dynamic mapping slot calculation. By referencing the storage in the format keccak256(abi.encodePacked(uint256(uint160(minsu)), uint256(1)));, we can confirm that the value is the same by calculating the corresponding mapping slot number and checking the storage.

[PASS] testSetFlags() (gas: 248370)
Traces:
[248370] StorageLayoutTest::testSetFlags() 
  β”œβ”€ [0] VM::startPrank(Owner: [0x9E326f4F4C1B9057164Ef592671cF0D37C8040C0]) 
  β”‚   └─ ← ()
  β”œβ”€ [180298] β†’ new StorageLayout@0x8b10C21F6dEE107A616205e39313147Ee8e0e19A
  β”‚   └─ ← 785 bytes of code
  β”œβ”€ [22600] StorageLayout::setFlags(Owner: [0x9E326f4F4C1B9057164Ef592671cF0D37C8040C0], 1094795585) 
  β”‚   └─ ← ()
  β”œβ”€ [0] VM::stopPrank() 
  β”‚   └─ ← ()
  β”œβ”€ [0] VM::load(StorageLayout: [0x8b10C21F6dEE107A616205e39313147Ee8e0e19A], 0x80b63dade9d4ca379daeb336da1ac63122f9446a350c48d3ecd78eda052432eb) 
  β”‚   └─ ← 0x0000000000000000000000000000000000000000000000000000000041414141
  β”œβ”€ [541] StorageLayout::users(Owner: [0x9E326f4F4C1B9057164Ef592671cF0D37C8040C0]) [staticcall]
  β”‚   └─ ← 1094795585
  └─ ← ()
  • For simple contracts like StorageLayout, the variables can be manually and easily calculated, however, for more complex contracts that inherit or have multiple variables stored in the same slot, it can become more difficult to work with.

  • For more complex contract level, there is an option provided by the foundry command 'storage-layout' that allows you to check the storage layout.

⚑ forge inspect --pretty StorageLayout storage-layout 
| Name  | Type                        | Slot | Offset | Bytes | Contract                                   |
|-------|-----------------------------|------|--------|-------|--------------------------------------------|
| flag  | uint256                     | 0    | 0      | 32    | src/test/StorageLayout.t.sol:StorageLayout |
| users | mapping(address => uint256) | 1    | 0      | 32    | src/test/StorageLayout.t.sol:StorageLayout |
| key   | bytes                       | 2    | 0      | 32    | src/test/StorageLayout.t.sol:StorageLayout |

Code Level delegatecall Storage Layout

  • It is possible to upgrade a contract and change its logic in cases where the code is not modifiable by using the delegatecall command, which allows for the execution of using other contract storage.
contract ImplFlagManagerV1 {
  uint256 public flag;

  function getFlag() external view returns (uint256) {
      return flag;
  }

  function setFlag(uint256 _flag) external {
      require(0 < _flag, "[ERR-01] Invalid flag");
      flag = _flag;
  }
}

contract DelegateCallFlagManager {
  uint256 public flag;

  function getFlag() external view returns (uint256) {
      return flag;
  }

  function delegateCallSet(ImplFlagManagerV1 _impl, uint256 _flag) external {
      bytes memory payload = abi.encodeWithSignature(
          "setFlag(uint256)",
          _flag
      );
      console.logBytes(payload);
      (bool _success, ) = address(_impl).delegatecall(payload);
      require(_success, "[ERR-02] Delegate call failed");
  }
}
  • The ImplFlagManagerV1 contract uses the 'flag' state variable and allows for the retrieval of the 'flag' variable through the use of the getFlag() function and the updating of the 'flag' variable with values greater than zero through the use of the setFlag(uint256) function.

  • The DelegateCallFlagManager contract is similar to the ImplFlagManagerV1 contract, with the exception of the delegateCallSet function.

ImplFlagManagerV1, DelegateCallFlagManager contract storage layout
ImplFlagManagerV1, DelegateCallFlagManager contract storage layout
  • The delegateCallSet function in the DelegateCallFlagManager contract uses the delegatecall to perform the logic of the setFlag function in the ImplFlagManagerV1 contract, using the storage of the DelegateCallFlagManager contract.

  • Both contracts should have compatible storage layouts, meaning they should have the same variables assigned to the same storage slots.

  • The delegatecall is a low-level primitive and possess the following specifications.

  • When calling a function in a contract, both the function and the parameters being passed need to be specified. This information needs to be encoded in a well-known format, so that the target contract can interpret it. (ABI encoding)

  • In the case of normal function calls, Solidity performs this encoding automatically, but when using delegatecall, it must be done manually.

  • To understand the internal logic of the delegateCallSet function, you should check the description below.

bytes memory payload = abi.encodeWithSignature(
    "setFlag(uint256)",
    _flag
);
// <abi.encodeWithSignature>
// parms1 : function signature
// parms2 : function parameter

// 0xd40c79f00000000000000000000000000000000000000000000000000000000041414141
  • If we look at the encoded structure, the first 4 bytes represent the function signature, and the next 32 bytes represent the parameters. It is currently set to 0x41414141
cast keccak "setFlag(uint256)"
0xd40c79f00eec2930b946749c3d40887e066d1cf5a1d633e066df20cd9e50491d
  • This encoded function data can be passed through a delegatecall.
(bool _success, ) = address(_impl).delegatecall(payload);
  • This line of code is executing the ImplFlagManagerV1.setFlag(uint256) function in the context of the current contract.

  • Because it is executed using delegatecall, it uses the storage of the calling contract, DelegateCallFlagManager.

  • delegatecall returns two values: a boolean indicating whether the call was successful, and a byte array containing the returned data.

  • As there is no separate return handling in this case, you only need to check the success of the call. This is important because when using delegatecall, the call's failures are not automatically propagated.

//...
function delegateCallGet(ImplFlagManagerV1 _impl)
    external
    returns (uint256)
{
    bytes memory payload = abi.encodeWithSignature("getFlag()");
    console.logBytes(payload);
    (bool _success, bytes memory _ret) = address(_impl).delegatecall(
        payload
    );
    require(_success, "[ERR-02] Delegate call failed");

    return abi.decode(_ret, (uint256));
}
//...
  • Next, we will similarly process the getFlag() function through the delegatecall command. In this case, since the function retrieves the flag data, we will need to handle the return.

  • Depending on the return type of the called function, we need to decode it to obtain the byte array in the correct order.

  • To handle the data returned by the delegatecall call stored in _ret, we will cast it to the return type of getFlag() which is uint256, it's a fixed width type and it's simply represented by big endian, so it's encoded as 32 bytes padded, so we can cast it and decode it to get the result.

return abi.decode(_ret, (uint256));
  • The abi.decode() function uses two parameters, a byte array containing the encoded value, and a tuple containing the encoded types.

Generalizing delegatecall return handling

  • In order to use delegatecall with any function, it must be able to handle arbitrary return data as well. This must be implemented at the assembly level.
assembly {
    let data := add(_ret, 0x20)
    let size := mload(_ret)
    return(data, size)
}
  • In summary, the DelegateCallFlagManager contract uses the delegatecall command to execute the logic of the ImplFlagManagerV1 contract's setFlag and getFlag functions, while using its own storage. This allows for the logic of the ImplFlagManagerV1 contract to be updated without modifying its code. The abi.decode() function is used to decode the returned data from the delegatecall command, and the return(data, size) command is used to copy a specified amount of memory starting at a certain address. The returned data can be accessed by referencing memory variables such as _ret. This technique allows for the data returned from a delegatecall to be easily passed without knowing the encoded value's type.
//...
function delegateCallGenericGet(ImplFlagManagerV1 _impl)
    external
    returns (uint256)
{
    bytes memory payload = abi.encodeWithSignature("getFlag()");
    (bool _success, bytes memory _ret) = address(_impl).delegatecall(
        payload
    );
    require(_success, "[ERR-02] Delegate call failed");
    assembly {
        let data := add(_ret, 0x20)
        let size := mload(_ret)
        return(data, size)
    }
}
//...

Code-Level fallback function for proxy pattern

  • The fallback function is another useful feature when implementing upgradeable contracts. It allows developers to specify what should happen when a non-existent function is called. The default behavior is to throw an error, but it can be changed.
interface IFallBack {
    function client() external;

    function server() external;
}

contract System {
    event Log(string message);

    function client() external {
        emit Log("client, Hello");
    }

    fallback() external {
        emit Log("[Fallback] call server");
    }
}
  • The IFallBack interface has defined the client() and server() functions, and a contract named System has been defined that includes the client() function and the fallback function.
IFallBack ifallback = IFallBack(address(new System()));
ifallback.client(); // => Log("client, Hello")
ifallback.server(); // => Log("[Fallback] call server")
  • A new instance of the contract System can be created and cast as IFallBack to call both the client() and server() functions.

  • If the server() function, which is not defined in the contract System, is called, the fallback function will be called instead.

  • One useful fact is that you can access the original call data that triggered the fallback function by using msg.data.

  • For example, if the fallback function of the contract System is triggered, by checking msg.data you can find the signature of the server() function, which means that you can determine what function the user intended to call by inspecting msg.data.

Upgradeable Contract Structure

  • By using both delegatecall and fallback functions, a general solution for upgradeable contracts based on proxy can be obtained.

  • Two contracts, the proxy contract and the logic contract, are actually deployed for each contract that can be upgraded.

  • The proxy contract is the one that stores all the data and the logic contract contains the functions that operate on that data.

  • Users interact only with the proxy contract

  • When a user calls a function on the proxy, the proxy uses delegatecall to pass the call to the logic contract

  • Because the proxy uses delegatecall, the logic contract can execute the function and affect the storage of the proxy contract.

  • When using upgradeable contracts, the proxy holds the state and the logic contract holds the code.

  • From the user's perspective, the proxy behaves the same as the logic contract. Upgrading the contract simply means that the proxy points to a new logic contract.

contract FallBackProxy {
  address public impl;

  function upgradeTo(address _impl) external {
      impl = _impl;
  }

  fallback() external payable {
      (bool _success, bytes memory _ret) = impl.delegatecall(msg.data);
      require(_success, "[ERR-02] Delegate call failed");

      assembly {
          let data := add(_ret, 0x20)
          let size := mload(_ret)
          return(data, size)
      }
  }
}
  • Based on the theory previously described, the proxy is set up, but there are a few errors.

  • The proxy has a single storage variable implementation that stores the address of the logic contract.

  • By calling the upgradeTo function, the logic contract can be changed and upgraded. (Of course, as the authorization system is not set up now, anyone can call it, which is not desirable.)

  • The fallback function uses delegatecall to pass all calls to the logic contract. (Calls to the upgradeTo() function, which is processed by the proxy itself, are excluded) But how can we know which function the user intended to call?

  • The original origin calldata that triggered the fallback function can be accessed via msg.data.

  • Calldata includes both the function signature and the parameter values, so it is easy to pass msg.data to delegatecall and it is essential to handle exceptions to see if the call was successful.

function testFallBackProxy() public {
    ImplFlagManagerV1 impl = new ImplFlagManagerV1();
    FallBackProxy proxy = new FallBackProxy();

    vm.expectEmit(false, false, false, true);
    proxy.upgradeTo(address(impl));
    vm.expectEmit(false, false, false, true);
    ImplFlagManagerV1(address(proxy)).setFlag(0x41414141);

    ImplFlagManagerV1(address(proxy)).getFlag();
}
  • The ImplFlagManagerV1 and FallBackProxy contracts are set up based on logic and proxy contracts, respectively. The upgradeTo function is used to update the address of the logic contract, address public impl;, in the proxy contract.

  • When the ImplFlagManagerV1 is casted to the proxy contract address and the setFlag(0x41414141) function is called, the fallback function is executed because the setFlag() function is not defined. In the msg.data, the following data is included.

0xd40c79f00000000000000000000000000000000000000000000000000000000041414141
  • The setFlag(uint256) function signature and the parameter 0x41414141.

  • Updating the proxy contract storage also updates the logic contract storage, as the storage slots are the same. This results in manipulating the value of the incorrect address public impl; state variable.

  • Finally, calling ImplFlagManagerV1(address(proxy)).getFlag() will result in an exception, causing the transaction to fail.

ImplFlagManagerV1, FallBackProxy Contract Storage
ImplFlagManagerV1, FallBackProxy Contract Storage
  • When comparing the storage layout of the two contracts, it can be seen that different variables are stored in slot 0. This results in a unfortunate outcome where the ImplFlagManagerV1.setFlag() function modifies the 0th slot of the storage of the proxy contract when executed in order to update.

  • In the proxy contract, the 0th slot contains the address of the logic contract for upgrading and thus, when ImplFlagManagerV1(address(proxy)).setFlag(0x41414141) is executed, the actual logic contract address becomes the address + 0x41414141 and references a different address.

  • Specifically, it now points to an undeployed logic contract address, so a delegatecall call is made to a blank account but no data is returned.

Solutions to Resolve Slot Collision in Upgradable Proxy Contracts

  • One solution to this issue is to use an unstructured storage pattern, which provides a solution to the problem without requiring changes to the logic contract.

  • The idea is for the proxy to store the logic contract address in a slot that is far enough away from the possible slot collision, so that the proxy can ignore the possibility of collision.

  • Other solutions include using design patterns like UUPS, transparent proxies, and diamond patterns, but these require changes to the logic contract.

  • However, these design patterns can be difficult to implement, especially if the logic contract is inherited from other contracts.

  • A simple but not optimal solution would be to insert dummy storage variables in the conflicting section, but this may be difficult to apply if the logic contract is inherited from other contracts.

contract UpgradeProxy {
  bytes32 constant RAND_IMPL_SLOT =
      bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);

  function upgradeTo(address _newImplementation) external {
      bytes32 slot = RAND_IMPL_SLOT;
      assembly {
          sstore(slot, _newImplementation)
      }
  }

  function implementation() public view returns (address _impl) {
      bytes32 slot = RAND_IMPL_SLOT;
      assembly {
          _impl := sload(slot)
      }
  }

  fallback() external payable {
      (bool _success, bytes memory _ret) = implementation().delegatecall(
          msg.data
      );
      require(_success, "[ERR-02] Delegate call failed");

      assembly {
          let data := add(_ret, 0x20)
          let size := mload(_ret)
          return(data, size)
      }
  }
}
  • The main difference between this proxy contract and previous ones is that it does not declare any storage variables. Instead, the address of the logic contract is stored in the RAND_IMPL_SLOT slot, which is calculated by taking the keccak256 hash of the eip1967.proxy.implementation string and subtracting 1.

  • According to the EIP1967 standard, if a custom slot is used to store the logic contract, services like Etherscan can automatically detect the contract proxy and display information about both the proxy and logic contract.

  • To upgrade the contract, the upgradeTo() function must modify the address of the slot provided by RAND_IMPL_SLOT using the sstore command.

  • To prevent proxy storage conflicts, it is necessary to check that the layout is clearly compatible.

  • In fact, the proxy is completely independent of the specific logic contract used. It is enough to write a single proxy that can be used by everyone.

  • On the other hand, for types such as dynamic arrays that are matched and dynamically resized using keccak hash, the handling may be slightly different.

  • Slots calculated in this way can actually cause collisions with RAND_IMPL_SLOT.

  • The general consensus is that these occurrences are not considered a problem as the likelihood of this happening is low.

  1. Proxies are vulnerable to function signature collisions that can lead to unexpected behavior.

  2. UpgradeTo function allow anyone to upgrade the contract, and since the behavior of the contract can change significantly during an upgrade, this poses a significant security threat that needs to be addressed in all proxy implementations.

ImplFlagManagerV1, UpgradeProxy Contract Storage
ImplFlagManagerV1, UpgradeProxy Contract Storage

Upgradeable proxy pattern initialization process

  • In the previous contract ImplFlagManagerV1, it was used as a logic contract, but it was simple enough to not have a user-defined constructor. This allows important limitations that occur when using a proxy to be successfully ignored.

  • The constructor is not actually a function, so it cannot be called by delegatecall. Therefore, a separate initialization function is used.

contract ImplFlagManagerV1 {
  bool public Initialized;
  uint256 public flag;

  function initialize(uint256 _flag) external {
      require(!Initialized, "[ERR-01] Already initialized");
      Initialized = true;
      flag = _flag;
  }

  function getFlag() external view returns (uint256) {
      return flag;
  }

  function setFlag(uint256 _flag) external {
      require(0 < _flag, "[ERR-01] Invalid flag");
      flag = _flag;
  }
}
  • To make the proxy pattern upgradeable, we added an Initialized storage variable and initialize(uint256) function. Unlike the constructor, the initialize(uint256) function is a regular function and can be called multiple times.

  • Because security-sensitive parameters are often set during initialization, we use the Initialized variable to prevent re-initialization.

contract ImplFlagManagerV2 {
  bool public Initialized;
  uint256 public flag;

  function initialize(uint256 _flag) external {
      require(!Initialized, "[ERR-01] Already initialized");
      Initialized = true;
      flag = _flag;
  }

  function getFlag() external view returns (uint256) {
      return flag;
  }

  function setFlag(uint256 _flag) external {
      require(0 < _flag, "[ERR-01] Invalid flag");
      flag = _flag >> 1;
  }
}
function testUpgradeProxy() public {
    ImplFlagManagerV1 logicV1 = new ImplFlagManagerV1();
    UpgradeProxy Uproxy = new UpgradeProxy();
    Uproxy.upgradeTo(address(logicV1));

    ImplFlagManagerV1 Uproxy_logicV1 = ImplFlagManagerV1(address(Uproxy));
    Uproxy_logicV1.initialize(0x41414141);

    assertEq(Uproxy_logicV1.getFlag(), 0x41414141);

    Uproxy_logicV1.setFlag(0x42424242);
    assertEq(Uproxy_logicV1.getFlag(), 0x42424242);

    ImplFlagManagerV2 logicV2 = new ImplFlagManagerV2();
    Uproxy.upgradeTo(address(logicV2));

    Uproxy_logicV1.setFlag(0x41414141);
    assertEq(Uproxy_logicV1.getFlag(), (0x41414141 >> 1));
}
  • The previous steps involve deploying the logic and proxy contracts, and using the proxy contract to connect to the logic contract using the upgradeTo function (as per EIP1967).

  • After initializing the ImplFlagManagerV1 logic contract through the proxy contract using the initialize(0x41414141) function, the getFlag() function is called to confirm that it returns the same value.

  • The ImplFlagManagerV2 contract, which has new features, can then be upgraded to through the proxy contract and the verification conditions can be confirmed to have been passed.

Solutions for Collision Vulnerabilities in Each Design Pattern

Subscribe to Zer0Luck
Receive the latest updates directly to yourΒ inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.