EVM 深入探討系列之解讀 CALL 和 DELEGATECALL 操作碼。

作者: Flush

導語

這是 noxx “EVM 深入探討” 系列的第五部分,這期我們將從 Solidity、EVM 和 Geth 客戶端層的工作原理,詳細解讀 CALL 和 DELEGATECALL 這兩個操作碼。

在我們深入探討之前,我們需要先了解合約執行上下文的概念。

執行上下文

當以太坊虛擬機 (EVM) 執行智能合約時,會為其創建一個上下文,由以下內容組成:

  • The Code

合約的字節碼是不可改變的,它被存儲在鏈上,並使用合約地址進行引用。代碼是存儲指令的區域。存儲在代碼中的指令數據作為合約賬戶狀態字段的一部分是持久的。外部擁有的賬戶(或 EOA)有空的代碼區域。代碼是智能合約執行期間由 EVM 讀取、解釋和執行的字節。代碼是不可變的,這意味著它不能被修改,但它可以通過指令 CODESIZE 和 CODECOPY 來讀取。一個合約的代碼可以被其他合約讀取,通過指令 EXTCODESIZE 和 EXTCODECOPY。

  • The Stack

調用棧,每個 EVM 合約執行時都會初始化一個空的棧。棧是一個 32 字節的元素列表,用於存儲智能合約指令的輸入和輸出。每個調用上下文創建一個棧,當調用上下文結束時,它被銷毀。當一個新的值被放在棧區時,它被放在頂部,只有頂部的值才會被指令使用。目前,棧的最大限制是 1024 個值。所有的指令都與棧相互作用,但它可以直接用 PUSH1, POP, DUP1 或 SWAP1 等指令進行操作。

  • The Memory

合約內存,是為每個 EVM 合約執行而初始化一個空的內存。EVM 的內存是不永久的,在調用上下文結束時被銷毀。在調用上下文開始時,內存被初始化為 0。從內存中讀和寫通常分別用 MLOAD 和 MSTORE 指令完成,但也可以用其他指令如 CREATE 或 EXTCODECOPY 來訪問。

  • The Storage

存儲區在執行過程中持久化,鏈上存儲,根據合約地址和插槽尋址。合約存儲是跨執行的,它存儲在鏈上,通過合約地址和它的存儲插槽被引用。存儲器是一個 32 字節的插槽與 32 字節的值的映射。存儲是智能合約的持久性內存:合約寫入的每個值都會保留到調用完成後,除非其值被改為 0,或者執行 SELFDESTRUCT 指令。從未寫入的密鑰中讀取存儲的字節也會返回 0。每個合約都有自己的存儲,不能讀取或修改其他合約的存儲。存儲是用指令 SLAD 和 STORE 來讀和寫的。

  • The calldata

交易傳入的數據。calldata 區域是作為智能合約交易的一部分發送給交易的數據。例如,當創建一個合約時,calldata 將是新合約的構造器代碼。值得注意的是,當一個合約執行 xCALL 指令時,它也會創建一個內部交易,在新的上下文中產生一個 calldata 區域。calldata 是不可改變的,可以用指令 CALLDATALOAD, CALLDATASIZE 和 CALLDATACOPY 來讀取。當一個合約執行 xCALL 指令時,它也會創建一個內部事務。因此,當執行 xCALL 時,在新的上下文中有一個 calldata 區域。

  • The Return Data

合約調用的返回數據。The Return Data 是智能合約在調用後可以返回一個值的方式。它可以由合約調用通過 RETURN 和 REVERT 指令設置,也可以由調用合約通過 RETURNDATASIZE 和 RETURNDATACOPY 讀取。

在閱讀後面的內容時,請記住以上內容。我們將從 Smart Contract Programmer

(https://solidity-by-example.org/delegatecall/) 的 DELEGATECALL 合約實例講起。

Solidity 實例

下圖顯示了同一個合約上兩個函數調用的執行情況,一個使用 DELEGATECALL,另一個使用 CALL。

我們將運行這兩個函數並比較它們的不同之處。

以下是這次交互的一些信息(如果我們在 remix 中自己執行的話,數據將會有所不同):

我們部署兩個合約,名為合約 A 和 B,以及使用一個 EOA 地址,數據如下:

  • EOA 地址:0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
  • 合約 A 的地址:0x7b96aF9Bd211cBf6BA5b0dd53aa61Dc5806b6AcE
  • 合約 B 的地址:0x3328358128832A260C76A4141e19E2A943CD4B6D

現在把 Contract B 的地址,一個 uint 值 12 以及 1000000000000000000 Wei(也就是 1 ETH)參數傳入,來調用 Contract A 裡的兩個函數,setVarsDelegateCall 和 setVarsCall。

  • Delegate Call

1. EOA 地址將合約 B 的地址,一個 uint 值 12 以及 1000000000000000000 Wei 傳入,調用合約 A 的 setVarsDelegateCall,由 delegatecall 調用合約 B 執行 setVars(uint256),參數為 12。

2. 該 delagatecall 調用執行了合約 B 的 setVars(uint256) 代碼,但更新了合約 A 的存儲。執行時的存儲、msg.sender 和 msg.value 與它的父調用 setVarsDelegateCall 相同。

3. 這些值被設置在合約 A 的存儲中,12 為 num,調用者 EOA 地址 (0x5B38) 為 sender,100000000000000 為 value。儘管 setVars(uint256) 被合約 A 調用,但當我們檢查 msg.sender 和 msg.value 時,我們得到了原 setVarsDelegateCall 的值。

執行這個函數後,檢查合約 A 和 B 的 num, sender 和 value 狀態,我們能看到合約 B 沒有被初始化,而所有的值都在合約 A 中設置。

  • Call

1. EOA 地址將合約 B 的地址,一個 uint 值 12 以及 1000000000000000000 Wei 傳入,調用合約 A 的 setVarsCall,由 call 調用合約 B 執行 setVars(uint256),參數為 12。

2. 該 call 調用執行合約 B 的 setVars(uint256) ,不改變(本合約的)存儲區、msg.sender 和 msg.value。

3. 合約 B 的存儲區寫入數據:num = 12,sender = 合約 A 地址以及 value = 0(1000000000000000000 Wei 被傳進了父調用 setVarsCall)。

在這個函數執行後,我們可以再次檢查合約 A 和 B 的 num, sender 和 value 狀態。我們看到情況正好相反,合約 A 中沒有值被初始化,而合約 B 中所有的值都被設置。

從概念上講,delegatecall 實際上允許從另一個合約中復制粘貼一個函數到當前合約中。它將被運行,就像它被你的合約執行一樣,並且可以訪問相同的存儲、msg.sender 和 msg.value。

你也可以查看團隊之前的文章來對 call 和 delegatecall 進行了解:智能合約安全審計入門篇—— delegatecall (1)

Delegate Call 和內存佈局

在上述的例子中,你可能會注意合約 B 第 5 行的註釋 “NOTE: storage layout must be the same as contract A(注意:內存佈局需要和合約 A 一樣)”。

合約裡的每一個函數都映射到一些靜態字節碼,這些靜態字節碼由編譯時計算出。當我們從 Solidity 代碼來看時,是一個個變量,如:num, sender 和 value。但是字節碼並不是這樣來識別的,它只認存儲插槽,而聲明變量的時候就把插槽定下來了(有所遺忘的話可以在之前的 Part 3  再回顧一下)。

合約 B 的 setVars(uint256) 函數中,”num = _num” 是要把_num 存進插槽 0。當我們看一個 DELEGATECALL 的時候我們不應該單純考慮 num → num,sender → sender 的映射,因為在字節碼的層面並不是這樣的,而是需要認識到這是 slot 0 → slot 0, slot 1 → slot 1 的映射。

下圖顯示了這種映射,以及相對應的變量名稱。

如果我們改變了定義狀態變量的順序,會發生什麼呢?

這將改變存儲插槽位置,同時也改變了 setVars(uint256) 函數的字節碼。如果我們通過互換第 6 行的 num 和第 8 行的 value 來更新合約 B,將首先聲明 “value” 狀態變量,再聲明 “num” 狀態變量。

這意味著 setVars(uint256) 中的第 11 行 “num = _num” 將_num 存入存儲插槽 2,第 13 行 “value = msg.value” 將 msg.value 存儲在存儲插槽 0 中。合約 A 和 B 之間的變量映射將不再與它們的存儲插槽相匹配。

當我們調用 DELEGATECALL 時,num 變量將被存儲在合約 A 的存儲插槽 2 中,該槽映射到 “value” 狀態變量。同樣地,當 “value” 被存儲時,它將被更新到 0 號插槽,該插槽映射到 “num” 狀態變量。這也是為什麼使用 DELEGATECALL 可能存在風險的原因之一。我們可能會不小心地用 value 值把 num 覆蓋了,用 num 值把 value 覆蓋了。但黑客不會,他們會有預謀有目的地對此進行攻擊。

試想一下我們有一個合約,存在一個開放式的 delegatecall,同時也知道此合約的 owner 存放在哪個插槽。這時我們就能夠構建一個帶有相同狀態變量佈局的合約。然後寫一個更新 owner 的方法,並通過 delegatecall 這個更新方法來改變該合約的 owner。

下面看一看操作碼層面。

Opcodes

我們對 DELEGATECALL 的工作原理有了大致的了解,接下來深入了解一下 DELEGATECALL 和 CALL 的操作碼。

對於 DELEGATECALL,有以下輸入變量:

  • gas:執行所需要消耗的 gas 費。
  • address :需要執行的賬戶。
  • argsOffset:輸入內存中數據 (calldata) 的字節偏移量。
  • argsSize:需要復制數據 (calldata) 的字節大小。
  • retOffset:輸出內存中數據 (return data) 的字節偏移量。
  • retSize:需要復制數據 (return data) 的字節大小。

CALL 與 DELEGATECALL 相比有完全相同的輸入變量,但多出一個附加值 value。

  • value:以 Wei 為單位的 value 數量的以太幣發送到賬戶(僅限 call)。

delegatecall 不需要 value 值輸入,因為它是從它的父調用中繼承。執行上下文的存儲空間與它的父調用相同的存儲區、msg.sender 和 msg.value。他們都是有一個布爾返回值 “success”,為 0 為執行失敗,反之為 1。

如果調用位置沒有合約或者沒有代碼,那麼 Delegatecall 將返回成功 “True”。如果在代碼設計上我們期望 delegatecall 函數在不能執行時返回 “False”,這可能會導致出現 bug。

為了理解這個操作碼,讓我們檢查一下前面的例子中合約 A 和合約 B 是如何執行 DELEGATECALL 的。

通過 Remix 檢查 DELEGATECALL 操作碼

下面是 Remix 中調用 DELEGATECALL 操作碼的截圖。對應 Solidity 代碼的 24 – 26 行。我們可以看到棧和內存的條目以及它們是怎麼傳進 DELEGATECALL 的。

下面將從 “操作碼→ 棧→ 內存→ calldata” 的順序來講解。

1. Solidity 代碼的 24 行,使用了 delegatecall 調用合約 B

的 setVars(unit256),調用執行 DELEGATECALL 操作碼。

2. DELEGATECALL 操作碼從棧中取出的 6 個輸入:

  • gas = 0x45eb;
  • address = 0x3328358128832A260C76A4141e19E2A943CD4B6D(合約 B 的地址);
  • argsOffset = 0xc4;
  • argsSize = 0x24;
  • retOffset = 0xc4;
  • retSize = 0x00。

3. argsOffset 和 argsSize 代表了將被傳遞給合約 B 的 calldata。這兩個變量表示從內存位置 0xc4 開始,並複制下一個 0x24(十進制的 36)字節作為 calldata。

4. 我們得到了

0x6466414b000000000000000000000000000000000000000000000000000000000000000c,可以拆分成 0x6466414b 和

0x00000000000000000000000000000000000000000000c,前者是

setVars(uint256) 的函數簽名,後者是十進制的 12,代表我們對 num 的輸入值。

5. 這對應了 Solidity 代碼的 25 行

abi.encodeWithSignature(“setVars(uint256)”, _num)。

因為 setVars(uint256) 並不返回任何值,所以 retSize 等於 0。如果存在返回值的情況,retSize 的值將被更新,返回的值將被存儲在 retOffset 中。以上操作應該讓我們對這個操作碼的底層邏輯了解的深一點,也同 Solidity 代碼聯繫起來了。

接下來現在讓我們來看看 Geth 客戶端的實現。

Geth 實現

我們來看一下 Geth 裡 DELEGATECALL 的一個特定部分,目的是展示 DELEGATECALL 操作碼在存儲範圍層面上與 CALL 操作碼有什麼不同,以及這與 LOAD 操作碼有什麼關係。

下邊的圖看起來會顯得很複雜,但是我們拆解開來一步一步做,在結束的時候會對 DELEGATECALL 和 CALL 有更深刻的認識了。

我們在左側標註了 DELEGATECALL 和 CALL 操作碼,在右下方標註了 LOAD 操作碼。讓我們看看它們是如何联繫起來的。

1. 圖中兩個 [1] 為 instructions.go 中的代碼,分別對應了 DELEGATECALL 和 CALL 操作碼的 Geth 函數。我們可以看到從棧中彈出的那幾個變量,之後看到調用 interpreter.evm.DeleagteCall 和 interpreter.evm.Call 這兩個函數,並帶有棧中的值、“to 地址” 和當前的合約範圍。

2. 圖中兩個 [2] 為 evm.go 中的代碼,分別對應了 evm.DelegateCall 和 evm.Call 執行的函數代碼。圖中省略了函數代碼的內容,重點關注 NewContract 函數的調用。NewContract 函數為我們創建了一個新的合約上下文,以便在其中執行。

3. 圖中兩個 [3] 為 evm.DelegateCall 和 evm.Call 的 NewContract 函數調用,與 2 中內容非常相似,除了以下兩點:

  • DelegateCall 中的參數值被設置為 nil,請記住,它從其父級上下文中繼承值,所以不會寫入這個參數中。
  • 輸入到 NewContract 函數的內容是不同的。在 evm.DelegateCall 中,caller.Address() 被傳入為合約 A 的地址。在 evm.Call 中,addrCopy 是複制的 toAddr 地址,也就是合約 B 的地址,這一點區別非常大。這兩個都為 AccountRef 類型。

4. DelegateCall 的 NewContract 將返回一個 Contract 結構體。其調用 AsDelegate() 函數(在 contract.go 中,圖 [4])。它將 msg.sender 和 msg.value 設置為原始調用的值也就是 EOA 地址和 100000000000000 Wei。這在 Call 調用實現中是沒有的。

5. 在 contract.go 中的 evm.DelegateCall 和 evm.Call 都執行 NewContract 函數。NewContract 函數的第二個入參變量為 “object ContractRef”,對應 3 中提到的 AccountRef 的映射。

6. “object ContractRef” 和一些其他值被用來初始化合約,對應 Contract 結構體裡的 “self”。

7. Contract 結構體(在 contract.go 中)有一個 “self” 字段,同時能看到也有其他與我們之前提到的執行上下文有關的字段。

8. 現在我們跳到 Geth 裡 SLOAD(在 instructions.go 中)的實現上,它在調用 GetState 時用的參數為 scope.Contract.Address()。這裡的 “Contract” 就是在 7 中提到的結構體。

9. Contract 結構體的 Address() 返回的是 self.Address。

10. Self 是一個 ContractRef 類型,ContractRef 必然有一個 Address() 方法。

11. ContractRef 是一個接口,規定如果一個類型要為 ContractRef,那必須有一個返回值類型是 common.Address 的 Address() 方法。common.Address 是一個長度為 20 的字節數組,也就是一個以太坊地址的長度。

12. 我們回到 [3] 中來看 evm.DelegateCall 和 evm.Call 中 AccountRef 的區別,可以發現 AccountRef 就是一個有 Address() 函數的地址,同時也符合 ContractRef 接口的規則。

13. AccountRef 的 Address() 函數是把 AccountRef 轉化成 common.Address,也就是 evm.DelegateCall 裡的合約 A 地址和 evm.Call 里合約 B 地址。這意味著第 8 部分講的 SLOAD 會在 DELEGATECALL 時使用合約 A 的存儲區,在 CALL 時使用合約 B 的存儲區。

看完 Geth 的實現,就會知道存儲、msg.sender 和 msg.value 在 DelegateCall 中是怎樣改變的了。現在我們也對 DELEGATECALL 操作碼有了全面的了解。

這篇文章到這裡就結束啦,下一期 Part 6 我們將深入探討 EVM、交易收據及其相關事件日誌中的關鍵數據結構。

參考鏈接:

https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-a5f

免責聲明:作為區塊鏈信息平台,本站所發布文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。文章內的信息僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。