visit
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.
In a Solidity contract, when transferring Ether to another contract, is there a potential risk of gas griefing from not handling the return data?
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
.
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).
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.
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();
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.
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.
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)
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.
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.
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.
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.
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.
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.
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)
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.
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).
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.
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 stacktag_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
statementtag_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)
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
}
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