February 28, 2024

Why Careful Validation Matters: A Vulnerability Originating in Inline Assembly

At Sherlock, one of the top Lead Senior Watsons, recently uncovered and helped to resolve a vulnerability related to the lack of overflow protection in Solidity's inline assembly. Read on for the details behind the vulnerability that was detected, allowing the arbitrary call of functions from a vulnerable smart contract.

Inline assembly in Solidity is often used to make calls more gas-efficient or to make efficient data manipulations. However, it is essential to take extra care, as the Solidity compiler's common safety nets do not apply here.

One of the top Lead Senior Watsons recently uncovered and helped resolve a vulnerability related to the lack of overflow protection in Solidity's inline assembly.

Read on for the details behind the vulnerability that 0x52 detected, allowing the arbitrary call of functions from a vulnerable smart contract.

Context

At the time of the audit the wagmi-leverage project used a library called ExternalCall , which provides a function patchAmountAndCall , that is responsible for calling a given target address with a given calldata , that is being patched before.

The function signature is the following:



function _patchAmountAndCall(
        address target,
        bytes calldata data,
        uint256 maxGas,
        uint256 swapAmountInDataIndex,
        uint256 swapAmountInDataValue
    ) internal returns (bool success)

It is notable that at the usage of this function:

  • The target address and the first 4 bytes of data(which will be the function selector) are checked against a whitelist.
  • The swapAmountInDataValue is not directly user-specified inside this function. Inline assembly is used to call the target address with the given data , after it was modified according to the given swapAmountInDataIndex and swapAmountInDataValue parameters.
  • 
    
    assembly ("memory-safe") {
    	let ptr := mload(0x40)
    	calldatacopy(ptr, data.offset, data.length)
    	if gt(swapAmountInDataValue, 0) {
    		mstore(add(add(ptr, 0x24), mul(swapAmountInDataIndex, 0x20)), swapAmountInDataValue)
        }
    	success := call(
    		maxGas,
    		target,
    		0, //value
    		ptr, //Inputs are stored at location ptr
    		data.length,
    		0,
    		0
    	)
    // ...
    }
    
    

    Let's break this down:

  • First, we get the free memory pointer stored in memory at position 0x40 and store it in the ptr variable.
  • Next, we call calldatacopy to copy the given calldata argument data to memory (at the free memory pointer).
  • The following line is the actual patching of the calldata.
  • Finally, we use the call function to call the given target address with the data stored at ptr.
  • So, what is the patching about here:



    As this function is intended to call a swap on the given target, and the amountIn is not a user input, but calculated by the contract, it is an argument in the calldata that has to be patched.

    Therefore, the user can specify the swapAmountInDataIndex to signal at which index the data has to be patched.

    So, if the given swapAmountInDataValue is not 0, we store it a calculated position in memory.


    How is this location calculated?

    Remember, our current ptr still points to the beginning of our calldata. Now we calculate the location like this:

    ptr + 36 (0x24) + swapAmountInDataIndex * 32 (0x20).

    We can see that an offset of 32 + 4 bytes is set to respect the function selector. The user can set another offset of n * 32 bytes via the swapAmountInDataIndex to ensure the amount gets stored in the correct position.

    Vulnerability

    This vulnerability is based on the fact that YUL inline assembly in Solidity has no overflow/underflow protections

    This means a calculation with a user-specified value is prone to over/underflow attacks. In this specific case, this could happen at the memory address calculation for the value to patch. add(add(ptr, 0x24), mul(swapAmountInDataIndex, 0x20)).

    As the swapAmountInDataIndex is user-specified, and the result is an uint256 , it could be set to a really high value and make the calculation overflow.

    By doing this, a malicious actor can overwrite the function selector with a value that was not whitelisted, which finally allows an attacker to call any function with any input on a whitelisted target contract.


    In this case, it could be overwritten with the swapAmountInDataValue, which is not a direct user input.

    However, as it is the amount of a token that the user could specify, a malicious actor could create and use his own token, granting him fine-grained control over this parameter.

    Solution

    To mitigate this vulnerability in the specific case Sherlock suggested to limit the swapAmountInDataValue parameter to the quotient of the data length and 32 (div(data.length, 0x20)), to allow the value to be put at any valid location inside the calldata, or right at the end of it.

    The project team also implemented this solution.

    The Takeaway

    This case emphasizes that developers should take extra care when using inline assembly, doing calculations, or manipulating memory, as the common safety nets of Solidity do not apply in these cases.

    The outlined finding showcases, once more, the importance of thorough security reviews and testing of smart contract code, especially when using inline assembly.