Statement regarding Geth v1.10.8 split

Dexaran
4 min readSep 2, 2021

--

On August 27, 2021 a chain split occurred on Ethereum mainnet (watch the announcement). The accident was followed by an article at Cointelegraph describing that the issue was caused by the EthereumVM bug and other EVM-compatible chains may be at risk.

The statement of Callisto Network

The described accident was not a result of the EVM bug but a result of miscoordination in the client update. Ethereum developers introduced breaking changes in their Geth v1.10.8 release that affected consensus rules and these changes must have been implemented during the hardfork instead.

In fact, when you change consensus rules it is always possible that there will be “a fork” — those nodes who upgraded their code will stay in new consensus with each other and the ones that refused to update will fork off the chain and stay in consensus with other non-updated nodes (the best example is Ethereum — EthereumCLassic fork).

This is exactly what happened at Ethereum chain on August 27.

The attack

Here you can find a transaction that triggered the chain split on Ethereum mainnet: https://etherscan.io/tx/0x1cb6fb36633d270edefc04d048145b4298e67b8aa82a9e5ec4aa1435dd770ce4

As you can see the transaction created the contract.

The “exploit”: some nodes (Geth v1.10.8) thought that the code of a newly created contract must be 0x000000000000000000000000000000000000008e8bbbc599bf9ecb4756bb466edeba12fae3a49e00000000000000000000000000000000000000000000000000

while other nodes (older ones) thought that the code must be 0x0000000000000000000000008eae784e072e961f76948a785b62c9a950fb17ae62c9a950fb17ae00000000000000000000000000000000000000000000000000

They came to disagreement and splitted from one another.

Investigating the EVM exploit

Let’s analyze the “attack” to understand why this happened.

First let’s disassemble the input data of the transaction: 0x3034526020600760203460045afa602034343e604034f3

Here is the result:

  • [0] ADDRESS
  • [1] CALLVALUE
  • [2] MSTORE
  • [4] PUSH1 0x20
  • [6] PUSH1 0x07
  • [8] PUSH1 0x20
  • [9] CALLVALUE
  • [11] PUSH1 0x04
  • [12] GAS
  • [13] STATICCALL
  • [15] PUSH1 0x20
  • [16] CALLVALUE
  • [17] CALLVALUE
  • [18] RETURNDATACOPY
  • [20] PUSH1 0x40
  • [21] CALLVALUE
  • [22] RETURN

We can now read what the contract is actually doing:

  1. The contract is going to store the address of a newly created contract in the memory. For this matter it calls [0] ADDRESS [1] CALLVALUE and then [2] MSTORE opcodes where the first two are the inputs for the MSTORE. So the contract takes the address of a newly created contract and pushes it onto the stack. Then it pushes value 0 onto the stack (because no value is provided for CALLVALUE opcode. Then it takes the contract address from the stack and pushes it into the contract memory (the stack is cleared).
  2. Next the contract is performing the [13] STATICCALL and here are the inputs: [4] PUSH1 0x20 [6] PUSH1 0x07 [8] PUSH1 0x20 [9] CALLVALUE [11] PUSH1 0x04 [12] GAS. You can find the description of what STATICCALL is actually doing here.
    In this case STATICCALL should invoke the contract at address 0x04, it should provide all remaining GAS for this invocation, it should provide first 32 bytes of memory as input data (memory was filled at pt.1), it should take the result of the invocation and put 32 bytes into memory starting at byte 7.
    It is important to note here that the 0x04 address is a special target for this call — it’s a precompiled contract called “Identity Precompile”. It takes the data you provide it and the returns it back (can be used for some optimization reasons). You can read more about precompiled contracts here.
  3. Let’s dive into what is going on inside of the STATICCALL in Geth. Here the args variable is a pointer to the contracts memory. It is passed to the Identity Precompile and the returned value is the same pointer to the contracts memory. The important thing here is that the implementation of CALL, CALLCODE, STATICCALL and DELEGATECALL in Geth was changed on August 24, 2021 with this commit. If we take a look at the previous implementation then we can find out that it was a bit different (watch previous version of the instructions.go ). Now “old nodes” think that the value of ret variable is a pointer to the contracts memory but the “new nodes” think it is the value.
  4. At this line “old nodes” shifted the value of ret variable when trying to store this in memory (ret is a pointer to that exact memory) by 7 bytes.
  5. [15] PUSH1 0x20 [16] CALLVALUE [17] CALLVALUE [18] RETURNDATACOPY these opcodes copy 32 bytes of ret into the memory (at this point “old nodes” and “new nodes” have it different already).
  6. [20] PUSH1 0x40 [21] CALLVALUE [22] RETURN these opcodes finish the execution returning 64 bytes of the memory i.e. make the returned 64 bytes of memory a new contract’s bytecode. At this point “old nodes” and “new nodes” disagree with each other how the code of the newly created contract must look like and therefore they can’t accept each others version of a block that includes the transaction of contract creation. This is the chain split.

It should be noted that this would not happen if nodes could agree on how the newly created contract’s code must look like. It doesn’t matter which version (shifted or unshifted) it would be — the point is that it has to be the same for all nodes.

Conclusion

Geth v1.10.8 release contains a bugfix for the EVM exploit. This bug could not cause a chain split on its own however. What caused the chain split was a rushed fix that changed consensus rules. Ethereum developers should have called it a HardFork, but not released as a regular client update.

There was a consensus at the chain how the instructions of the EVM must work. If you apply a change on consensus rules then you can’t pretend that it always worked like that. Instead there must be a specified block at which the consensus upgrade is considered applied and all nodes must follow it starting from this particular block number.

--

--