low

SHA256 built-in will return input value on chains without SHA256 precompile

Reward

Total

455.95 USDC

Selected
455.95 USDC
Selected Submission

SHA256 built-in will return input value on chains without SHA256 precompile

Severity

High Risk

Relevant GitHub Links

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L674-L689

https://github.com/vyperlang/vyper/blob/b01cd686aa567b32498fefd76bd96b0597c6f099/vyper/builtins/functions.py#L629-L641

Summary

When the SHA256 built-in function is called with a bytes32 input, we use the same scratch space to save the input and return the output. If a chain does not implement the SHA256 precompile (which is a requirement for many ZK rollups), this address will be an EOA, so the call will silently fail and we'll return the input value from memory.

Vulnerability Details

The SHA256 built-in function is a wrapper around the precompiled contract at address(0x02). In the event that it is called with a bytes32 argument, we perform the following logic:

  1. Place the input argument at the 0 memory slot.
  2. Call the precompile with an input of memory slots 0-31.
  3. Assert that the call succeeded.
  4. Ask the precompile to return the hashed value to memory slots 0-31.
  5. mload the value from memory slots 0-31 to return the hashed value.

We can see this logic implemented here:

sub = args[0]
# bytes32 input
if sub.typ == BYTES32_T:
    return IRnode.from_list(
        [
            "seq",
            ["mstore", MemoryPositions.FREE_VAR_SPACE, sub],
            # @ok this will return no data if not enough gas? so it'll juse use the input
            # right now this can't be exploited becuase only 72 gas, so 1/64 remaining is just 1-2, not enough for mload
            # but if precompile gas increased even slightly, there would be a problem
            # - fuck i'm an idiot, after hours realized _make_sha256_call has an assert so requires a return value
            _make_sha256_call(
                inp_start=MemoryPositions.FREE_VAR_SPACE,
                inp_len=32,
                out_start=MemoryPositions.FREE_VAR_SPACE,
                out_len=32,
            ),
            ["mload", MemoryPositions.FREE_VAR_SPACE],  # push value onto stack
        ],
        typ=BYTES32_T,
        add_gas_estimate=SHA256_BASE_GAS + 1 * SHA256_PER_WORD_GAS,
    )
def _make_sha256_call(inp_start, inp_len, out_start, out_len):
    return [
        "assert",
        [
            "staticcall",
            ["gas"],  # gas
            SHA256_ADDRESS,  # address
            inp_start,
            inp_len,
            out_start,
            out_len,
        ],
    ]

In the event that the staticcall to address(0x02) succeeds (ie passes the assert) but returns no data, the input data will remain at memory slot 0 and will be returned from the function call.

In the event that a chain does not implement the SHA256 precompile, this is exactly what would happen. Because calls to EOAs always return 1 (success), such a call will pass the assert, but will return no calldata. The input data will then be returned with no error, leading to major vulnerabilities in any contract that uses this function.

Note that not implementing the SHA256 precompile is a common requirement for ZK rollups. Both ZKsync and Scroll do not implement the precompile at present. Fortunately, both currently have errors that will stop this vulnerability from being exploited, but future rollups that simply skip implementing the precompile will be vulnerable.

Impact

Rollups that do not implement the SHA256 precompile will lead to the SHA256 built-in function returning the input (rather than no data) for all bytes32 inputs.

Tools Used

Manual Review

Recommendations

Because there is a risk of the call succeeding with no return value, return the data to FREE_VAR_SPACE2 to ensure that 0 is returned in the case of no data being returned.