on some of my favorite openzeppelin smart contracts

tl; dr

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-toolkit-sol.


👾 today’s outline

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

🎶 today’s mood


000. an open and secure zeppelin

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 ].


001. utils/

|----- 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.


utils/Context.sol

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 with this).

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 keyword override must be used in the overriding modifier).

view functions declare that no state will be changed. when a view function is called, the STATICALL opcode is used (enforcing the state to stay unmodified). for library view functions, DELEGATECALL is used (there are no runtime checks that prevent state modifications). a view function cannot modify the state of the contract, for instance by writing to state variables, creating other contracts, emitting events, sending ether with call() or using any low-level calls, using selfdestruct(), calling functions that pure or view, 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 a pure function at compile-time given only its inputs and msg.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.


utils/Array.sol

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”:

  1. while left < right, with left = mid + 1 and right = mid - 1.

  2. while left < right, with left = mid + 1 and right = mid, and left is returned.

  3. 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, and address 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 bytes1bytes2,…, bytes32 hold sequences of bytes from one to up to 32. the type bytes32[] is an array of bytes (the same way uint256[] is an array of uint256, etc.).

by the way, due to padding rules, bytes* with anything less than 32 is a waste (and should be replaced by bytes, 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 opcode MSTORE 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:


010. access/

|----- 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.


access/Ownable.sol

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 calling requirerevertor assert.

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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.


access/Ownable2Step.sol:

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:


011. security/

|----- 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.


security/ReentrancyGuard.sol

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:

  1. a vulnerable contract has X ether.

  2. an attacker stores 1 ether with a deposit() function.

  3. an attacker calls the withdraw() function and points the recipient address to their malicious contract.

  4. the withdraw() function verifies that the attacker has 1 ether (yes, from their deposit) and transfers 1 ether to the malicious contract.

  5. 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 optionally payable.

receive() is a new keyword in solidity 0.6.x, and it is used as a fallback() function for empty calldata (or any value) that is only able to receive ether.

to learn more about fallback() exploitation, check this ethernaut solution.

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

in this exploit, the only checks the attacker needs are:

  1. the accumulated gas cost, and

  2. 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 than uint256 or any type that takes up a full word because each write operation emits an extra SLOAD to first read the slot's contents, replace the bits taken up by the bool, 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, or POP data. basically, the EVM is a stack machine, as it does not operate on registers but on a virtual stack with a size limit 1024. stack items (both keys and values) have a size of 32-bytes (or 256-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 in Array.sol), MLOAD, or MSTORE8.

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 opcode CALLDATACOPY, which copies a number of bytes of the transaction to memory (copies s bytes of calldata from position f to memory at position t).

  • calldatasize(), tells the size of transaction data.

  • calldataload(), loads 32 bytes of the transaction data onto the stack.

  • additionally, returndatacopy(t, f, s) is used for opcode RETURNDATACOPY to copy s bytes from returndata at position f to memory at position t.

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 of 32 bytes each. the opcode SSTORE is used to store data and SLOAD 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():


100. proxy/

|----- 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.


proxy/Proxy.sol

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:

  1. first, it takes full-control of memory to copy msg.data , writing at position 0.

  2. then call implementation (the second contract address) with delegatecall() and copy the returned data.

  3. 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 about view modifiers for library.

delegatecall() is the low-level function for this opcode, similar to call().

basically, when a contract A executes delegatecall() to contract B, Bs code is executed with contract A’ s storage, msg.sender, and msg.value.

to learn more about how attackers can exploit DELEGATECALL, check my writeup for ethernaut’s delegation.

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


◻️♄

Subscribe to bt3gl's autistic symposium
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.