ANALYSIS AND RECREATION OF THE 20 MILLION $OP HACK ON OPTIMISM CHAIN
Recently on May 26th, 20 million $OP (value at 14 million USD at the time of writing this article) was hacked. The incident happened before the $OP token was released to the users 5 days later. This article provides information about the incident and tools to simulate the hacking process in detail.
About Optimism
Optimism is a Layer 2 on Ethereum that provides fast transaction speed and decent security based on the Optimistic Rollup mechanism. As one of the top Layer 2, Optimism has a peak TVL of more than 1 billion USD.
Source: l2beats.com
Using the EVM virtual machine, Optimism provides an environment for developing dapps that is similar to the Ethereum mainnet.
Theoretical basis
To better understand the hack, we need to understand the process of deploying a smart contract by another smart contract.
When contract A is deployed by contract B, the address of contract A is calculated based on the following parameters:
address_A = keccak256(address_B, nonce_B)
According to the above formula, address_A depends only on 2 parameters address_B and nonce_B, where nonce_B is the number of contracts deployed by B plus 1.
In other words, we can predetermine the address of any contract A deployed by contract B based on B's address and contract A's sequence number.
For example, here we have 2 contracts as follows:
pragma solidity ^0.5.3;
contract Instance {}
contract Factory {
mapping(uint256 => address) public nonceToAddress;
uint256 public nonce;
constructor() public {
nonce = 1;
}
function createInstance() public {
Instance newInstance = new Instance();
nonceToAddress[nonce] = address(newInstance);
nonce++;
}
}
where contract Instance will be deployed by contract Factory.
We can calculate in advance the address of the deployed Instance through each call to the Factory's createInstance()
function:
// nonce = 1
await factory.connect(deployer).createInstance();
var nonce = 0x01; //The nonce must be a hex literal!
var input_arr = [factory.address, nonce];
var rlp_encoded = rlp.encode(input_arr);
var contract_address_long = keccak('keccak256').update(Buffer.from(rlp_encoded)).digest('hex');
var contract_address = '0x' + contract_address_long.substring(24); //Trim the first 24 characters.
console.log('Instance contract_address with nonce 1: ' + contract_address);
expect((await factory.nonceToAddress(1)).toLowerCase()).be.equal(contract_address);
// The deployed instance address must be equal to the one we calculated
Run yarn test_create:
link
Result:
The hacking process
The incident happened due to the carelessness of Wintermute - a market maker partner with Optimism. Wintermute borrows Optimism 20 million OP to create liquidity for this token.
The hack includes the participation of 5 main addresses, which we call shortly as follows:
A(L1) and A(L2): Adress 0x4f3a120e72c76c22ae802d129f599bfdbc31cb81 on Ethereum and Optimism Chain.
B(L1) and B(L2): ProxyFactory contract 0x76e2cfc1f5fa8f6a5b3fc4c8f4788f0116861f9b on Ethereum and Optimism Chain.
X(L2): Contract which contains the attack logic of the hacker
To receive the tokens, Wintermute sends address A to the Optimism Foundation.
However, Wintermute only owns this address on Ethereum Chain (L1) and has not yet deployed it to Optimism Chain (L2). They think that if they already own this address on L1, they can easily own it on L2, because both chains use Ethereum virtual machines.
May-26-2022 11:55:44 PM +UTC:
Optimism Foundation sends 1 test $OP to address A(L2)
May-27-2022 04:05:27 PM +UTC:
Optimism Foundation continues to send 1,000,000 OP to address A(L2)
It's possible that address A(L2) is now in the hacker's sights
May-27-2022 04:59:21 PM +UTC:
Optimism Foundation sends the rest 19,000,000 OP to address A(L2)
=> an amount of 20M USD is in an empty_address, no logic has been deployed on that address yet.
Hacker investigates address A(L1) on Ethereum chain
On Ethereum Chain, A(L1) is actually a contract:
The hacker also investigated further, this contract is deployed by contract B(L1) at this transaction
At address B(L2) on Optimism Chain is also an identical ProxyFactory contract as on Ethereum Chain
Combined with the theory presented above, the hacker can deduce the contract pair created by B on Ethereum and Optimism if given the same nonce will give the same address.
Next, the hacker finds the nonce that generated the address A(L1)
By changing the nonce value until the resulting address matches A, the hacker finds a nonce of 8884.
const rlp = require('rlp'); const keccak = require('keccak'); const findNonce = () => { var factoryAddress = '0x76e2cfc1f5fa8f6a5b3fc4c8f4788f0116861f9b'; var target = '0x4f3a120e72c76c22ae802d129f599bfdbc31cb81'; var nonce = 1; while (true) { var input_arr = [factoryAddress, parseInt(nonce.toString(16), 16)]; var rlp_encoded = rlp.encode(input_arr); var contract_address_long = keccak('keccak256').update(Buffer.from(rlp_encoded)).digest('hex'); var contract_address = '0x' + contract_address_long.substring(24); if (contract_address == target) { console.log(nonce); console.log(contract_address); return; } nonce++; } }; findNonce();
Result:
The hacker investigates the nonce of address B(L2) on Optimism to see if it passed 8884.
Fortunately for the hacker, the nonce at that time was only about 10.
In the next step, the hacker prepares two logics:
replaying the deployment calls repeatedly to B(L2)'s createProxy() function to increase the nonce to 8884
logic to withdraw OP Token after successfully deploying the contract to address A(L2)
The hacker combined those two logics in contract X(L2)
Hacker attacks and withdraws:
Replaying the deployment transaction to take control of address A(L2)
The hacker initiates the attack at address X(L2). He repeatedly calls B(L2)'s createProxy() function.
Now 20M $OP in contract A(L2) completely belongs to the hacker, he withdrew about 2M $OP in 2 transactions.
Recreate on local environment
Using this repository to recreate the hack.
To save time running the test, we will assume the targetAddress
corresponds to nonce = 15 instead of 8884.
3 modules included:
In which ProxyFactory and OPTokenMock have already existed, the hacker only needs to code the Attack contract.
Analyzing the code in the Attack contract, because Hacker wants to combine both replayDeploy and withdrawToken logic in the same contract, the Attack contract will be a bit confusing and have some repetitive things:
In the
constructor()
function, the factory, target, and token parameters are needed for the replayDeploy executionIn the
replayDeploy()
function, thecreateProxy
loop will be executed until thetargetAddress
is foundIn the
initialize()
function, it is necessary to initialize the token and initialize the owner value, because the Attack contract will contain the logic of the Proxy contract that has just been deployed to thetargetAddress
.
Run yarn exploit
function: link
The returned result will be as follow: