智能合約安全審計入門系列之delegatecall
By:小白@慢霧安全團隊
背景概述
上篇文章中我們學習了合約中數據的存儲方式以及如何讀取合約中的各種數據。這次我們將帶大家了解delegatecall 函數。
前置知識
首先,我們先來了解合約中常見的兩種外部函數調用:call 和delegatecall,下面我們通過一個簡單的小實驗來看看這兩者的區別。
首先來看A 合約:
contract A { address public a;
function test() public returns (address b){ b = address(this); a = b;
}}
部署後得到A 合約的地址,我們再利用A 合約的地址部署B 合約:
contract B { address public a;
address Aaddress = //這裡填入A 合約的地址; function testCall() public{ Aaddress.call(abi.encodeWithSignature(“test()”)); } function testDelegatecall() public{ Aaddress.delegatecall(abi.encodeWithSignature(“test()”)); }}
當我們調用B.testCall() 或者B.testDelegatecall() 函數時,這兩個函數都會去調用A.test() ,我們需要做的是觀察B 合約與A 合約中的address a 的變化。
首先我們來看部署後A 合約與B 合約中的address a 的值:
solidity
solidity
這裡我們可以看到,部署後A 合約與B 合約中的address a 的值均為0,我們先調用B.testCall() 函數看看會發生什麼變化:
solidity
調用後我們先查看B:address a 地址的值,發現並未改變
solidity
我們再來看A:address a 的值,這裡我們可以看到A:address a 現在被賦值了,當前address a 的值為
0x9F2b8EAA0cb96bc709482eBdcB8f18dFB12D3133, 這個值正是A 合約的地址。
這裡我們可以得出一個結論:當合約使用call 函數進行外部函數調用時,是在被調用合約的代碼環境中執行相應的代碼,對調用者沒有影響。
solidity
重新部署後調用B.testDelegatecall() 函數(這裡需要清除之前的合約數據所以需要重新部署,兩個合約的地址也會改變):
solidity
成功調用後我們來查看B:address a 的值,這裡我們發現B:address a 被成功賦值了,當前address a 的值為0xB25f1f0B4653b4e104f7Fbd64Ff183e23CdBa582,這個值為B 合約的地址。
我們再來看A:address a 地址的值,發現並未發生改變,所以當我們使用B.testDelegatecall() 調用A.test() 時,test 函數中的代碼邏輯是在B 合約的環境中執行的,相當於將A.test() 的代碼拿到B 合約中執行,且這個操作並不會影響A 合約中的數據。
solidity
總結一下,從上面的小實驗中我們可以很明顯的看到call 和delegatecall 的區別:
- call:調用後內置變量msg 的值會修改為調用者,執行環境為被調用者的運行環境;
- delegatecall:調用後內置變量msg 的值不會修改為調用者,但執行環境為調用者的運行環境;
- callcode:調用後內置變量msg 的值會修改為調用者,但執行環境為調用者的運行環境。需要注意的是callcode 已經在0.5.0 以後的版本被禁用了,所以我們這裡只做簡單了解。
我們可以通過一張圖來了解一下:
solidity
了解了delegatecall 函數與call 函數的區別後我們再來看delegatecall 函數的一個有趣的特點:
我們依然是通過一個小實驗來為大家講解(這裡涉及到solidity 中變量的存儲方式,在上一篇文章《智能合約安全審計入門篇之訪問私有數據》中有比較詳細的講解)。
這裡我們還是請出剛剛的兩個合約並稍作修改,我們在兩個合約中都加入address c:
contract A { address public c; address public a;
function test() public returns (address b){ b = address(this); a = b;
}}
contract B { address public a; address public c;
address Aaddress = //這裡填入A 合約的地址;
function testDelegatecall() public{ Aaddress.delegatecall(abi.encodeWithSignature(“test()”)); }}
這裡從代碼中可以看到,我將兩個合約中的address a 和address c 的聲明順序反過來。下面我們部署合約後來調用B.testDelegatecall() 看看會發生什麼有趣的現象(這裡省略部署過程)。
solidity
下面我們來看address a 和address c 的值會發生什麼變化:
solidity
solidity
這里大家肯定也發現問題了,我們通過A.test() 明明修改的是address a,為什麼調用後的結果是address a 沒有變化反而address c 被修改了呢?
這就要引出delegatecall 函數的一個有趣的特點了,當我們的外部調用涉及到storage 變量的修改時,變量的修改並不是根據變量名來修改的,而是根據變量的存儲位置來修改的。 A 合約中address c 存儲在slot0 中,address a 存儲在slot1 中,反之在B 合約中address a 存儲在slot0 中,address c 存儲在slot1 中。當我們通過調用B 合約中的delegatecall 函數調用A 合約中的test 函數時,test 函數修改的是A 合約中slot1 這個插槽,所以代碼運行的結果是B 合約中的address c 被修改了,因為在B 合約中的slot1 對應的正是address c 這個地址存儲的位置。
總結:當使用delegatecall 函數進行外部調用涉及到storage 變量的修改時是根據插槽位置來修改的而不是變量名。
漏洞示例
看了前置知識相信大家對delegatecall 有一定的了解了,下面我們來結合合約代碼來模擬真實的攻擊場景:
// SPDX-License-Identifier: MITpragma solidity ^0.8.13;
contract Lib { address public owner;
function pwn() public { owner = msg.sender; }}
contract HackMe { address public owner; Lib public lib;
constructor(Lib _lib) { owner = msg.sender; lib = Lib(_lib); }
fallback() external payable { address(lib).delegatecall(msg.data); }}
漏洞分析
我們可以看到有兩個合約,Lib 合約中只有一個pwn 函數用來修改合約的owner,在HackMe 合約中存在fallback 函數,fallback 函數的內容是使用delegatecall 去調用Lib 合約中的函數。我們需要利用HackMe.fallback() 觸發delegatecall 函數去調用Lib.pwn() 將HackMe 合約中的owner 改成自己。
攻擊合約
// SPDX-License-Identifier: MITpragma solidity ^0.8.13;
contract Attack { address public hackMe;
constructor(address _hackMe) { hackMe = _hackMe; }
function attack() public { hackMe.call(abi.encodeWithSignature(“pwn()”)); }}
我們來請出我們的老朋友受害者Alice 和攻擊者Eve 這兩個角色來分析下攻擊流程:
1. Alice 部署Lib 合約;
2. Alice 部署HackMe 合約並在構造函數中傳入Lib 合約的地址;
3. 攻擊者Eve 部署Attack 合約並在構造函數中傳入hackMe 地址;
4. 攻擊者Eve 調用attack 函數成功將HackMe 合約中的owner 改成自己。
我們先來回顧一下fallback 函數何時會被觸發調用?
1. 向某合約直接轉賬時(會觸發某合約中的fallack 函數)
2. 向某合約調用無法匹配到函數名的函數時(會觸發某合約中的fallack 函數)
現在我們來看看到底發生了什麼?
attack 函數首先去調用HackMe.pwn() ,發現HackMe 合約中並沒有pwn 函數,此時觸發HackMe.fallback() ,HackMe.fallback() 又使用deldegatecall 調用Lib 合約中的函數,函數名取得是msg.data 也就是”pwn()”,而Lib 合約中恰好有名為pwn 的函數,該函數的作用是將合約中的owner 修改為msg.sender。在前置知識中我們了解到delegatecall 函數的執行環境是調用者的環境,並且對於storage 變量的修改是根據被調用的合約的插槽位置來修改的。
簡而言之在HackMe 執行delegatecall 調用Lib.pwn() 後,相當於將Lib.pwn() 直接拿到HackMe 合約中執行了。 pwn 函數修改了Lib 合約中存儲位置為slot0 的變量owner,這樣HackMe 通過delegatecall 調用pwn 函數後也會修改HackMe 合約中存儲位置為slot0 的變量恰好也是owner 變量,這樣HackMe 合約中的owner 就成功的被攻擊者Eve 修改成自己了。
這個攻擊流程對初學者來說可能有點繞,但是理解了fallback 函數的觸發條件和delegatecall 函數的特徵也就好很多了。如果你覺得自己已經很了解delegatecall 函數的各種特點了可以期待下一篇文章:《智能合約安全審計入門篇之delegatecall (2)》。
修復建議
作為開發者
1. 在使用delegatecall 時應注意被調用合約的地址不能是可控的;
2. 在較為複雜的合約環境下需要注意變量的聲明順序以及存儲位置。因為使用delegatecall 進行外部調時會根據被調用合約的數據結構來用修改本合約相應slot 中存儲的數據,在數據結構發生變化時這可能會造成非預期的變量覆蓋。
作為審計者
1. 在審計過程中遇到合約中有使用delegatecall 時需要注意被調用的合約地址是否可控;
2. 當被調用合約中的函數存在修改storage 變量的情況時需要注意變量存儲插槽的位置,避免由於數據結構不一致而導致本合約中存儲的storage 變量被錯誤的覆蓋。
注:本文參考於《Solidity by Example》
參考鏈接:https://solidity-by-example.org/hacks/delegatecall
展開全文打開碳鏈價值APP 查看更多精彩資訊