Optimism Infinite Money Duplication Bugfix Review

Immunefi
Immunefi
Published in
8 min readMar 16, 2022

Summary

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.

Optimistic Rollups

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

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

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.

The 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!

Vulnerability Analysis

Before we go into the details of the vulnerability, we need to understand one last thing: the SELFDESTRUCT opcode.

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

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

After 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 OVM_ETH contract!

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 (OVM_ETH contract).

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:

  1. 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 selfdestruct sends the contract’s balance to itself. This inflates the value due to the bug in Optimism’s Geth implementation
  2. The attacker deploys the contracts and in the constructor makes calls to the selfdestruct function multiple times in a loop
  3. After the loop is finished, the constructor calls the second internal function of the exploit contract to transfer the inflated funds to the attacker
  4. 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.

Vulnerability Fix

Optimism fixed the bug in the following Github PR. To understand the reasoning behind this fix and why it was implemented using that paritcular method, please refer to Saurik’s blog post.

Acknowledgments

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.

--

--

Immunefi
Immunefi

Immunefi is the premier bug bounty platform for smart contracts, where hackers review code, disclose vulnerabilities, get paid, and make crypto safer.