Skip to content

Latest commit

 

History

History
120 lines (96 loc) · 6.97 KB

M-02.md

File metadata and controls

120 lines (96 loc) · 6.97 KB

Clashing between empty calldata and zero function selector in diamond dispatch logic

Summary

The diamond dispatch logic present in the DiamondProxy contract, used to route functions call, contains a clashing between two different scenarios, causing an invocation with empty calldata (usually meant to transfer ETH) to be interpreted as a function call to a function whose selector has the zero value.

Impact

The ZkSync Era main entrypoint in Ethereum is implemented using the Diamond pattern. Under this model, function calls are routed to the proper facet (i.e. a module) based on the function selector present in calldata (first 4 bytes, or msg.sig).

diamond

This routing is implemented in the DiamondProxy contract:

https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/zksync/DiamondProxy.sol#L20-L55

20:     fallback() external payable {
21:         Diamond.DiamondStorage storage diamondStorage = Diamond.getDiamondStorage();
22:         // Check whether the data contains a "full" selector or it is empty.
23:         // Required because Diamond proxy finds a facet by function signature,
24:         // which is not defined for data length in range [1, 3].
25:         require(msg.data.length >= 4 || msg.data.length == 0, "Ut");
26:         // Get facet from function selector
27:         Diamond.SelectorToFacet memory facet = diamondStorage.selectorToFacet[msg.sig];
28:         address facetAddress = facet.facetAddress;
29: 
30:         require(facetAddress != address(0), "F"); // Proxy has no facet for this selector
31:         require(!diamondStorage.isFrozen || !facet.isFreezable, "q1"); // Facet is frozen
32: 
33:         assembly {
34:             // The pointer to the free memory slot
35:             let ptr := mload(0x40)
36:             // Copy function signature and arguments from calldata at zero position into memory at pointer position
37:             calldatacopy(ptr, 0, calldatasize())
38:             // Delegatecall method of the implementation contract returns 0 on error
39:             let result := delegatecall(gas(), facetAddress, ptr, calldatasize(), 0, 0)
40:             // Get the size of the last return data
41:             let size := returndatasize()
42:             // Copy the size length of bytes from return data at zero position to pointer position
43:             returndatacopy(ptr, 0, size)
44:             // Depending on the result value
45:             switch result
46:             case 0 {
47:                 // End execution and revert state changes
48:                 revert(ptr, size)
49:             }
50:             default {
51:                 // Return data with length of size at pointers position
52:                 return(ptr, size)
53:             }
54:         }
55:     }

As we can see in the previous snippet of code, line 27 fetches the facet based on the msg.sig value. If this selector is correctly mapped (line 30) and the facet is not frozen (line 31), then the call is dispatched to the facet using a delegatecall, similar to standard proxy pattern (lines 33-54).

It should be noted that calls can potentially have empty calldata. This is in fact a supported action by the diamond, since the check present in line 25 allows a calldata length of zero (require(msg.data.length >= 4 || msg.data.length == 0, "Ut")). In this scenario, msg.sig will be bytes4(0), and the implementation will behave exactly as if it were a function call to the zero selector (i.e. when calldata = 0x00000000...). The issue leads to a clash between a call with empty calldata and a real function call to a function whose selector value is zero.

The described conflict has the following impact:

  • A call with empty calldata (e.g. a transfer of ETH), will be interpreted as a call to a function whose selector is zero. The implementation will query the facet in line 27, and will ultimately be reverted by the check in line 30.
  • In the event there is a registered function whose selector is zero (unlikely, but possible), a call with empty calldata will be mistakenly routed to the registered facet for the selector with zero value.

Proof of Concept

In the following test, the diamond receives a call with empty calldata (this is an ETH transfer) that is incorrectly interpreted as a function call to a function with zero selector. The transaction fails due to the check in line 30 with reason "F", as there is no facet associated with a selector of value zero.

function test_Diamond_FailsEmptyCalldata() public {
    // Build a diamond, this will be empty but is enough to demo the issue
    AllowList allowList = new AllowList(owner);
    DiamondInit diamondInit = new DiamondInit();
    
    bytes8 dummyHash = 0x1234567890123456;
    address dummyAddress = makeAddr("dummyAddress");
    bytes memory diamondInitData = abi.encodeWithSelector(
        diamondInit.initialize.selector,
        dummyAddress, //verifier
        owner,
        owner,
        0,
        0,
        0,
        allowList,
        VerifierParams({recursionNodeLevelVkHash: 0, recursionLeafLevelVkHash: 0, recursionCircuitsSetVksHash: 0}),
        false,
        dummyHash,
        dummyHash,
        1000000
    );
    
    Diamond.FacetCut[] memory facetCuts = new Diamond.FacetCut[](0);
    
    Diamond.DiamondCutData memory diamondCutData = Diamond.DiamondCutData({
        facetCuts: facetCuts,
        initAddress: address(diamondInit),
        initCalldata: diamondInitData
    });
    
    uint256 chainId = block.chainid;
    DiamondProxy diamondProxy = new DiamondProxy(chainId, diamondCutData);
    
    vm.deal(user, 1 ether);
    vm.prank(user);
    (bool success, bytes memory response) = address(diamondProxy).call{value: 1 ether}("");
    // this fails, ETH transfer will be associated with a call to a function with a zero selector and fail
    assertFalse(success);
}

Recommendation

The key question here is who receives a call with empty calldata, since a diamond is expected to contain multiple facets. The following are different alternatives depending on the intended use case:

  1. Have the DiamondProxy receive the call without actually invoking any facet. This will solve the clashing issue, while providing a behavior similar to having an empty receive() function.
  2. Have a way to register which facet should handle this type of invocations. When receiving a call with msg.data.length == 0, route the call to the registered facet.
  3. Similarly to the previous point, instead of registering a facet, have a default facet to handle this, i.e. ReceiveFacet. When receiving a call with msg.data.length == 0, route the call to this default facet.
  4. If these types of calls should not be needed, then prevent calls with empty calldata (i.e. disallow calls with msg.data.length == 0) to avoid the potential clashing with a function with zero value selector.