On February 2, whitehat Jay Freeman (Saurik), known for developing Cydia, reported a critical vulnerability in the Optimism protocol, a Layer 2 (L2) scaling solution for Ethereum. The bug itself would have allowed an attacker to replicate money continuously on any chain using a vulnerability found in OVM 2.0.
For this disclosure, the project paid out the full critical amount listed on Immunefi’s bug bounty page for Optimism: $2,000,042!
OVM is equivalent to the Ethereum Virtual Machine (EVM) specification. This means that Optimism is close to identical with Ethereum and shares the same account and state structure. In other words, all the operations work the same on Optimism as on Ethereum, with some small exceptions.
The bug was found in one of the implementations of a portion of the EVM’s execution logic, i.e., an opcode, which would have allowed a potential attacker to print an unlimited amount of OETH. That OETH could then be used to drain DEXes and bridge assets from Optimism to Ethereum, allowing a hacker to steal large amounts of money. At the time of submission, Optimism’s TVL of $322.5M was potentially at risk.
The financial impact was clearly critical–hence the full payout from Optimism. The team released a fix quickly after receiving the report and responsibly disclosed the bug downstream to all the forks of Optimism, including Metis on Immunefi.
The bug was quite complex to understand and hard to find, so the likelihood of exploitation was low. But if anybody had found that bug before Saurik had repsonsibly disclosed it, Saurik, the impact would be enormous.
That’s why having a security-oriented mindset for a project and protocol is important. Even audited code can have bugs. Bug bounty programs are the last line of defense against blackhat activity by incentivizing them with large bug bounties to responsibly disclose bugs rather than exploiting them.
To understand the bug better and know why it was exploitable, we first need to explain what an L2 is and what OVM 2.0 is.
Intro to L2
The current Ethereum version has low transaction throughput and high latency in processing. This means that transactions are both slow and prohibitively expensive, due to high demand, relative to what the network can take at any given time.
There are two general types of scaling solutions proposed for the above issues:
On-chain scaling refers to any direct modification made to a blockchain, like data sharding and execution sharding in the incoming version of Ethereum 2.0.
Off-chain scaling refers to any innovation outside of a blockchain, i.e. execution of transaction bytecode happens externally instead of on Ethereum. These solutions are called L2, because Layer 2 works above Layer 1 (L1) (Ethereum) to optimize and speed up processing. Optimism is a well-known example of an L2 scaling solution.
Instead of executing and storing all the data on Ethereum, where transactions are only processed at a premium, we decide to only store a summary of the L2 state on Ethereum. This means that all of the actual computation and storage of contracts and data is done on L2. In this way, rollups can inherit Ethereum’s security guarantees, while still acting as an efficient scaling solution.
Optimistic rollups batch together off-chain transactions into batches (rolls them), but they do not contain additional proof guaranteeing their validity. These rollups “optimistically” assume all transactions are valid. When assertions of the L2 state are posted on-chain, validators of the rollup can challenge the assertion when they think someone has posted an incorrect or malicious state. This is called “fraud detection”. The security of this system relies on the validator posting a bond along with their state that is forfeited to the challenger if the challenger can prove that the state is incorrect.
Optimism is a scaling solution based on the concept of optimistic rollups, which means that it stores a summary of all L2 transactions on Ethereum. Optimism depends on ‘fault proofs’, which is a way of detecting whether a transaction being verified is incorrect. Optimism handles fault-proofs using OVM 2.0.
Before we dive into OVM 2.0, it’s important to understand the distinction between executing and proving.
Executing means advancing the state of a blockchain by running transactions inside the virtual machine. The EVM has a stack-based architecture. We can control the stack by using the opcodes, like
ADD, SSTORE, MLOAD, etc. When smart contracts receive a message, their EVM bytecode is run, which allows them to update their state or even send further messages to other contracts.
‘Proving’ is simply the act of convincing L1 contracts that the state produced by executing some transactions is correct. Some systems based on EVM L1 rely on re-executing the disputed code segment on L1 and comparing the results.
Optimism’s approach to this is different from other L2 scaling solutions. Here, executing and proving are done together.
If there were a dispute between Alice and Bob, the transaction in question would be re-executed (replayed) on the L1 chain. But this introduces some potential issues, as we cannot rely on certain opcodes to return the same value on the L1 chain and the L2 chain. Certain ones like
BLOCKNUMBER, for example, won’t produce the same value because they rely on blockchain metadata or information from the time of proving (instead of the time of execution).
The solution is introducing a mechanism that would help retain the context of the disputed transaction on L2 when verifying it on L1. Optimism Virtual Machine (OVM 2.0) replaces all context-dependent opcodes with OVM counterparts, like
As we know, Optimism created OVM 2.0 to have EVM equivalence with Ethereum. In this regard, executing a transaction on Optimism is similar to executing a transaction on Ethereum: Optimism loads the state, applies the transaction to that state, and records the state changes. The data transaction layer indexes each new block, and the process is repeated.
The OVM 1.0 decided to store ETH as ERC-20 tokens instead. This caused some issues for Optimism, as the network needed to support all things that were working on Ethereum but was broken due to this, like gas tokens. When OVM 2.0 launched, OVM 2.0 stopped support for this feature but still stores all of the balances for user accounts in the storage state of an ERC-20 contract. Optimism modified Geth, one of the original three implementations of Ethereum protocol, to apply patches to
StateDB to store the native balances in an ERC-20 token storage state.
What this means is ETH is still represented internally as an ERC-20 token at the address
0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. That contract is also called
OVM_ETH. As a result, user balances will always be zero inside the state trie (which is where Ethereum would store the balance), and the user’s actual balance will be stored in the aforementioned token’s storage.
UsingOVM flag is set with a
USING_OVM environment variable. Operations on the
StateDB that affect an account balance are then redirected from the underlying
stateObject (which represents an individual account) to the storage state in the
OVM_ETH contract when such a flag is found to be active.
If you want to learn more about how exactly Optimism works under the hood, please refer to the official documentation from Optimism. This overview also explains how block production and transactions work. It’s worth the read!
Before we go into the details of the vulnerability, we need to understand one last thing: the
This command causes a contract to self-destruct, deleting its account object. The main advantage of this command is that it permits enormous amounts of “obsolete” states to be easily removed from the active set of the blockchain.
selfdestruct(address) method, which was renamed from
suicide(address), destroys all bytecode from the calling contract address and transfers all Ether held to the target address. No functions (including the fallback) are called if the target address is also a contract.
The most important thing to note here is that the contract (object) is destroyed at the end of a transaction, meaning we can still perform operations on that contract after calling the
selfdestruct function, only if such operations are still in the same transaction.
selfdestruct has been executed, the remaining Ether stored at that address is sent to the designated target, and then the storage and code is removed from the state. All of the above is handled by the Optimism client, which is a fork of Geth. In the Optimism Geth implementation, we can find a piece of code that causes the whole vulnerability.
The issue is with setting the balance of an account after
selfdestruct to 0.
stateObject.data.Balance = new(big.Int)
The problem is that the Optimism client sets the balance to zero directly on the
stateObject instead of checking
UsingOVM and redirecting balance modification to the
Due to this bug, when a contract is
selfdestructed, it gives the balance of the calling contract to the target AND still keeps the original balance. We’re modifying a
stateObject balance and not updating native balances in the ERC20 token storage state (
An attacker could have used this bug to inflate the balance of the target contract by repeatedly
selfdestructing a contract that holds Ether. After several iterations the attacker can then “cash out” the inflated Ether balance, thus creating the money out of thin air.
Here is a step by step guide how what that would look like:
- Create an exploit contract that contains two internal functions. One with
selfdestruct, and a second that withdraws the balance of the contract to the caller. The function with
selfdestructsends the contract’s balance to itself. This inflates the value due to the bug in Optimism’s Geth implementation
- The attacker deploys the contracts and in the constructor makes calls to the
selfdestructfunction multiple times in a loop
- After the loop is finished, the constructor calls the second internal function of the exploit contract to transfer the inflated funds to the attacker
- The exploit contract is destroyed at the end of the transaction
We highly recommend reading Saurik’s blog post on how he found the bug, as he dives deep into the vulnerability history of Optimism. He also shows an example exploit of this vulnerability.
We would like to thank Saurik for doing an amazing job and responsibly disclosing such an important bug. Props also to the Optimism team who did an amazing job responding quickly to the report and patching it.
If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.
And if you’re feeling good about your skillset and want to see if you will find bugs in the code written by geohot himself, check out the new bug bounty program from Optimism.