本文會從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。現在我們看一下他們之間的差別。
下邊是這次互動的一些資訊(如果你在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
- 一個 EOA 位址把 Contract B 的位址和一個uint值 12以及 1000000000000000000 Wei 傳入,調用Contract A的setVarsDelegateCall,這次是委托調用Contract B執行setVars(uint256),參數是12。
- 委托調用運作 Contract B 的 setVars(uint256) 但是更新的是 Contract A 的存儲區,它運作時的存儲區,msg.sender 和msg.value 也都和父調用一樣。
- 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
- 一個 EOA 位址把 Contract B 的位址和一個uint值 12以及 1000000000000000000 Wei 傳入,調用 Contract A 的setVarsCall,這次是調用Contract B執行setVars(uint256),參數是12。
- 調用運作 Contract B 的setVars(uint256) ,不改變(本合約的)存儲區,msg.sender,和msg.value
- 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的映射。
試想如果我們改變了聲明變量的順序會怎樣。那麼他們的插槽位置會改變,同時setVars(uint256) 的位元組碼也跟着變了。如果我們把 Contract B 的6行和8行互換位置,先聲明 value 後聲明 num 。那就意味着11行的“num = _num”意味着把 _num存進插槽2裡,13行的“value = msg.value”意味着把msg.value 存進插槽0。這就用意味着兩合約中,我們變量之間的映射和插槽之間的映射不比對了
在這種情況下,當我們運作 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
-
: 執行上下文的accountaddress
-
: 輸入資料(calldata)的偏移量argsOffset
-
: calldata的大小argsSize
-
: 輸出資料(returndata)的偏移量retOffset
-
: returndata的大小retSize
CALL比起上邊的隻多一個value,其它的都一樣
-
: 發送給account的以太币(CALL only)value
委托調用不需要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的。
我們按照這條路線了解:opcode → stack → memory → calldata
- Solidity代碼的24行,使用了delegatecall 調用 Contract B 的 setVars(unit256),調用了DELEGATECALL操作碼。
- DELEGATECALL從棧上拿6個輸入:
- Gas = 0x45eb
- Address = 0x3328358128832A260C76A4141e19E2A943CD4B6D (Address for Contract B)
- ArgsOffset = 0xc4
- ArgsSize = 0x24
- RetOffset = 0xc4
- RetSize = 0x00
- 注意到 argsOffset 和 argsSize 兩個代表了傳入 Contract B 的 calldata。這兩個變量讓我們從記憶體位置0xc4開始,複制後邊的 0x24 (十進制36)作為calldata。
- 我們是以拿到了0x6466414b000000000000000000000000000000000000000000000000000000000000000c,6466414b是setVars(uint256) 的函數簽名,而000000000000000000000000000000000000000000000000000000000000000c是我們傳入的資料 12。
- 這對應了Solidity代碼的25行,abi.encodeWithSignature("setVars(uint256)", _num)。
因為setVars(uint256)不傳回任何值,是以retSize置0。
如果有傳回值的話,就是存在retOffset以後的retOffset以内。這應該讓你對這個操作碼的底層邏輯了解的深一點,也會和Solidity聯系起來了。
現在我們看一下Geth裡的實作。
Geth實作
我們看一下Geth裡寫DELEGATECALL的部分。目标是展現DELEGATECALL和CALL在存儲的層面的差別,以及是怎麼聯系上SLOAD的。
下邊有圖,我們拆解開來一步一步做,在結束的時候你就會對DELEGATECALL和CALL有深刻的認識。
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] 号截圖,分别對應DELEGATECALL和CALL操作碼的代碼,在instructions.go裡。我們可以看到從棧裡彈出的那幾個變量,之後可以看到調用 interpreter.evm.DeleagteCall和 interpreter.evm.Call 這兩個函數,傳進去了棧裡的變量,目标位址和現在的合約上下文
- 圖裡也有兩個 [2] 号截圖,分别對應 evm.DelegateCall 和 evm.Call 的代碼的代碼,在evm.go裡邊。中間省略了一些校驗和其它函數,我們主要關注執行調用NewContract方法建立上下文的代碼,其它的可以忽略掉。
- 圖裡有兩個 [3] 号截圖。裡邊主要是evm.DelegateCall和evm.Call 調用NewContract。它們非常相似,以下兩點除外:
- DelegateCall的value參數設為nil,它從之前的上下文繼承,是以不寫進這個參數裡。
- NewContract的第二個參數也不一樣。evm.DelegateCall 裡caller.Address( ) 用的是Contract A的位址。evm.Call 裡addrCopy是複制的toAddr,也就是Contract B的位址,這一點差別非常大。他倆都是AccountRef類型,這個很重要,後邊會提到。
- DelegateCall’s的NewContract會傳回一個Contract結構體。它又調用了AsDelegate()方法(在contract.go裡)(見圖[4]),把msg.sender 和 msg.value設定成了父調用的樣子,也就是EOA位址和1000000000000000000 Wei。這在Call的實作裡是沒有的。
- evm.DelegateCall 和 evm.Call 都執行NewContract方法(在contract.go裡),NewContract方法的第二個入參是“object ContractRef”,對應着第三點裡提到的AccountRef。
- “object ContractRef”和一些其他值被用來初始化合約,對應Contract結構體裡的“self”
- Contract結構體( 在contract.go裡)有一個“self”字段,你可以看到也有其他的字段與我們之前提到的執行上下文有關。
- 現在我們跳躍一下,去看看Geth裡SLOAD(在instructions.go裡)的實作,它在調用GetState時用的參數就是scope.Contract.Address( )。這裡的“Contract”就是我們在第7條提到的結構體。
- Contract結構體的Address( ) 傳回的是self.Address。
- Self是一個ContractRef類型,ContractRef必然有一個Address( ) 方法。
- ContractRef是一個接口,規定如果一個類型要做ContractRef,那必須有一個傳回值類型是common.Address的Address( ) 函數。common.Address是一個長度20的位元組數組,也就是以太坊位址的長度。
- 我們回到第3塊看一下evm.DelegateCall和evm.Call 中AccountRef的差別。我們可以看到AccountRef就是一個有Address( ) 函數的位址,那它也符合ContractRef接口的規則。
- 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作了基本講解!