天天看點

solidity開發 - CALL 和 DELEGATECALL 的詳解

本文會從solidity,EVM 和 Geth 三個層面解讀這兩個opcode,讓你對它們有一個全面的認識。然而在深入了解他們之前,我們先确認一下合約執行上下文的概念:

執行上下文

當EVM運作合約時,會創造一個​​上下文​​,它包含以下幾個部分:

  • Code
  • 存儲在鍊上的合約的不可變代碼。
  • Call Stack
  • 前文講過的合約的調用棧,EVM運作合約時會初始化一個空的。
  • Memory
  • 合約的記憶體,EVM運作合約時會初始化一個空的。
  • Storage
  • 存儲區在執行過程中持久化,鍊上存儲,根據合約位址和插槽尋址。
  • The Call Data
  • 交易的傳入資料
  • The Return Data
  • 合約調用的傳回資料

在閱讀下面内容時,時刻記着這幾個點。我們先從​​Smart Contract Programmer​​的DELEGATECALL使用用例開始講:

Solidity 樣例

下圖是同一個合約中的兩個調用,一個使用了DELEGATECALL,另一個使用了CALL。現在我們看一下他們之間的差別。

solidity開發 - CALL 和 DELEGATECALL 的詳解

下邊是這次互動的一些資訊(如果你在remix裡自己執行的話,會是不一樣的資料):

我們有兩個合約,即 Contract A 和 Contract B 還有一個 EOA:

  • EOA 位址 = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
  • Contract A 位址 = 0x7b96aF9Bd211cBf6BA5b0dd53aa61Dc5806b6AcE
  • Contract B 位址 = 0x3328358128832A260C76A4141e19E2A943CD4B6D

現在把 Contract B 的位址和一個uint值 12以及 1000000000000000000 Wei 傳入,調用 Contract A 裡的兩個方法, setVarsDelegateCall 和 setVarsCall。

Delegate Call

  1. 一個 EOA 位址把 Contract B 的位址和一個uint值 12以及 1000000000000000000 Wei 傳入,調用Contract A的setVarsDelegateCall,這次是委托調用Contract B執行setVars(uint256),參數是12。
  2. 委托調用運作 Contract B 的 setVars(uint256) 但是更新的是 Contract A 的存儲區,它運作時的存儲區,msg.sender 和msg.value 也都和父調用一樣。
  3. Contract A的存儲區寫入資料:num=12,sender = EOA Address以及value = 1000000000000000000。盡管Contract A 調用的setVars(uint256)不帶value,

執行完這個方法之後我們檢查Contract A 和Contract B的num, sender 和 value狀态。我們可以看到Contract B沒有被初始化,都設定在Contract A裡了。

Call

  1. 一個 EOA 位址把 Contract B 的位址和一個uint值 12以及 1000000000000000000 Wei 傳入,調用 Contract A 的setVarsCall,這次是調用Contract B執行setVars(uint256),參數是12。
  2. 調用運作 Contract B 的setVars(uint256) ,不改變(本合約的)存儲區,msg.sender,和msg.value
  3. Contract B的存儲區寫入資料:num=12,sender = Contract A Address 以及value = 0。(1000000000000000000 Wei被傳進了父調用setVarsCall。)

執行完這個方法之後我們檢查Contract A 和Contract B的num, sender 和 value狀态。我們可以看到Contract A沒有被初始化,都設定在Contract B裡了。

“委托調用”就是允許你從别的合約裡複制一個方法粘貼到你的合約裡,運作起來就行在你的合約裡執行的一樣,使用本合約的存儲區,msg.sender 和 msg.value。

而“調用”是進入到另一個合約去執行方法,相當于發了一筆交易,有其自己的value值和sender(也就是調用call的合約位址)。

Delegate Call & Storage Layout委托調用與記憶體布局

在上述例子裡,你肯呢個注意到Contract B 第5行的注釋*“NOTE: storage layout must be the same as contract A”*。

合約裡的每一個函數都會經過編譯成為一個靜态的位元組碼。當我們了解solidity變量的時候,是看見num,sender和value這三個變量去了解的。但是位元組碼不知道這些,它隻認存儲插槽,而聲明變量的時候就把插槽定下來了。

Contract B 的 setVars(uint256) 函數裡,“num = _num”就是說要把 _num 存進插槽0。當我們看一個DELEGATECALL的時候不要去想num → num,sender → sender的映射,因為在位元組碼的層面不是這樣的,我們需要認識到這是slot 0 → slot 0, slot 1 → slot 1的映射。

solidity開發 - CALL 和 DELEGATECALL 的詳解

 試想如果我們改變了聲明變量的順序會怎樣。那麼他們的插槽位置會改變,同時setVars(uint256) 的位元組碼也跟着變了。如果我們把 Contract B 的6行和8行互換位置,先聲明 value 後聲明 num 。那就意味着11行的“num = _num”意味着把 _num存進插槽2裡,13行的“value = msg.value”意味着把msg.value 存進插槽0。這就用意味着兩合約中,我們變量之間的映射和插槽之間的映射不比對了

solidity開發 - CALL 和 DELEGATECALL 的詳解

在這種情況下,當我們運作 DELEGATECALL 時,num變量會被存在插槽2,而這裡在 Contract A 中映射到 value 變量。反過來也是一樣的,兩個變量就會存儲進預想之外的地方。這就是DELEGATECALL比較危險的原因之一。我們意外地 value 值把 num 覆寫了,用 num 值把 value 覆寫了。但是黑客可不會意外,他們會有目的地攻擊。

試想我們知道一個開放 delegatecall 的合約,我們知道那個合約存儲 owner的插槽。現在我們可以做一個相同布局的合約,然後寫一個更新owner的方法,這就意味着我們可以通過委托調用這個更新方法來改變該合約的owner。

如果你對這個黑客攻擊感興趣的話可以在這裡深入了解一下:

  • ​​Ethernaut Level 6 - Delegation​​
  • ​​Ethernaut Level 16 - Preservation​​

下面看一看opcode層面

Opcodes

我們現在知道DELEGATECALL怎麼工作了,那麼深入一下,看看DELEGATECALL和CALL的操作碼。

對于DELEGATECALL我們有以下輸入變量

  • ​gas​

    ​: 執行的gas費
  • ​address​

    ​: 執行上下文的account
  • ​argsOffset​

    ​: 輸入資料(calldata)的偏移量
  • ​argsSize​

    ​: calldata的大小
  • ​retOffset​

    ​: 輸出資料(returndata)的偏移量
  • ​retSize​

    ​: returndata的大小

CALL比起上邊的隻多一個value,其它的都一樣

  • ​value​

    ​: 發送給account的以太币(CALL only)

委托調用不需要value輸入,它從父調用繼承。我們的執行上下文有和父調用一樣的存儲區,msg.sender 和 msg.value。

他們都是有一個傳回值布爾值"success",為0則為執行失敗,反之則為1。

如果調用位置沒有合約或者沒有代碼,Delegatecall會傳回true。這會出現bug,因為它沒執行,我們是希望傳回False的

DELEGATECALL Opcode Inspection With Remix利用Remix檢驗DELEGATECALL

下邊是Remix中調用DELEGATECALL操作碼的截圖。對應Solidity代碼的24-26行。

我們可以看到棧和記憶體的條目以及它們是怎麼傳進DELEGATECALL的。

solidity開發 - CALL 和 DELEGATECALL 的詳解

我們按照這條路線了解:opcode → stack → memory → calldata

  1. Solidity代碼的24行,使用了delegatecall 調用 Contract B 的 setVars(unit256),調用了DELEGATECALL操作碼。
  2. DELEGATECALL從棧上拿6個輸入:
  1. Gas = 0x45eb
  2. Address = 0x3328358128832A260C76A4141e19E2A943CD4B6D (Address for Contract B)
  3. ArgsOffset = 0xc4
  4. ArgsSize = 0x24
  5. RetOffset = 0xc4
  6. RetSize = 0x00
  1. 注意到 argsOffset 和 argsSize 兩個代表了傳入 Contract B 的 calldata。這兩個變量讓我們從記憶體位置0xc4開始,複制後邊的 0x24 (十進制36)作為calldata。
  2. 我們是以拿到了0x6466414b000000000000000000000000000000000000000000000000000000000000000c,6466414b是setVars(uint256) 的函數簽名,而000000000000000000000000000000000000000000000000000000000000000c是我們傳入的資料 12。
  3. 這對應了Solidity代碼的25行,abi.encodeWithSignature("setVars(uint256)", _num)。

因為setVars(uint256)不傳回任何值,是以retSize置0。

如果有傳回值的話,就是存在retOffset以後的retOffset以内。這應該讓你對這個操作碼的底層邏輯了解的深一點,也會和Solidity聯系起來了。

現在我們看一下Geth裡的實作。

Geth實作

我們看一下Geth裡寫DELEGATECALL的部分。目标是展現DELEGATECALL和CALL在存儲的層面的差別,以及是怎麼聯系上SLOAD的。

下邊有圖,我們拆解開來一步一步做,在結束的時候你就會對DELEGATECALL和CALL有深刻的認識。

solidity開發 - CALL 和 DELEGATECALL 的詳解

We have the DELEGATECALL & CALL opcodes labeled on the left-hand side and the SLOAD opcode labeled bottom right. Let’s see how they’re connected.

  1. 這圖裡有兩個 [1] 号截圖,分别對應DELEGATECALL和CALL操作碼的代碼,在​​instructions.go​​裡。我們可以看到從棧裡彈出的那幾個變量,之後可以看到調用 interpreter.evm.DeleagteCall和 interpreter.evm.Call 這兩個函數,傳進去了棧裡的變量,目标位址和現在的合約上下文
  2. 圖裡也有兩個 [2] 号截圖,分别對應 evm.DelegateCall 和 evm.Call 的代碼的代碼,在​​evm.go​​裡邊。中間省略了一些校驗和其它函數,我們主要關注執行調用NewContract方法建立上下文的代碼,其它的可以忽略掉。
  3. 圖裡有兩個 [3] 号截圖。裡邊主要是evm.DelegateCall和evm.Call 調用NewContract。它們非常相似,以下兩點除外:
  1. DelegateCall的value參數設為nil,它從之前的上下文繼承,是以不寫進這個參數裡。
  2. NewContract的第二個參數也不一樣。evm.DelegateCall 裡caller.Address( ) 用的是Contract A的位址。evm.Call 裡addrCopy是複制的toAddr,也就是Contract B的位址,這一點差別非常大。他倆都是AccountRef類型,這個很重要,後邊會提到。
  1. DelegateCall’s的NewContract會傳回一個Contract結構體。它又調用了AsDelegate()方法(在​​contract.go​​裡)(見圖[4]),把msg.sender 和 msg.value設定成了父調用的樣子,也就是EOA位址和1000000000000000000 Wei。這在Call的實作裡是沒有的。
  2. evm.DelegateCall 和 evm.Call 都執行NewContract方法(在​​contract.go​​裡),NewContract方法的第二個入參是“object ContractRef”,對應着第三點裡提到的AccountRef。
  3. “object ContractRef”和一些其他值被用來初始化合約,對應Contract結構體裡的“self”
  4. Contract結構體( 在​​contract.go​​裡)有一個“self”字段,你可以看到也有其他的字段與我們之前提到的執行上下文有關。
  5. 現在我們跳躍一下,去看看Geth裡SLOAD(在​​instructions.go​​裡)的實作,它在調用GetState時用的參數就是scope.Contract.Address( )。這裡的“Contract”就是我們在第7條提到的結構體。
  6. Contract結構體的Address( ) 傳回的是self.Address。
  7. Self是一個ContractRef類型,ContractRef必然有一個Address( ) 方法。
  8. ContractRef是一個接口,規定如果一個類型要做ContractRef,那必須有一個傳回值類型是common.Address的Address( ) 函數。common.Address是一個長度20的位元組數組,也就是以太坊位址的長度。
  9. 我們回到第3塊看一下evm.DelegateCall和evm.Call 中AccountRef的差別。我們可以看到AccountRef就是一個有Address( ) 函數的位址,那它也符合ContractRef接口的規則。
  10. AccountRef 的 Address( ) 函數是把 AccountRef 轉化成common.Address,也就是 evm.DelegateCall 裡的 Contract A 位址和 evm.Call 裡的Contract B 位址。這意味着第8部分講的 SLOAD 會在 DELEGATECALL 時使用 Contract A 的存儲區,在 CALL 時使用 Contract B 的存儲區。

以上即對DelegateCall的存儲區, msg.sender和msg.value的來龍去脈作詳細講解,對DELEGATECALL作了基本講解!