today i go over some openzeppelin contracts while discussing features and vulnerabilities (such as reentrancy and ownership).
this post is suitable for web2|3 hackers, solidity or non-solidity peeps, and computer nerds in general 🤓. for a general intro to solidity, you can check my web3-starter-sol.
000. an open and secure zeppelin
001. utils/
- Context.sol: a wrapper for msg.sender and msg.data
- Array.sol: handy methods for arrays
010. access/
- Ownable.sol: providing onlyOwner modifier
- Ownable2Step.sol: an example of inheriting Ownable
011. security/
- ReentrancyGuard.sol: guard against single function reentrancy attacks
100. proxy/
- Proxy.sol: implementing core low-level delegation functionality
founded in 2015, openzeppelin is an awesome crypto cybersecurity company that has created essential standards for secure blockchain applications.
besides security audits, their main products are the defender platform (now 2.0), the forta network, and the widely used smart contracts libraries (containing implementations of standards such as ERC20 and ERC721, access control and role-based schemes, reusable solidity components, etc.). they also have a vast smart contract documentation (for example, on things like how to extend their contracts via inheritance) and the solidity wizard tool.
diving into their github, there are several projects worth an in-depth review. for instance, my next post will be about my solutions to their ctf game (called ethernaut), using a systematic methodology with foundry (you can take a peak already at my github).
however, today we will be talking about some of openzeppelin’s smart contracts, inside util/
, access/
, security/
, and proxy/
:
|----- contracts/
|
|----- finance/
|----- governance/
|----- interfaces/
|----- metatx/
|----- mocks/
|----- token/
|----- ✨👀✨ utils/
|----- ✨👀✨ access/
|----- ✨👀✨ security/
|----- ✨👀✨ proxy/
[ perhaps another time we will look at finance/
(e.g., VestingWallet.sol
) or governance/
(e.g., TimelockController.sol
and Governor.sol
), or the token interfaces, metatx/
(e.g., ERC2771*.sol
), mock/
, token/
(e.g., ERC1155
, ERC20
, ERC72
), etc… as they are all relevant and fun ].
|----- utils/
|
|----- cryptography/
|----- ECDSA.sol
|----- EIP712.sol
|----- MerkleProof.sol
|----- MessageHashUtils.sol
L----- SignatureChecker.sol
|----- introspection/
L----- ERC165.sol
|----- math/
|----- Math.sol
|----- SafeCast.sol
L----- SignedMath.sol
|----- structs/
|----- BitMaps.sol
|----- Checkpoints.sol
|----- DoubleEndedQueue.sol
|----- EnumerableMap.sol
L----- EnumerableSet.sol
|----- Base64.sol
|----- Address.sol
|----- Create2.sol
|----- Multicall.sol
|----- Nonces.sol
|----- ShortStrings.sol
|----- StorageSlot.sol
|----- Strings.sol
|----- ✨👀✨ Arrays.sol
L----- ✨👀✨ Context.sol
utils/
are miscellaneous contracts and libraries containing utilities for new data types, security, and safe low-level primitives.
before we look at Ownable
, Proxy
, and ReentrancyGuard,
we will look at Context
(as it is a short && sweet inherited suite) and to Array
(as we can talk a little bit about one of my favorite subjects: algorithms).
for completeness, the other contracts in this directory are:
the libraries Address
,Base64
, and Strings
, providing operations related to the native data types.
SafeCast
providing ways to convert between signed
and unsigned
types safely.
Multicall
providing a way to batch together multiple calls in one external call.
EnumerableMap
is an extension of mapping
with key-value enumeration (and iteration). same for EnumerableSet
.
Create2
provides utilities to safely use the EVM CREATE2
opcode without dealing with low-level assembly.
Context
is an abstract
wrapper for the current execution context, i.e., the transaction sender and data.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
contracts must be marked as
abstract
when at least one of their functions is not implemented or they do not supply arguments for their base contract constructor. they cannot be instantiated directly or override an implemented virtual function with an unimplemented one.
abstract
contracts can be helpful in the same logic as defining methods through an interface is.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
this simple contract creates two internal
view
virtual
functions for both msg.sender
and msg.data
.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
internal
functions can only be accessed from inside the current contract or contracts deriving from it. they cannot be accessed externally and are not exposed through the contract’s ABI.
external
functions are part of the interface and can only be called from other contracts and transactions. they cannot be called internality (except withthis
).
public
functions are part of the contract interface and can be either called internally or with message calls.
virtual
means that the function can change its behavior in derived (overriding) classes (the keywordoverride
must be used in the overriding modifier).
view
functions declare that no state will be changed. when aview
function is called, theSTATICALL
opcode is used (enforcing the state to stay unmodified). for libraryview
functions,DELEGATECALL
is used (there are no runtime checks that prevent state modifications). aview
function cannot modify the state of the contract, for instance by writing to state variables, creating other contracts, emitting events, sending ether withcall()
or using any low-level calls, usingselfdestruct()
, calling functions thatpure
orview
, or using inline assembly with certain opcodes.
pure
functions declare that no state variable will be changed or read (i.e., it promises not to modify or read from the state). it’s possible to evaluate apure
function at compile-time given only its inputs andmsg.data
(without knowledge of the blockchain state).to understand visibility and getters, check solidity by example.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
in summary, Context
is an excellent example of how openzeppelin engineers design their code to be more generic and upgradeable.
for instance, if msg.sender
becomes obsolete (such as tx.origin
), this primitive can be updated in this one-liner instead of across all contracts in the world.
an Array
type in solidity has a compile-time fixed size or a dynamic size, and can be declared several ways:
uint[] public array;
uint[] public array = [1, 3, 7];
uint[10] public array;
openzeppelin’s Array
is a library
that provides a collection of functions related to array types.
it utilizes utils/StorageSlot.sol
(for reading and writing primitive types to specific storage slots) and util/math/Math.sol
(for math utilities missing in solidity).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
libraries are like contracts, but without any state variable or payable function.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
the internal
view
findUpperBound()
function is an O(log n)
search method for (ascending order and non-repeated) sorted arrays, which returns the first index that contains a value greater or equal to element
:
💜 this is just our good and old binary search! 💜
if python is more familiar to you, here is a classical implementation of this algorithm:
mostly, when dealing with binary search algorithms, you want to pay attention to the three possible “templates”:
while left < right
, with left = mid + 1
and right = mid - 1
.
while left < right
, with left = mid + 1
and right = mid
, and left
is returned.
while left + 1 < right
, with left = mid
and right = mid
, and left
and right
are returned.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if you are not familiar with algorithms and data structure, i have an entire repository teaching these subjects with detailed examples, based on a book i published many years ago. you should check it out. ( :
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
in the case of Array
, we are looking at the second template:
mid
will always be less than high because Math.average()
rounds towards zero (integer division with truncation).
low
is the exclusive upper bound.
the inclusive upper bound is return
.
the next three internal
pure
unsafeAccess()
functions deal with the assembly
code that calculates the storage slot of the element at the given index pos
, for either address[]
, bytes32[]
, or uint256[]
data structures.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
the types
bool
,uint256
,int256
, andaddress
are some of the primitive types available in solidity.
mappings
are non-iterable maps where the keys can be a built-in type,bytes
,string
, or even a contract.the value types
bytes1
,bytes2
,…,bytes32
hold sequences of bytes from one to up to32
. the typebytes32[]
is an array of bytes (the same wayuint256[]
is an array ofuint256
, etc.).by the way, due to padding rules,
bytes*
with anything less than32
is a waste (and should be replaced bybytes
, a dynamically-sized byte array).~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
the language used for inline assembly in solidity is called yul and offers a way to access the EVM at a low level, bypassing safety features and checks.
mstore()
represents the opcodeMSTORE
that writes a(u)int256
to memory (i.e., volatile read-write RAM which is not persistent across transactions).
keccak256(bytes memory)
is a cryptographic function that computes the keccak-256 hash of the input (and is used all over ethereum, for example, for creating deterministic unique IDs, commit-reveal schemes, and compact signatures).~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
finally, two internal
pure
unsafeMemoryAccess()
functions deal with memory access unsafely by skipping solidity’s index-out-of-range check.
they should only be used if the input index pos
is lower than the array length:
|----- access/
|
|----- ✨👀✨ Ownable.sol
|----- ✨👀✨ Ownable2Step.sol
|----- AccessControl.sol
L----- extensions/
in general, access control is given through ownership, where an account is assigned as the contract’s owner
.
the access/
subdirectory provides libraries to restrict who can access the functions of a contract or when they can do it.
while AccessControl
states general role-based access control features, Ownable
implements a simple mechanism with a single owner that can be assigned to a single account.
let’s look at the Ownable
contract and how an account (owner
) can be granted access to specific functions through the onlyOwner
modifier (which reverts any function that is not called by the address registered as owner
).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
modifiers are code that can be run before and/or after a function call. they are used to restrict access, validate inputs, and perform security checks.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
the main features of this abstract
contract are:
defining the modifier onlyOwner
.
defining a “special” address called owner
, set to the address provided by the deployer.
defining how owner
can be changed with transferOwnership()
.
first, we see that the contract is inheriting from utils/Context.sol
(discussed above) and has one private
state variable, _owner
(remember, state variables are declared outside a function, stored in storage, and updated through a transaction).
then, the contract performs two sanity checks, throwing error
if the caller account is not authorized to perform an operation or the owner
is not a valid account (e.g., address 0x0
).
after that, the OwnershipTransferred()
event
is declared.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
event
is a convenient interface for abstracting the EVM logging protocol. their log entries provide the contract’s address, a series of up to four topics, and some binary data. events leverage the function’s ABI to interpret the typed structure, and can also be used as a cheap form of storage.
error
allows the definition of descriptive names and data for failure situations, with an opcode to abort execution and revert all state changes. errors can be thrown by callingrequire
,revert
, orassert
.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
next, constructor
is called, and the contract is initialized, setting the address provided by the deployer as the initial owner
(by default, owner
is the account that deployed the contract).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
a constructor is an optional function executed upon contract creation that can run contract initialization code. before it is executed, state variables are initialized (to their values or default values). after it has run, the final code of the contract is deployed to the blockchain.
if there is no constructor, the contract will assume the default
constructor() {}
.learn more about how
constructor
could be exploited in my ethernaut solution for fal1out.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
finally, we see the creation of the modifier onlyOwner
, stating that the function must be called by the owner
.
the internal
view
virtual
checker _checkOwner()
verifies that msg.sender
is owner
, and any other account reverts with OwnableUnauthorizedAccount()
.
if the check pass, _;
states that the function should execute normally as it will be replaced by the actual function body when the modifier is used.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
the underscore “_” is used inside a function modifier to tell solidity to execute the rest of the code.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
note that _msgSender()
is a wrapper from Context.sol
, the same way owner()
is a wrapper for the state variable _owner
.
the last part of this contract is two public
and one internal
functions to deal with ownership changes. note that the public
functions are already modified by onlyOwner
.
renounceOwnership()
leaves the contract without owner
and can only be called by the current owner
.
transferOwnership()
and _transferOwnership()
transfer owner
to a new account (newOwner)
, emitting an OwnershipTransferred()
event
. they also can only be called by the current owner
.
in conclusion, the modifier onlyOwner
is seen in many projects as ownership can be assigned to a simple EOA, multiple EOAs, multisig accounts, or complex modules with timelocks and governance. just a few examples: the address manager contract in optimistic ethereum, the usdc stablecoin contract, and the chainLink price feeds contract.
as a final example of its applicability, here is an example in an AllowList
:
the limitations of Ownable
is that only one address can be owner
at a given time, and only the owner
gets to decide who is newOwner
.
when different authorization levels are needed, role-based access control (RBAC) can define multiple roles, each allowed to perform different sets of actions (e.g., instead of onlyOwner
, use onlyAdminRole
in some places, and onlyModeratorRole
in others).
openzeppelin provides the contract Roles and AccessControl for such extensions.
as an illustration, let’s take a quick look at another contract in the access/
directory, whose parent is Ownable
.
Ownable2Step
provides an access control mechanism where there is an account (owner
) with, now, two functions for ownership transferring, transferOwnership()
and acceptOwnership()
, for added safety of a securely designed two-step process:
|----- security/
|
|----- Pausable.sol
L----- ✨👀✨ ReentrancyGuard.sol
contracts inside security/
seek to deal with common security practices:
ReentrancyGuard
is a modifier that can prevent reentrancy for single contract functions.
Pausable
is an emergency response mechanism that can pause a functionality if remediation is needed.
although Pausable
is an interesting contract, today we will discuss the reentrancy module.
contracts may call other contracts by function calls or transferring ether. they can also call back contracts that called them (i.e., reentering) or any other contract in the call stack.
a reentrancy attack can happen when a contract is reentered in an invalid state. this can happen if the contract calls other untrusted contracts or transfers funds to untrusted accounts.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
a state in the blockchain is considered valid when the contract-specific invariants hold true.
contract invariants are properties of the program state that are expected always to be true. for instance, the value of
owner
state variable, the total token supply, etc., should always remain the same.to learn more, @andrzej has a great post on contract, preconditions, & invariants.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
in its simplest version, an attacking contract exploits vulnerable code in another contract to seize the flow of operation or funds.
for example, an attacker could repeatedly call a withdraw()
or receive()
function (or similar balance updating function) before a vulnerable contract’s balance is updated.
here is a step-by-step scenario:
a vulnerable contract has X ether
.
an attacker stores 1 ether
with a deposit()
function.
an attacker calls the withdraw()
function and points the recipient address to their malicious contract.
the withdraw()
function verifies that the attacker has 1 ether
(yes, from their deposit) and transfers 1 ether
to the malicious contract.
the fallback()
function is triggered when the ether
is received and, before balances
is updated, calls withdraw()
repeatedly until the vulnerable contract is drained (X + 1 ether
is sent to the attacker).
reentrancy attacks can happen on a single function, multiple functions, or even extend across distinct smart contracts:
single reentrancy attacks can occur when a vulnerable function is the same function the attack is trying to call recursively.
cross-function attacks can occur when two or more functions (including a vulnerable one) share the same state variables. they are harder to detect and prevent.
cross-contract attacks can occur when contracts share the same state (e.g., if multiple contracts share the same variable and a contract updates it insecurely). an example could be the exploitation of an ERC-777 token, which enables “operators” to send tokens on behalf of a token owner by adding send()
and receive()
hooks (this was exploited on the uniswap/lendf.me attack).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
on june 17th, 2016, ”The DAO” was compromised, and 3.6 million ETH were stolen using a single-function reentrancy attack. the ethereum foundation had to issue a critical update to reverse the hack, leading to its fork.
recent reentrancy hacks were plx locker (2021), cream finance (2021), rari capital (2021), siren protocol (2021), paraluni (2022), revest finance (2022), fei protocol (2022), ola finance (2022), and safemint(2022).
i also highly recommend @pcaversaccio historical collection of reentrancy attacks.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if it still feels a bit abstract, don’t worry. let’s try again with code.
consider the following Vulnerable
contract with a public
withdraw()
function, where an external call()
aims to transfer (withdraw) ETH to msg.sender
:
the contract invariant is that its total amount of funds should equal the sum of all entries in balances
.
however, because the user’s balance is updated to 0
ONLY after call()
, the invariant is broken because amount
has been sent, but balances
has not been updated yet.
if msg.sender
is another smart contract, it can, as we saw above, recall withdraw()
function through fallback()
or receive()
(they would be triggered when ether
is sent to the attacker’s contract).
since balances
is not updated until after the call, the reentering invocation of withdraw()
can be repeated and drain the contract.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
solidity’s
fallback()
function is executed if none of the other functions match the function identifier or no data was provided with the function call. it can be optionallypayable
.
receive()
is a new keyword in solidity 0.6.x, and it is used as afallback()
function for emptycalldata
(or any value) that is only able to receiveether
.to learn more about
fallback()
exploitation, check this ethernaut solution.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
in this exploit, the only checks the attacker needs are:
the accumulated gas cost, and
the overall balance of the target smart contract (to ensure they are not attempting to withdraw more ether
than the contract holds, as it would revert the entire transaction and change the state).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
but, what is gas, really?
gas is a unit of computation. each transaction is charged with some gas that has to be paid for by the originator.
gas spent is the total amount of gas used in a transaction. if the gas is used up at any point, an out-of-gas exception is triggered, ending execution and reverting all modifications made to the state in the current call frame. since each block has a maximum amount of gas, it also limits the work needed to validate a block.
gas price is how much ether you are willing to pay for gas. it's set by the originator of the transaction, who has to pay
gas_price * gas
upfront to the EVM executor (and any gas left is refunded, except for exceptions that revert changes).~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
so what’s the solution for reentrancy?
initially, single-function reentrancy vulnerabilities could be fixed by deferring the external call until after balances
has been updated:
however, this solution also has issues. if another function calls withdraw()
, the attack could still be possible through cross-function reentrancy (i.e., when multiple functions share the same state).
for instance, an attacker could reenter the contract with a transfer()
function while withdraw()
call has not yet concluded (and balances
for msg.sender
has not been set to 0
yet):
a remediation is to defer ANY external call until after all relevant state changes have occurred.
✨ enters openzeppelin’s ReentrancyGuard
✨
the contract allows a universal solution to reentrancy protection for individual contracts through a mutex technique (i.e., working with lock states that only owner
can change):
by using the modifier nonReentrant
, other contracts could safely consume NotVulnerable
, using public
balances
for its logic while withdraw()
safeguarded against reentrancy.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
to learn from a reentrancy practical problem, check my ethernaut foundry solution for this vulnerability. here is a teaser of the exploit i wrote:
contract ReentrancyExploit { Reentrance private level; bool private _ENTERED; address private owner; uint256 private initialDeposit; constructor(Reentrance _level) { owner = msg.sender; level = _level; _ENTERED = false; } function run() public payable { require(msg.value > 0, "must send some ether"); initialDeposit = msg.value; level.donate{value: msg.value}(address(this)); level.withdraw(initialDeposit); level.withdraw(address(level).balance); } function withdrawtoHacker() public returns (bool) { uint256 hackerBalancer = address(this).balance; (bool success, ) = owner.call{value: hackerBalancer}(""); return success; } receive() external payable { if (!_ENTERED) { _ENTERED = true; level.withdraw(initialDeposit); } } }
i also highly recommend you check openzeppelin’s post on the reentrancy guard after istanbul hard fork and eip-1884.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
now let’s take a deeper look at ReentrancyGuard
. the abstract
contract ReentrancyGuard
starts with:
three private
uint256
state variables. the first two, _NOT_ENTERED
and _ENTERED
, serve as (a cheap) bool
.
a general error
, and
a constructor
that starts with _status
equal to false
(meaning, “not entered yet” or false
) when the contract is deployed.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ReentrancyGuard
contains a docstring explaining why_NOT_ENTERED = 1
and_ENTERED = 2
:“
bool
are more expensive thanuint256
or any type that takes up a full word because each write operation emits an extraSLOAD
to first read the slot's contents, replace the bits taken up by thebool
, and then write back. this is the compiler's defense against contract upgrades and pointer aliasing, and it cannot be disabled.the values being non-zero value makes deployment a bit more expensive, but in exchange the refund on every call to
nonReentrant
will be lower in amount. since refunds are capped to a percentage of the total transaction's gas, it is best to keep them low in cases like this one, to increase the likelihood of the full refund coming into effect.”edit: PaulRBerg was curious about this too.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
following is the definition of the modifier nonReentrant
, which we learned above that can be utilized on inherited contracts to prevent a function from being called while this function is still executing (i.e., ensure that there are no nested/reentrant calls to them).
this modifier leverages two private
functions, _nonReentrantBefore()
and _nonReentrantAfter()
, to ensure that any call in between (the contract execution) reverts with ReentrancyGuardReentrantCall()
. in other words, any call to nonReentrant
after its first call will fail as there is already a nonReentrant
function in the call stack.
note that once _nonReentrantAfter()
is called and _status
is restored to _NOT_ENTERED
, a refund can be triggered accordingly to EIP-2200. the EIP proposed a way for gas metering on SSTORE
, so that subsequent storage write operations within the same call frame (such as reentry locks) can benefit from gas reduction.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
i talked about
MSTORE
already, so it’s worth talking about the four ways the EVM stores data, depending on their context.firstly, there is the key-value stack, where you can
POP
,PUSH
,DUP1
, orPOP
data. basically, the EVM is a stack machine, as it does not operate on registers but on a virtual stack with a size limit1024
. stack items (both keys and values) have a size of32-bytes
(or256-bit
), so the EVM is a 256-bit word machine (facilitating, for instance, keccak256 hash scheme and elliptic-curve computations).secondly, there is the byte-array memory (RAM), used to store data during execution (such as passing arguments to internal functions). opcodes are
MSTORE
(like inArray.sol
),MLOAD
, orMSTORE8
.third, there is the calldata (which can be accessed through
msg.data
), a read-only byte-addressable space for the data parameter of a transaction or call. unlike the stack, this data is accessed by specifying the exact byte offset and the number of bytes to read. inline assembly functions to operate calldata are (they will be important in the next section):
calldatacopy(t, f, s)
, used for opcodeCALLDATACOPY
, which copies a number of bytes of the transaction to memory (copiess
bytes of calldata from positionf
to memory at positiont
).
calldatasize()
, tells the size of transaction data.
calldataload()
, loads32 bytes
of the transaction data onto the stack.additionally,
returndatacopy(t, f, s)
is used for opcodeRETURNDATACOPY
to copys
bytes fromreturndata
at positionf
to memory at positiont
.lastly, there is disk storage, a persistent read-write word-addressable space, where each contract stores persistent information (and where state variables live), and is represented by a mapping of
2^{256}
slots of32 bytes
each. the opcodeSSTORE
is used to store data andSLOAD
to load.the required gas for disk storage is the most expensive, while storing data to stack is the cheapest.
check evm.storage for a dive deep into the storage of any contract on ethereum or avalanche and ethervm.io for a broad opcodes reference.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
what about deadlocks? what happens if a nonReentrant
function calls another nonReentrant
function?
because there is only a single nonReentrant
guard, a nonReentrant
function should always be external. the contract disclaimers that “calling a nonReentrant
function from another nonReentrant
function is not supported. it is possible to prevent this from happening by making the nonReentrant
function external, and calling a private
function that does the actual work.”
finally, the last part of the contract is the internal
view
function that returns true
if the reentrancy guard is on:
note that ReentrancyGuard
is still vulnerable to cross-contracts attacks, where the execution flow of the external call()
in withdraw()
could still be taken over by an attacker (e.g., if they craft an exploit that presents a misleading balances
).
check this review for a more detailed cross-contract reentrancy example using Checkpoints
to monitor balances
, for instance, visible to AnotherContract
we discussed above.
to conclude this session, let’s go over other workarounds for reentrancy attack protection:
the checks-effects-interaction pattern:
a technique to organize statements in a function so that the state remains valid before calling other contracts.
basically, every statement is classified as either check (contract’s state change), effect (blockchain state change and writing to storage), or interaction, and must be in this exact order.
however, this technique is prone to human error and would not work for multi-contract situations.
using intermediary escrows (pull payments):
instead of sending funds to a receiver, they could be “pulled” out of an escrow contract.
openzeppelin implements this pattern in the PullPayment
contract.
setting gas limits by utilizing transfer()
or send()
instead of call()
:
the fallback()
function in any contract is triggered by transfer()
or send()
.
these functions only allow the receiver a stipend of 2300
gas sent that can be utilized in the execution of the contract, which is not enough to perform reentrancy attacks.
however, this argument has been debated by many advocates of stopping using transfer()
, as gas/opcode pricing is not stable (this was touched on in the EIP-1884 implementation).
|----- proxy/
|
|----- ERC1967/
|----- beacon/
|----- transparent/
|----- utils/
|----- Clones.sol
L----- ✨👀✨Proxy.sol
proxy/
contracts provide a low-level implementation suite for different proxy patterns with and without upgradeability.
in another post, i will go over more details about different types of upgradeable proxies. today, we will take a quick look at Proxy
.
the main gist of the abstract
contract Proxy
is to provide a fallback()
function that delegates all calls to another contract using the EVM opcode DELEGATECALL
.
Proxy
does not contain any state variable and starts directly with the internal
virtual
function _delegate()
, which runs inline assembly to delegate the current call to a second contract (given by implementation
):
let’s see what the low-level code is doing:
first, it takes full-control of memory to copy msg.data
, writing at position 0
.
then call implementation
(the second contract address) with delegatecall()
and copy the returned data.
finally, revert if result
is 0
(meaning an error) or return the data.
next, the fallback()
function delegates the current call to the address returned by the internal
virtual
_implementation()
function (an overridden function returning the address fallback()
should delegate).
the fallback()
function will return directly to the external caller, i.e., success
and return data of the delegated call is returned to the caller of the proxy.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
i mentioned the opcode
DELEGATECALL
before when talking aboutview
modifiers forlibrary
.
delegatecall()
is the low-level function for this opcode, similar tocall()
.basically, when a contract
A
executesdelegatecall()
to contractB
,B
’s code is executed with contractA
’ s storage,msg.sender
, andmsg.value
.to learn more about how attackers can exploit
DELEGATECALL
, check my writeup for ethernaut’s delegation.~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~