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 , when transferring Ether to another contract, is there a potential risk of gas griefing from not handling the return data? Solidity contract Transferring Ether A Solidity function definition must contain the keyword to be able to accept an Ether value in the invoking call. payable The breaking changes introduced in version 0.6 included introducing the optional and functions as default message handlers. The function, when present, is given priority over the function for handling a call containing a value without message data. Solidity receive fallback receive fallback Transferring Ether with the standard gas stipend can be done with either the or functions on the type transfer send address // 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 is allowed to consume up to 23K gas in its handling function (i.e., when is a contract the or functions). The gas stipend severely restricts what can be achieved inside the handler function, which is the primary cause of the recommendation to use over or . msg.sender msg.sender receive fallback call send transfer Receive Function If present, the function is given priority to handle a message with a and without any . receive msg.value 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 function signature is absent any keyword and associate return type. receive return (If a 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). receive Fallback function If present, the default function for a message with a and or when the function has not also been implemented without . msg.value msg.data receive 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 opcode. call One motivation for using rather than the or functions on the type is the ability to choose the amount of gas to be forwarded for use in the call (gas forwarding). call send transfer address 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 and : calldata; the encoded signature and parameters (equivalent to ) in insize abi.encodeWithSignature("") and : the free memory pointer (as return data is stored in memory at the first free location). out outsize 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 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 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. call call 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 and . A griefing attack using return data from the default function is simply not possible, assuming their contract was implemented in Solidity. receive fallback 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 and must be annotated with @public. It cannot have arguments and cannot return anything. default 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 and opcodes. As the does not define the return data size, the generated IR code must deal with that unknown return data size. RETURNDATASIZE RETURNDATACOPY call Return size of zero Within a Yul code block, the opcode can be invoked with all parameters assigned a value, with the resulting IR code not containing the copying of the return data. call /* * 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 , 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. call 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) to investigate the stack and opcodes. ) asm ( 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. performs the call, using inputs from the stack tag_5 is the top-level call response handling, note the presence of opcode, despite not storing the return data . tag_7 returndatacopy reference in the Solidity code providing with the statement tag_10 require 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) to investigate the stack and opcodes. ) asm ( 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 with irrelevant comments and initializer stripped out, showing the value gets stored in the return data buffer by the default handler. asm 0x01 shuffles entries on the stack, eventually storing the value from the offset in memory tag_1 0x01 0x00 constructs the return of the offset in memory with a size of two bytes (memory nibble size) tag_2 0x00 0x20 Importantly, the return data is stored in memory (with ), meaning that gas cost is incurred, irrespective of whether the caller even uses it. mstore 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 with set parameters. Compile to intermediary representation (IR) to investigate the stack and opcodes. ) call asm ( 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: top-level orchestration for the call and response tag_5 clears the stack and function exit tag_7 reverts providing the error response tag_8 Importantly, you find no occurrences of either or , meaning the caller is not copying any return data to its memory. returndatasize returndatacopy 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