作者:0x7F@知道創宇404區塊鏈安全研究團隊
時間:2018年7月12日

0x00 前言

區塊鏈的火熱程度一直以直線上升,其中以區塊鏈 2.0 —— 以太坊為代表,不斷的為傳統行業帶來革新,同時也推動區塊鏈技術發展。

區塊鏈是一種分布式數據存儲、點對點傳輸、共識機制、加密算法等計算機技術的新型應用模式,這是一個典型的去中心化應用,建立在 p2p 網絡之上;本文以學習和分析以太坊運作原理為目的,將以太坊網絡架構作為一個切入點,逐步深入分析,最終對以太坊網絡架構有個大致的了解。

通過學習以太坊網絡架構,可以更容易的對網絡部分的源碼進行審計,便于后續的協議分析,來發現未知的安全隱患;除此之外,目前基于 p2p 網絡的成熟的應用非常少,借助分析以太坊網絡架構的機會,可以學習一套成熟的 p2p 網絡運行架構。

本文側重于數據鏈路的建立和交互,不涉及網絡模塊中的節點發現、區塊同步、廣播等功能模塊。

0x01 目錄

  1. Geth 啟動
  2. 網絡架構
  3. 共享密鑰
  4. RLPXFrameRW 幀
  5. RLP 編碼
  6. LES 協議
  7. 總結

其中第 3、4、5 三個小節是第 2 節「網絡架構」的子內容,作為詳細的補充。

0x02 Geth 啟動

在介紹以太坊網絡架構之前,首先簡單分析下 Geth 的整體啟動流程,便于后續的理解和分析。

以太坊源碼目錄

tree -d -L 1
.
├── accounts                賬號相關
├── bmt                     實現二叉merkle樹
├── build                   編譯生成的程序
├── cmd                     geth程序主體
├── common                  工具函數庫
├── consensus               共識算法
├── console                 交互式命令
├── containers              docker 支持相關
├── contracts               合約相關
├── core                    以太坊核心部分
├── crypto                  加密函數庫
├── dashboard               統計
├── eth                     以太坊協議
├── ethclient               以太坊RPC客戶端
├── ethdb                   底層存儲
├── ethstats                統計報告
├── event                   事件處理
├── internal                RPC調用
├── les                     輕量級子協議 
├── light                   輕客戶端部分功能
├── log                     日志模塊
├── metrics                 服務監控相關
├── miner                   挖礦相關
├── mobile                  geth的移動端API
├── node                    接口節點
├── p2p                     p2p網絡協議
├── params                  一些預設參數值
├── rlp                     RLP系列化格式 
├── rpc                     RPC接口
├── signer                  簽名相關
├── swarm                   分布式存儲
├── tests                   以太坊JSON測試
├── trie                    Merkle Patricia實現
├── vendor                  一些擴展庫
└── whisper                 分布式消息

35 directories

初始化工作

Geth 的 main() 函數非常的簡潔,通過 app.Run() 來啟動程序:

[./cmd/geth/main.go]
func main() {
    if err := app.Run(os.Args); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

其簡潔是得力于 Geth 使用了 gopkg.in/urfave/cli.v1 擴展包,該擴展包用于管理程序的啟動,以及命令行解析,其中 app 是該擴展包的一個實例。

在 Go 語言中,在有 init() 函數的情況下,會默認先調用 init() 函數,然后再調用 main() 函數;Geth 幾乎在 ./cmd/geth/main.go#init() 中完成了所有的初始化操作:設置程序的子命令集,設置程序入口函數等,下面看下 init() 函數片段:

[./cmd/geth/main.go]
func init() {
    // Initialize the CLI app and start Geth
    app.Action = geth
    app.HideVersion = true // we have a command to print the version 
    app.Copyright = "Copyright 2013-2018 The go-ethereum Authors"
    app.Commands = []cli.Command{
        // See chaincmd.go:
        initCommand,
        importCommand,
        exportCommand,
        importPreimagesCommand,
        ...
    }
    ...
}

在以上代碼中,預設了 app 實例的值,其中 app.Action = geth 作為 app.Run() 調用的默認函數,而 app.Commands 保存了子命令實例,通過匹配命令行參數可以調用不同的函數(而不調用 app.Action),使用 Geth 不同的功能,如:開啟帶控制臺的 Geth、使用 Geth 創造創世塊等。

節點啟動流程

無論是通過 geth() 函數還是其他的命令行參數啟動節點,節點的啟動流程大致都是相同的,這里以 geth() 為例:

[./cmd/geth/main.go]
func geth(ctx *cli.Context) error {
    node := makeFullNode(ctx)
    startNode(ctx, node)
    node.Wait()
    return nil
}

其中 makeFullNode() 函數將返回一個節點實例,然后通過 startNode() 啟動。在 Geth 中,每一個功能模塊都被視為一個服務,每一個服務的正常運行驅動著 Geth 的各項功能;makeFullNode() 通過解析命令行參數,注冊指定的服務。以下是 makeFullNode() 代碼片段:

[./cmd/geth/config.go]
func makeFullNode(ctx *cli.Context) *node.Node {
    stack, cfg := makeConfigNode(ctx)

    utils.RegisterEthService(stack, &cfg.Eth)

    if ctx.GlobalBool(utils.DashboardEnabledFlag.Name) {
        utils.RegisterDashboardService(stack, &cfg.Dashboard, gitCommit)
    }

    ...

    // Add the Ethereum Stats daemon if requested.
    if cfg.Ethstats.URL != "" {
        utils.RegisterEthStatsService(stack, cfg.Ethstats.URL)
    }
    return stack
}

然后通過 startNode() 啟動各項服務并運行節點。以下是 Geth 啟動流程圖:

每個服務正常運行,相互協作,構成了 Geth:

0x03 網絡架構

通過 main() 函數的調用,最終啟動了 p2p 網絡,這一小節對網絡架構做詳細的分析。

三層架構
以太坊是去中心化的數字貨幣系統,天然適用 p2p 通信架構,并且在其上還支持了多種協議。在以太坊中,p2p 作為通信鏈路,用于負載上層協議的傳輸,可以將其分為三層結構:

  1. 最上層是以太坊中各個協議的具體實現,如 eth 協議、les 協議。
  2. 第二層是以太坊中的 p2p 通信鏈路層,主要負責啟動監聽、處理新加入連接或維護連接,為上層協議提供了信道。
  3. 最下面的一層,是由 Go 語言所提供的網絡 IO 層,也就是對 TCP/IP 中的網絡層及以下的封裝。

p2p 通信鏈路層
從最下層開始逐步分析,第三層是由 Go 語言所封裝的網絡 IO 層,這里就跳過了,直接分析 p2p 通信鏈路層。p2p 通信鏈路層主要做了三項工作:

  1. 由上層協議的數據交付給 p2p 層后,首先通過 RLP 編碼。
  2. RLP 編碼后的數據將由共享密鑰進行加密,保證通信過程中數據的安全。
  3. 最后,將數據流轉換為 RLPXFrameRW 幀,便于數據的加密傳輸和解析。
    (以上三點由下文做分析)

p2p 源碼分析
p2p 同樣作為 Geth 中的一項服務,通過「0x03 Geth 啟動」中 startNode() 啟動,p2p 通過其 Start() 函數啟動。以下是 Start() 函數代碼片段:

[./p2p/server.go]
func (srv *Server) Start() (err error) {
    ...
    if !srv.NoDiscovery {
        ...
    }
    if srv.DiscoveryV5 {
        ...
    }
    ...
    // listen/dial
    if srv.ListenAddr != "" {
        if err := srv.startListening(); err != nil {
            return err
        }
    }
    ...
    go srv.run(dialer)
    ...
}

上述代碼中,設置了 p2p 服務的基礎參數,并根據用戶參數開啟節點發現(節點發現不在本文的討論范圍內),隨后開啟 p2p 服務監聽,最后開啟單獨的協程用于處理報文。以下分為服務監聽和報文處理兩個模塊來分析。

服務監聽

通過 startListening() 的調用進入到服務監聽的流程中,隨后在該函數中調用 listenLoop 用一個無限循環處理接受連接,隨后通過 SetupConn() 函數為正常的連接建立 p2p 通信鏈路。在 SetupConn() 中調用 setupConn() 來做具體工作,以下是 setupConn() 的代碼片段:

[./p2p/server.go]
func (srv *Server) setupConn(c *conn, flags connFlag, dialDest *discover.Node) error {
    ...
    if c.id, err = c.doEncHandshake(srv.PrivateKey, dialDest); err != nil {
        srv.log.Trace("Failed RLPx handshake", "addr", c.fd.RemoteAddr(), "conn", c.flags, "err", err)
        return err
    }
    ...
    phs, err := c.doProtoHandshake(srv.ourHandshake)
    ...
}

setupConn() 函數中主要由 doEncHandshake() 函數與客戶端交換密鑰,并生成臨時共享密鑰,用于本次通信加密,并創建一個幀處理器 RLPXFrameRW;再調用 doProtoHandshake() 函數為本次通信協商遵循的規則和事務,包含版本號、名稱、容量、端口號等信息。在成功建立通信鏈路,完成協議握手后,處理流程轉移到報文處理模塊。

下面是服務監聽函數調用流程:

報文處理

p2p.Start() 通過調用 run() 函數處理報文,run() 函數用無限循環等待事務,比如上文中,新連接完成握手包后,將由該函數來負責。run() 函數中支持多個命令的處理,包含的命令有服務退出清理、發送握手包、添加新節點、刪除節點等。以下是 run() 函數結構:

[./p2p/server.go]
func (srv *Server) run(dialstate dialer) {
    ...
    for {
        select {
        case <-srv.quit: ...
        case n := <-srv.addstatic: ...
        case n := <-srv.removestatic: ...
        case op := <-srv.peerOp: ...
        case t := <-taskdone: ...
        case c := <-srv.posthandshake: ...
        case c := <-srv.addpeer: ...
        case pd := <-srv.delpeer: ...
        }
    }
}

為了理清整個網絡架構,本文直接討論 addpeer 分支:當一個新節點添加服務器節點時,將進入到該分支下,根據之前的握手信息,為上層協議生成實例,然后調用 runPeer(),最終通過 p.run() 進入報文的處理流程中。

繼續分析 p.run() 函數,其開啟了讀取數據和 ping 兩個協程,用于處理接收報文和維持連接,隨后通過調用 startProtocols() 函數,調用指定協議的 Run() 函數,進入具體協議的處理流程。

下面是報文處理函數調用流程

p2p 通信鏈路交互流程

這里整體看下 p2p 通信鏈路的處理流程,以及對數據包的封裝。

0x04 共享密鑰

在 p2p 通信鏈路的建立過程中,第一步就是協商共享密鑰,該小節說明下密鑰的生成過程。

迪菲-赫爾曼密鑰交換
p2p 網絡中使用到的是「迪菲-赫爾曼密鑰交換」技術[1]。迪菲-赫爾曼密鑰交換(英語:Diffie–Hellman key exchange,縮寫為D-H) 是一種安全協議。它可以讓雙方在完全沒有對方任何預先信息的條件下通過不安全信道創建起一個密鑰。

簡單來說,鏈接的兩方生成隨機的私鑰,通過隨機的私鑰得到公鑰。然后雙方交換各自的公鑰,這樣雙方都可以通過自己隨機的私鑰和對方的公鑰來生成一個同樣的共享密鑰(shared-secret)。后續的通訊使用這個共享密鑰作為對稱加密算法的密鑰。其中對于 A、B公私鑰對滿足這樣的數學等式:ECDH(A私鑰, B公鑰) == ECDH(B私鑰, A公鑰)

共享密鑰生成
在 p2p 網絡中由 doEncHandshake() 方法完成密鑰的交換和共享密鑰的生成工作。下面是該函數的代碼片段:

[./p2p/rlpx.go]
func (t *rlpx) doEncHandshake(prv *ecdsa.PrivateKey, dial *discover.Node) (discover.NodeID, error) {
    ...
    if dial == nil {
        sec, err = receiverEncHandshake(t.fd, prv, nil)
    } else {
        sec, err = initiatorEncHandshake(t.fd, prv, dial.ID, nil)
    }
    ...
    t.rw = newRLPXFrameRW(t.fd, sec)
    ..
}

如果作為服務端監聽連接,收到新連接后調用 receiverEncHandshake() 函數,若作為客戶端向服務端發起請求,則調用 initiatorEncHandshake()函數;兩個函數區別不大,都將交換密鑰,并生成共享密鑰,initiatorEncHandshake() 僅僅是作為發起數據的一端;最終執行完后,調用 newRLPXFrameRW() 創建幀處理器。

從服務端的角度來看,將調用 receiverEncHandshake() 函數來創建共享密鑰,以下是該函數的代碼片段:

[./p2p/rlpx.go]
func receiverEncHandshake(conn io.ReadWriter, prv *ecdsa.PrivateKey, token []byte) (s secrets, err error) {
    authPacket, err := readHandshakeMsg(authMsg, encAuthMsgLen, prv, conn)
    ...
    authRespMsg, err := h.makeAuthResp()
    ...
    if _, err = conn.Write(authRespPacket); err != nil {
        return s, err
    }
    return h.secrets(authPacket, authRespPacket)
}

共享密鑰生成的過程:

  1. 在完成 TCP 連接后,客戶端使用服務端的公鑰(node_id)加密,發送自己的公鑰和包含臨時公鑰的簽名,還有一個隨機值 nonce。
  2. 服務端收到數據,獲得客戶端的公鑰,使用橢圓曲線算法從簽名中獲得客戶端的臨時公鑰;服務端將自己的臨時公鑰和隨機值 nonce 用客戶端的公鑰加密發送。
  3. 通過上述兩步的密鑰交換后,對于客戶端目前有自己的臨時公私鑰對和服務端的臨時公鑰,使用橢圓曲線算法從自己的臨時私鑰和服務端的臨時公鑰計算得出共享密鑰;同理,服務端按照相同的方式也可以計算出共享密鑰。

以下是共享密鑰生成圖示:

得出共享密鑰后,客戶端和服務端就可以使用共享密鑰做對稱加密,完成對通信的加密。

0x05 RLPXFrameRW 幀

在共享密鑰生成完畢后,初始化了 RLPXFrameRW 幀處理器;其 RLPXFrameRW 幀的目的是為了在單個連接上支持多路復用協議。其次,由于幀分組的消息為加密數據流產生了天然的分界點,更便于數據的解析,除此之外,還可以對發送的數據進行驗證。

RLPXFrameRW 幀包含了兩個主要函數,WriteMsg() 用于發送數據,ReadMsg()用于讀取數據;以下是 WriteMsg() 的代碼片段:

[./p2p/rlpx.go]
func (rw *rlpxFrameRW) WriteMsg(msg Msg) error {
    ...
    // write header
    headbuf := make([]byte, 32)
    ...
    // write header MAC
    copy(headbuf[16:], updateMAC(rw.egressMAC, rw.macCipher, headbuf[:16]))
    if _, err := rw.conn.Write(headbuf); err != nil {
        return err
    }

    // write encrypted frame, updating the egress MAC hash with
    // the data written to conn.
    tee := cipher.StreamWriter{S: rw.enc, W: io.MultiWriter(rw.conn, rw.egressMAC)}
    if _, err := tee.Write(ptype); err != nil {
        return err
    }
    if _, err := io.Copy(tee, msg.Payload); err != nil {
        return err
    }
    if padding := fsize % 16; padding > 0 {
        if _, err := tee.Write(zero16[:16-padding]); err != nil {
            return err
        }
    }

    // write frame MAC. egress MAC hash is up to date because
    // frame content was written to it as well.
    fmacseed := rw.egressMAC.Sum(nil)
    mac := updateMAC(rw.egressMAC, rw.macCipher, fmacseed)
    _, err := rw.conn.Write(mac)
    return err
}

結合以太坊 RLPX 的文檔[2]和上述代碼,可以分析出 RLPXFrameRW 幀的結構。在一般情況下,發送一次數據將產生五個數據包:

header          // 包含數據包大小和數據包源協議
header_mac      // 頭部消息認證
frame           // 具體傳輸的內容
padding         // 使幀按字節對齊
frame_mac       // 用于消息認證

接收方按照同樣的格式對數據包進行解析和驗證。

0x06 RLP 編碼

RLP編碼 (遞歸長度前綴編碼)提供了一種適用于任意二進制數據數組的編碼,RLP 已經成為以太坊中對對象進行序列化的主要編碼方式,便于對數據結構的解析。比起 json 數據格式,RLP 編碼使用更少的字節。

在以太坊的網絡模塊中,所有的上層協議的數據包要交互給 p2p 鏈路時,都要首先通過 RLP 編碼;從 p2p 鏈路讀取數據,也要先進行解碼才能操作。

以太坊中 RLP 的編碼規則[3]。

0x07 LES 協議層

這里以 LES 協議為上層協議的代表,分析在以太坊網絡架構中應用協議的工作原理。

LES 服務由 Geth 初始化時啟動,調用源碼 les 下的 NewLesServer() 函數開啟一個 LES 服務并初始化,并通過 NewProtocolManager() 實現以太坊子協議的接口函數。其中 les/handle.go 包含了 LES 服務交互的大部分邏輯。

回顧上文 p2p 網絡架構,最終 p2p 底層通過 p.Run() 啟動協議,在 LES 協議中,也就是調用 LES 協議的 Run() 函數:

[./les/handle.go#NewProtocolManager()]
Run: func(p *p2p.Peer, rw p2p.MsgReadWriter) error {
    ...
    select {
        case manager.newPeerCh <- peer:
            ...
            err := manager.handle(peer)
            ...
        case <-manager.quitSync:
            ...
    }
}

可以看到重要的處理邏輯都被包含在 handle() 函數中,handle() 函數的主要功能包含 LES 協議握手和消息處理,下面是 handle() 函數片段:

[./les/handle.go]
func (pm *ProtocolManager) handle(p *peer) error {
    ...
    if err := p.Handshake(td, hash, number, genesis.Hash(), pm.server); err != nil {
        p.Log().Debug("Light Ethereum handshake failed", "err", err)
        return err
    }
    ...
    for {
        if err := pm.handleMsg(p); err != nil {
            p.Log().Debug("Light Ethereum message handling failed", "err", err)
            return err
        }
    }
}

handle() 函數中首先進行協議握手,其實現函數是 ./les/peer.go#Handshake(),通過服務端和客戶端交換握手包,互相獲取信息,其中包含有:協議版本、網絡號、區塊頭哈希、創世塊哈希等值。隨后用無線循環處理通信的數據,以下是報文處理的邏輯:

[./les/handle.go]
func (pm *ProtocolManager) handleMsg(p *peer) error {
    msg, err := p.rw.ReadMsg()
    ...
    switch msg.Code {
        case StatusMsg: ...
        case AnnounceMsg: ...
        case GetBlockHeadersMsg: ...
        case BlockHeadersMsg: ...
        case GetBlockBodiesMsg: ...
        ...
    }
}

處理一個請求的詳細流程是:

  1. 使用 RLPXFrameRW 幀處理器,獲取請求的數據。
  2. 使用共享密鑰解密數據。
  3. 使用 RLP 編碼將二進制數據序列化。
  4. 通過對 msg.Code 的判斷,執行相應的功能。
  5. 對響應數據進行 RLP 編碼,共享密鑰加密,轉換為 RLPXFrameRW,最后發送給請求方。

下面是 LES 協議處理流程:

0x08 總結

通過本文的分析,對以太坊網絡架構有了大致的了解,便于后續的分析和代碼審計;在安全方面來講,由協議所帶的安全問題往往比本地的安全問題更為嚴重,應該對網絡層面的安全問題給予更高的關注。

從本文也可以看到,以太坊網絡架構非常的完善,具有極高的魯棒性,這也證明了以太坊是可以被市場所認可的區塊鏈系統。除此之外,由于 p2p 網絡方向的資料較少,以太坊的網絡架構也可以作為學習 p2p 網絡的資料。



針對目前主流的以太坊應用,知道創宇提供專業權威的智能合約審計服務,規避因合約安全問題導致的財產損失,為各類以太坊應用安全保駕護航。

知道創宇404智能合約安全審計團隊: https://www.scanv.com/lca/index.html
聯系電話:(086) 136 8133 5016(沈經理,工作日:10:00-18:00)

歡迎掃碼咨詢:


References:
[1] WIKI.DH: https://en.wikipedia.org/wiki/Diffie–Hellman_key_exchange
[2] Github.rlpx: https://github.com/ethereum/devp2p/blob/master/rlpx.md
[3] WIKI.RLP: https://github.com/ethereum/wiki/wiki/RLP
[4] Github.ZtesoftCS: https://github.com/ZtesoftCS/go-ethereum-code-analysis
[5] CSDN: https://blog.csdn.net/weixin_41814722/article/details/80680749
[6] CSDN: https://blog.csdn.net/itcastcpp/article/details/80305636
[7] ETHFANS: https://ethfans.org/bob/articles/864
[8] BITSHUO: https://bitshuo.com/topic/5975fbb14a7a061b785db8d5
[9] Github.go-ethereum: https://github.com/ethereum/go-ethereum


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/642/