Examples of incorrect use of the memory-safe annotation

Hey everyone,
For educational purposes, I got asked to provide an example of how an incorrect application of the memory-safe annotation might result in incorrect behavior by the Solidity compiler. we’re looking for sample code which:

  1. incorrectly uses the memory-safe annotation that compiles and runs

  2. upon removal of the incorrect memory-safe annotation, still compiles and runs (with the same compilation flags), but produces a different result

the only difference between the two trials should be removing the “memory-safe” annotation.

I came up with the following code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

contract WithoutMemorySafe {
    // Compute twice the sum of twenty squared parameters
    function g(
        uint256 x0,
        uint256 x1,
        uint256 x2,
        uint256 x3,
        uint256 x4,
        uint256 x5,
        uint256 x6,
        uint256 x7,
        uint256 x8,
        uint256 x9,
        uint256 x10,
        uint256 x11,
        uint256 x12,
        uint256 x13,
        uint256 x14,
        uint256 x15,
        uint256 x16,
        uint256 x17,
        uint256 x18,
        uint256 x19
    ) public pure returns (uint256 result) {
        assembly {
            {
                let u0 := arg(x0)
                let u1 := arg(x1)
                let u2 := arg(x2)
                let u3 := arg(x3)
                let u4 := arg(x4)
                let u5 := arg(x5)
                let u6 := arg(x6)
                let u7 := arg(x7)
                let u8 := arg(x8)
                let u9 := arg(x9)
                let u10 := arg(x10)
                let u11 := arg(x11)
                let u12 := arg(x12)
                let u13 := arg(x13)
                let u14 := arg(x14)
                let u15 := arg(x15)
                let u16 := arg(x16)
                let u17 := arg(x17)
                let u18 := arg(x18)
                let u19 := arg(x19)

                result := sum(u19, u18, u17, u16, u15, u14, u13, u12, u11, u10, u9, u8, u7, u6, u5, u4, u3, u2, u1, u0)
            }

            // Compute twice the square of an element
            function arg(i) -> x {
                let s := mul(i, i)
                x := add(s, s)
            }

            function sum(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19) ->
                output
            {
                // Changes the behavior here
                mstore(0x80, 999)
                let temp := add(a0, a1)
                temp := add(temp, a2)
                temp := add(temp, a3)
                temp := add(temp, a4)
                temp := add(temp, a5)
                temp := add(temp, a6)
                temp := add(temp, a7)
                temp := add(temp, a8)
                temp := add(temp, a9)
                temp := add(temp, a10)
                temp := add(temp, a11)
                temp := add(temp, a12)
                temp := add(temp, a13)
                temp := add(temp, a14)
                temp := add(temp, a15)
                temp := add(temp, a16)
                temp := add(temp, a17)
                temp := add(temp, a18)
                temp := add(temp, a19)
                output := temp
            }
        }
    }
}

It compiles with solc 0.8.20 using the IR pipeline. Now, I need to remove all the non-intrinsic call and still have a code that satisfies the conditions. But now I am stuck.
Any help, insights is more than welcomed.

What’s wrong with that snippet? It does reproduce the problem if you add the ("memory-safe") annotation to the assembly block.

If you compile it to Yul with solc --optimize --via-ir --ir-optimized --debug-info none, you’ll see that the compiler inserts a memoryguard:

let _1 := memoryguard(0xa0)

The fact that the value is 0xa0, while normally the space reserved by the compiler ends at 0x80 indicates that one variable was moved to memory.

Then the compiler uses that slot to store the value of x1 (the second argument of g):

mstore(0x80, calldataload(36))

But your assembly block overwrites it. Since the memory move happens later in the pipeline, the compiler does not know that this is the consequence of your code. It has to rely on the annotation. Since it’s there it takes it at a face value:

mstore(128, 999)

Later the value calculation for most variables correctly uses the parameter values from calldata:

add(mul(calldataload(68), calldataload(68)), mul(calldataload(68), calldataload(68)))

But for x1 the value is loaded from the reserved memory slot:

add(mul(mload(0x80), mload(0x80)), mul(mload(0x80), mload(0x80)))

Since you overwrote it, this is wrong and is a change in behavior compared to the theoretical behavior of the version without the annotation. If you compile to Yul without the annotation, you’ll see that it does not have this problem, because x1 is not stored in memory (also the code is a bit different, because some optimizations cannot be applied to unsafe assembly blocks in general, even though they might have been fine in this particular case).

It won’t compile all the way to the bytecode without the annotation though. If it did, moving variables to memory would have been unnecessary in the first place. And any example of this kind of breakage will necessarily require there to be some variables needing to be moved to memory because this is currently the what the annotation is meant protect.