Yul in Solidity: Potential Gas Griefing From Return Data When Transferring Ether?

Written by squeakycactus | Published 2023/11/23
Tech Story Tags: evm | yul | solidity | asm | ethereum | gas-griefing | vyper | smart-contract-security

TLDRvia the TL;DR App

If you're unfamiliar with the languages of Solidity and Yul or lack a passion for the specifics of EVM assembly, then this may not be the rabbit hole you're looking for.

Question

In a Solidity contract, when transferring Ether to another contract, is there a potential risk of gas griefing from not handling the return data?

Transferring Ether

A Solidity function definition must contain the payable keyword to be able to accept an Ether value in the invoking call.

The breaking changes introduced in Solidity version 0.6 included introducing the optional receive and fallback functions as default message handlers. The receive function, when present, is given priority over the fallback function for handling a call containing a value without message data.

Transferring Ether with the standard gas stipend can be done with either the transfer or send functions on the address type

// reverts on failure, forwards 2300 gas stipend
msg.sender.transfer(1 ether);

// returns false on failure, forwards 2300 gas stipend
msg.sender.send(1 ether);

The recipient msg.sender is allowed to consume up to 23K gas in its handling function (i.e., when msg.sender is a contract the receive or fallback functions). The gas stipend severely restricts what can be achieved inside the handler function, which is the primary cause of the recommendation to use call over send or transfer.

Receive Function

If present, the receive function is given priority to handle a message with a msg.value and without any msg.data.

A contract can now have only one receive function, declared with the syntax: receive() external payable {…} (without the function keyword).

Important to note is the receive function signature is absent any return keyword and associate return type.

(If a receive function is defined with a return value, it does not conform to the language specification and will fail compilation; likewise, attempting to return a value inside a function that lacks a return value in its signature will also fail compilation).

Fallback function

If present, the default function for a message with a msg.value and msg.data or when the receive function has not also been implemented without msg.data.

declared using fallback() external [payable] {…} (without the function keyword). This function cannot have arguments, cannot return anything and must have external visibility. The fallback function always receives data, but to also receive Ether, you should mark it as payable.

Once again, we have a default handling function that is forbidden from returning anything.

Tuples are syntactic sugar

Internally, Solidity allows tuple types that can be used to return multiple values at the same time. Tuples can be assigned to newly declared variables or existing ones, but the number of assignments must match the length of the returned list.

Tuples are not proper types in Solidity, they can only be used to form syntactic groupings of expressions.

When de-structuring a list of return values, some assignments can be omitted when not pertinent to the code.

        // Variables declared with type and assigned from the returned tuple,
        // not all elements have to be specified (but the number must match).
        (uint x, , uint y) = f();

Yul

Solidity supports both assembly blocks and inline assembly in Yul. Supporting the same feature set as Solidity but with the opportunity for greater granularity that can also lead to more efficiently generated bytecode.

Call opcode

Invoking a contract function is achieved using the call opcode.

One motivation for using call rather than the send or transfer functions on the address type is the ability to choose the amount of gas to be forwarded for use in the call (gas forwarding).

call(g, a, v, in, insize, out, outsize)

call contract at address a with input mem[in…(in+insize)) providing g gas and v wei and output area mem[out…(out+outsize)) returning zero on error (eg. out of gas) and one on success.

Inline call

An inline code fragment can use key-value pairings for parameter assignments, where other parameters may be omitted as they are inferred from the surrounding Solidity context or given default values.

 (bool success,) = to.call{value: amount}("");

Values in the above example for call(g, a, v, in, insize, out, outsize):

  • g: gas(); remaining gas allowance

  • a: to; address of the contract being called

  • v: amount; Ether to transfer denominated in wei

  • in and insize: calldata; the encoded signature and parameters (equivalent to abi.encodeWithSignature(""))

  • out and outsize: the free memory pointer (as return data is stored in memory at the first free location).

Importantly, as the call is being made in Yul and not Solidity, the Solidity compile time type checks are absent, resulting in the return data being an unknown, and the compiled code will have to handle the possibility of that unknown return data.

(See Appendix A for sample code, ASM and breakdown)

Gas griefing

An avenue of attack where the goal is not to provide direct profit for the attacker but instead to cause an inconvenience to the victim. Greedily consuming sufficient gas to either prevent correct execution on return or simply to exorbitantly increase transaction costs can achieve griefing.

Oversized return data

When the size of the return data is unknown (as it is with the previous Yul inline call example), then the entire return data is copied to memory even though the assignment is not being used (copying to memory costs gas). If the call opcode forwards all the available gas, there is the opportunity for a malevolent recipient to dynamically create return data large enough to consume all the forwarded gas.

When you are only performing a value transfer with a generous gas allocation, you may not have expected it to cost an obscene amount of gas, nor might you have expected enough gas spending to cause an out-of-gas exception. Causing inconvenience is the purpose of the griefing attack.

Solidity contract - default functions

The Solidity language specification explicitly forbids return values from the default functions of receive and fallback. A griefing attack using return data from the default function is simply not possible, assuming their contract was implemented in Solidity.

Vyper contract - default function

An alternative smart contract language is Vyper, which provides a similar feature set but aims at being less permissive than Solidity to prevent risky implementations.

Vyper has a default function that will be invoked (as the name implies) when the contract lacks a matching named function to the function selector bytes of the calldata.

This function is always named default and must be annotated with @public. It cannot have arguments and cannot return anything.

The restriction on forbidding any return value is identical to the Solidity default functions, and as with Solidity, the attacker is unable to implement their griefing contract in Vyper.

Yul contract

As the restrictions on the default function(s) having no return data are part of the language specification rather than the EVM specification, what happens if we implement the attack contract at a lower level of abstraction?

(See Appendix B for sample code, asm and breakdown)

When implementing the entire contract in Yul, a default function that returns data can be successfully implemented. Although writing a contract using only Yul dials up the technical requirements for the attacker, it does mean that gas griefing from non-handling the return data for value transfers that use call is possible.

Return data buffer

The Byzantium hardfork (2017) included EIP-211, which introduced both the opcodes and the return data buffer to deal with return data of unknown size.

Compiler implementors are advised to reserve a zero-length area for return data if the size of the return data is unknown before the call and then use RETURNDATACOPY in conjunction with RETURNDATASIZE to actually retrieve the data.

(See Appendix A for sample code, asm and breakdown)

When compiling the Solidity with inline Yul into the intermediate representation (IR), we can see it contains the RETURNDATASIZE and RETURNDATACOPY opcodes. As the call does not define the return data size, the generated IR code must deal with that unknown return data size.

Return size of zero

Within a Yul code block, the call opcode can be invoked with all parameters assigned a value, with the resulting IR code not containing the copying of the return data.

        /*
         *  1. As we want to call the default handler, empty calldata is sufficient
         *      :. `argsOffset` and `argsLength` are both zero
         *  2. To ignore the return buffer we must state it explicitly
         *      :. `retOffset` and `retLength` are both zero
         */
        assembly {
            success := call(gas(), recipient, amount, 0, 0, 0, 0)
        }

(See Appendix C for sample code, asm and breakdown)

Is the risk really mitigated?

Another important detail from EIP-211:

Note that the EVM implementation needs to keep the return data until the next call or the return from the current call. Since this resource was already paid for as part of the memory of the callee, it should not be a problem. Implementations may either choose to keep the full memory of the callee alive until the next call or copy only the return data to a special memory area.

Put simply, when an attacker is attempting to grief a victim using a malicious contract that bloats the return data, the caller can do nothing to prevent the gas from being consumed by the malicious contract by storing its return data in the return data buffer.

Mitigation

The only way to properly mitigate the risk of interacting with a malicious contract would be to strictly follow a process of only interacting with trusted contracts or ones that can be independently verified as sound (e.g. checking that the verified source code on Etherscan will behave appropriately).

Summary

When transferring Ether from a victim contract to a griefer contract using a Yul call, if the griefer was written in Solidity or Vyper, there is no risk of griefing from non-handling of return data, as their default functions are forbidden from using the return data buffer. However, if written in Yul, then data could be returned by the default handler.

The griefing attack is the consumption of an unreasonable amount of the forwarded gas by returning oversized data, causing inconvenience to the victim.

Although the victim can explicitly set the return data size to zero, avoiding the generation of the memory copying code in the victim's contract, it cannot prevent the griefer's gas consumption from storing their data in the return data buffer.

Rather than a unique problem with handling return data, this issue seems more like a generic risk when yielding the control flow to another contract. When outside the dominion of your control, you are powerless to restrict what the other contract does; instead, you are able to restrict only the amount of computation by limiting the gas forwarded in the call.

References

https://soliditylang.org/blog/2020/03/23/fallback-receive-split/ https://docs.soliditylang.org/en/v0.8.22/contracts.html#receive-ether-function https://docs.soliditylang.org/en/v0.8.22/cheatsheet.html#members-of-address https://docs.soliditylang.org/en/v0.8.22/security-considerations.html#sending-and-receiving-ether https://docs.soliditylang.org/en/v0.8.22/control-structures.html?highlight=tuples#destructuring-assignments-and-returning-multiple-values https://docs.soliditylang.org/en/v0.8.22/yul.html#evm-dialect https://docs.soliditylang.org/en/v0.8.22/assembly.html https://docs.soliditylang.org/en/v0.8.22/internals/layout_in_memory.html#layout-in-memory https://www.evm.codes/ https://www.ethervm.io/ https://eips.ethereum.org/EIPS/eip-211 https://scsfg.io/hackers/griefing/ https://docs.vyperlang.org/en/v0.1.0-beta.17/structure-of-a-contract.html https://blog.ethereum.org/2017/10/12/byzantium-hf-announcement

Appendices

Appendix A - Inline Yul call

Solidity contract with an inline Yul call to transfer value. Compile to intermediary representation (IR) asm to investigate the stack and opcodes. (forge build --extra-output-files evm.assembly)

pragma solidity 0.8.20;

contract InlineYul {
    function solidityCall() external{
        address recipient = msg.sender;
        uint256 amount = 1 ether;

        (bool success,) = (recipient).call{value: amount}("");

        require(success, "transfer failed");
    }
}

IR are the stack operations with opcodes, with the below being the pertinent subset from the compilation of InlineYul.

  • tag_5 performs the call, using inputs from the stack
  • tag_7 is the top-level call response handling, note the presence of returndatacopy opcode, despite not storing the return data reference in the Solidity code.
  • tag_10 providing with the require statement
tag_5:
    /* "src/InlineYul.sol":136:153  address recipient */
  0x00
    /* "src/InlineYul.sol":156:166  msg.sender */
  caller
    /* "src/InlineYul.sol":136:166  address recipient = msg.sender */
  swap1
  pop
    /* "src/InlineYul.sol":177:191  uint256 amount */
  0x00
    /* "src/InlineYul.sol":194:201  1 ether */
  0x0de0b6b3a7640000
    /* "src/InlineYul.sol":177:201  uint256 amount = 1 ether */
  swap1
  pop
    /* "src/InlineYul.sol":215:227  bool success */
  0x00
    /* "src/InlineYul.sol":233:242  recipient */
  dup3
    /* "src/InlineYul.sol":232:248  (recipient).call */
  0xffffffffffffffffffffffffffffffffffffffff
  and
    /* "src/InlineYul.sol":256:262  amount */
  dup3
    /* "src/InlineYul.sol":232:267  (recipient).call{value: amount}("") */
  mload(0x40)
  tag_7
  swap1
  tag_8
  jump  // in
tag_7:
  0x00
  mload(0x40)
  dup1
  dup4
  sub
  dup2
  dup6
  dup8
  gas
  call
  swap3
  pop
  pop
  pop
  returndatasize
  dup1
  0x00
  dup2
  eq
  tag_11
  jumpi
  mload(0x40)
  swap2
  pop
  and(add(returndatasize, 0x3f), not(0x1f))
  dup3
  add
  0x40
  mstore
  returndatasize
  dup3
  mstore
  returndatasize
  0x00
  0x20
  dup5
  add
  returndatacopy
  jump(tag_10)

Appendix B - Yul contract

A Yul contract of a storage box (without the setter for brevity). Compile to intermediary representation (IR) asm to investigate the stack and opcodes. (forge build --extra-output-files evm.assembly)

object "Box" {
    code {
        let runtime_size := datasize("runtime")
        let runtime_offset := dataoffset("runtime")
        datacopy(0, runtime_offset, runtime_size)
        return(0, runtime_size)
    }

    object "runtime" {
        code {
                let data := fallback()
                return(data, 32)

            function fallback() -> memloc {
                let val := 0x01
                memloc := 0
                mstore(memloc, val)
            }
        }
    }
}

The asm with irrelevant comments and initializer stripped out, showing the value 0x01 gets stored in the return data buffer by the default handler.

  • tag_1 shuffles entries on the stack, eventually storing the value 0x01 from the offset 0x00 in memory

  • tag_2 constructs the return of the offset 0x00 in memory with a size of two bytes 0x20 (memory nibble size)

Importantly, the return data is stored in memory (with mstore), meaning that gas cost is incurred, irrespective of whether the caller even uses it.

sub_0: assembly {
      tag_2
      tag_1
      jump  // in
    tag_2:
      0x20
      dup2
      return
    tag_1:
      0x00
      0x01
      0x00
      swap2
      pop
      dup1
      dup3
      mstore
      pop
      swap1
      jump  // out
}

Appendix C - Block Yul call

Solidity contract with a block of assembly performing a call with set parameters. Compile to intermediary representation (IR) asm to investigate the stack and opcodes. (forge build --extra-output-files evm.assembly)

pragma solidity 0.8.20;

contract BlockYul {

    function call() external {
        address recipient = msg.sender;
        uint256 amount = 1 ether;
        bool success;

        /*
         *  1. As we want to call the default handler, empty calldata is sufficient
         *      :. `argsOffset` and `argsLength` are both zero
         *  2. To ignore the return buffer we must state it explicitly
         *      :. `retOffset` and `retLength` are both zero
         */
        assembly {
            success := call(gas(), recipient, amount, 0, 0, 0, 0)
        }

        require(success, "transfer failed");
    }
}

IR are the stack operations with opcodes, with the below being the pertinent subset from the compilation of BlockYul:

  • tag_5 top-level orchestration for the call and response

  • tag_7 clears the stack and function exit

  • tag_8 reverts providing the error response

Importantly, you find no occurrences of either returndatasize or returndatacopy, meaning the caller is not copying any return data to its memory.

tag_5:
    /* "src/BlockYul.sol":130:147  address recipient */
  0x00
    /* "src/BlockYul.sol":150:160  msg.sender */
  caller
    /* "src/BlockYul.sol":130:160  address recipient = msg.sender */
  swap1
  pop
    /* "src/BlockYul.sol":171:185  uint256 amount */
  0x00
    /* "src/BlockYul.sol":188:195  1 ether */
  0x0de0b6b3a7640000
    /* "src/BlockYul.sol":171:195  uint256 amount = 1 ether */
  swap1
  pop
    /* "src/BlockYul.sol":206:218  bool success */
  0x00
    /* "src/BlockYul.sol":614:615  0 */
  dup1
    /* "src/BlockYul.sol":611:612  0 */
  0x00
    /* "src/BlockYul.sol":608:609  0 */
  dup1
    /* "src/BlockYul.sol":605:606  0 */
  0x00
    /* "src/BlockYul.sol":597:603  amount */
  dup6
    /* "src/BlockYul.sol":586:595  recipient */
  dup8
    /* "src/BlockYul.sol":579:584  gas() */
  gas
    /* "src/BlockYul.sol":574:616  call(gas(), recipient, amount, 0, 0, 0, 0) */
  call
    /* "src/BlockYul.sol":563:616  success := call(gas(), recipient, amount, 0, 0, 0, 0) */
  swap1
  pop
    /* "src/BlockYul.sol":647:654  success */
  dup1
    /* "src/BlockYul.sol":639:674  require(success, "transfer failed") */
  tag_7
  jumpi
  mload(0x40)  0x08c379a000000000000000000000000000000000000000000000000000000000
  dup2
  mstore
  0x04
  add
  tag_8
  swap1
  tag_9
  jump  // in
tag_8:
  mload(0x40)
  dup1
  swap2
  sub
  swap1
  revert
tag_7:
    /* "src/BlockYul.sol":119:682  {... */
  pop
  pop
  pop
    /* "src/BlockYul.sol":94:682  function call() external {... */
  jump  // out


Written by squeakycactus | Occasional Web3 dev & security researcher, full-time time waster.
Published by HackerNoon on 2023/11/23