基于字节码关键路径的智能合约漏洞检测
2022-03-11印桂生高乐庄园李俊
印桂生, 高乐, 庄园, 李俊
(1.哈尔滨工程大学 计算机科学与技术学院,黑龙江 哈尔滨 150001;2.国家工业信息安全发展研究中心, 北京 100040)
自2015年全球性去中心化应用平台以太坊[1]成立,以智能合约为基础的去中心化应用高速发展。智能合约被定义为一组具有特定规则的数字化协议[2],是运行在区块链网络上的应用程序,其主要编写语言为Solidity,被编译后以字节码形式在以太坊虚拟机(ethereum virtual machine,EVM)[3]中存储执行。随着区块链应用的广泛普及,智能合约被发现存在多种漏洞,导致了多起因合约漏洞被攻击的事件,造成数千万美元的损失[4]。例如2016年的去中心化自治组织(decentralized autonomous organization,DAO)[5]安全漏洞事件,区块链业界众筹项目The DAO被攻击,黑客利用合约代码中的可重入漏洞盗取资金池中的资产,导致360万以太币流失,市值6 000万美元。2017年,黑客利用Parity[6]多重签名钱包合约中的委托调用漏洞,获取钱包地址的所有权并转移内部资产,导致价值上亿美元资金被冻结。案例表明,智能合约若自身存在漏洞隐患,会严重威胁用户信息和财产安全,甚至造成难以预估的损失。面对智能合约存在的安全问题,研究高效的合约漏洞检测方法具有重要意义。
目前,智能合约漏洞检测的主要方法有形式化验证、符号执行、模糊测试、污点分析等[7]。形式化验证是通过数学推理逻辑和证明,检查代码功能正确性和属性的安全性[8],保证一定范围内的绝对正确,但需要人工参与建模和推理过程,效率较低。符号执行的核心思想是使用符号值代替具体的执行程序[9],此方法能够减少测试用例集达实现高覆盖率,但也会出现路径爆炸的情况。模糊测试是一种通过构造非预期的输入数据并监视程序运行异常结果的软件故障识别方法[10],其优点在于测试速度快,消耗低,缺点在于所能涵盖的系统行为有限,无法达到理想的路径覆盖率。这些检测方法各有优势,但仍存在如下问题:1)目前近30%的智能合约没有源码[11], 而大部分智能合约漏洞检测工具只支持合约源码的检测,面对没有源码或只有字节码的合约无法实现漏洞检测;2)现有静态检测[12]方法只针对单一函数调用行为进行建模,没有构建和分析合约的整体执行流程;3)目前检测方法多依赖于有限的专家规则,漏洞定义相对简单,导致检测结果的真阳性比例较低[13]。
针对上述问题,本文工作引入关键指令概念,提出一种基于关键路径的漏洞检测方法。该方法面向智能合约的二进制代码,为不同漏洞定义关键指令及其检测规则,通过构建合约控制流程图,生成基于关键指令的执行路径,采用规则匹配模型完成漏洞识别,有效地提高了智能合约安全检测的准确度。
1 智能合约漏洞分析
随着智能合约的广泛应用,关于智能合约安全问题的研究也日益重要。NCC Group[14]总结出10种出现频率较高的智能合约安全漏洞,分别为: 可重入、委托调用、时间戳依赖、Gas耗尽终止、访问控制、未严格判断不安全函数调用返回值、拒绝服务、可预测的随机处理、短地址攻击以及整数溢出。本文主要针对可重入、委托调用、时间戳依赖这3种最高频的漏洞类型进行分析,将与其漏洞特征相关的EVM底层指令定义为关键指令,并根据不同漏洞的关键指令提出相应的检测规则。
1.1 可重入漏洞
智能合约的重要特点是调用外部合约函数,通过外部调用,合约完成转账(即发送以太币给外部账户)。若外部调用操作不当,极其容易被攻击者利用,通过回退函数或者回调攻击合约自身来盗取以太币,从而造成用户的损失[15]。回退函数没有参数和返回值,在合约的调用中,如果没有其他函数与给定的函数标识符匹配,那么回退函数就会执行。此外,每当合约收到以太币但没有任何附带数据时,回退函数也会执行。攻击者可通过编写攻击合约,调用受害合约,利用攻击合约的回退函数,循环调用受害合约的代码。可重入漏洞攻击往往是外部调用被攻击者劫持,迫使合约进一步执行代码,通过回退函数再次调用回退函数本身,最终实现单次或多次重入攻击。DAO事件就是这类攻击的典型例子。
可重入漏洞特点:在Solidity中一般有3种转账方法,分别是transfer()、send()、call()。由于以太坊执行交易需要收取一定数量的费用(简称Gas[16]),当合约调用call()函数进行转账时,所有可用Gas会被传递,这使得攻击者能够多次回调受害合约。当某函数通过一系列调用调回自身时,此时可能发生可重入漏洞。针对可重入漏洞,本文定义关键指令如表1所示。
表1 可重入漏洞关键指令Table 1 Key instructions for reentrancy
根据上述关键指令,可重入漏洞的检测规则具体定义为:
1)对于一个函数A,检查函数调用A是否在源自调用A的调用链中出现了不止一次。即检查EVM底层的CALL指令调用链;
2)检查函数中存在call()调用且满足value>0且Gas足够多。即检查CALL指令的第1个堆栈参数Gas以及第3个堆栈参数value;
3)资产记录的改变,在实际转账后。即检查算数逻辑指令与CALL指令出现的先后顺序。
1.2 委托调用漏洞
Solidity中有2个常用的内置变量msg.sender和msg.data,前者表示合约调用者的地址,后者表示调用者传入的数据。此外,Solidity中调用其他合约的方法,除了call()外,通过委托调用delegatecall()方法也可以实现智能合约之间的交互。与call()调用不同,delegatecall调用会修改调用者的存储,且在其调用后msg.sender的值一直为原调用者的地址[17]。攻击者通过自身合约的上下文环境调用其他合约的代码,当delegatecall的参数设置为msg.data时,攻击者一般通过构造msg.data,实现调用受害合约的任何函数。正是由于这种委托漏洞,Parity合约损失了价值3 000万美元的以太币。
委托调用漏洞特点:当合约中存在委托调用,且委托调用的调用地址和调用字符序列由调用者传入时(例如delegatecall的参数为msg.data),就会产生委托调用漏洞。针对委托调用漏洞,本文定义关键指令,如表2所示。
表2 委托调用漏洞关键指令Table 2 Key instructions for delegatecall
根据上述关键指令,委托调用漏洞的检测规则具体定义如下:
1)检查在当前合约的执行过程中是否存delegatecall()调用,即检查是否存在DELEGATECALL和SELFDESTRUCT指令;
2)检查delegatecall()的调用地用的字符序列是否由调用者传入,即检查DELEGATECALL指令的参数中是否存在CALLDATALOAD以及CALLVALUE。
1.3 时间戳依赖漏洞
时间戳是一个唯一标识某一刻的时间字符序列[18]。以太坊中,区块时间戳有很多用途,例如生成随机数或用于条件语句中作为时间变量的判断条件。然而,当调用转账函数且函数中条件判断中存在时间戳引用时,矿工通过修改时间戳,可以产生特定需求的随机数,并且可通过对时间戳的控制,满足有利于自身的条件,进而损害其他用户的利益。
时间戳依赖漏洞特点:当调用转账函数时(transfer()、send()、call()),合约中的时间戳引用极易被恶意矿工所利用。为此,针对委托调用漏洞,本文定义关键指令如表3所示:
表3 时间戳依赖漏洞关键指令Table 3 Key instructions for timestamp
根据上述关键指令,时间戳依赖漏洞的检测规则具体定义为:
1)检查合约或函数中是否有block.number、now、block.timestamp等时间戳操作,即是否存在TIMESTAMP以及NUMBER指令;
2)检查函数是否调用了send()或transfer()。即检查是否存在CALL指令,且GAS≤2 300。
3)检查函数存在call()调用且value>0。即检查是否存在CALL指令,且第3个参数value>0。
2 基于关键路径的合约字节码漏洞检测
目前,现有漏洞检测方法主要面向智能合约的源代码,仅有少数工作支持二进制代码的安全检测[19],这些方法没有深入挖掘漏洞与EVM底层指令间的关系,导致真阳性比例较低。为解决此问题,本文提出一种基于关键路径的智能合约漏洞检测方法,根据漏洞特征为不同漏洞定义相应的关键指令,生成可用于规则匹配的关键路径,实现对智能合约字节码高效的漏洞检测。具体方案流程如图1所示。首先,将合约二进制代码反编译生成合约控制流图(control flow graph,CFG);其次,根据关键指令生成关键执行路径;最后,采用匹配规则方法对关键路径进行漏洞判断。
图1 工作流程Fig.1 The overall workflow graph
2.1 合约控制流图构建
为了更好地表示智能合约的二进制代码,采用由数据依赖关系和控制关系组成的控制流程图对其进行表示。构建CFG首先反编译合约的字节码,生成EVM指令及参数,EVM中常用指令如表4所示。
表4 EVM常用指令举例Table 4 Examples of common instructions
CFG是由指令及其参数构成的基础块组成,其中每个基础块以非跳转指令开头,以跳转或终止指令(如STOP、JUMP、JUMPI、RETURN、REVERT、SELFDESTRUCT等)作为结束。合约二进制代码先反编译生成基础块,再根据各个基础块的跳转关系,即解析基础块中的跳转指令(JUMP和JUMPI)指令,将基础块连接起来,形成目标合约的控制流程图。
如图2所示,构建CFG首先找到基础块间明显的跳转关系,例如,在基础块162和基础块694中找到2组跳转指令PUSH2 0x2f2和JUMP,表示跳转到地址0x2f2,即基础块754,它将基础块162、基础块694、基础块754构成一个CFG子图。同时,尚未计算的跳转指令(JUMP和JUMPI)被标记为未解析状态。其次,选择一个CFG子图中未解析的跳转指令,推断其跳转目标的逆向指令集,执行该指令以计算跳转目标,并将该指令标记为已解析状态,最后添加到CFG中。由于新引入的跳转关系可能导致构建的CFG子图出现新的跳转指令,所以该子图中跳转指令都需再次标记为未解析状态,重复此过程,直到所有跳转指令都被标记为已解析状态。
图2 CFG构建Fig.2 The CFG construction phase
如图2所示,基础块162、基础块694、基础块754构成一个CFG子图,当执行到基础块754中最后一行的JUMP指令,出现2个逆向指令,分别为基础块694和基础块162中的2个PUSH2指令,说明此时基础块754引入2个新的跳转关系:基础块754→基础块1435、基础块754→基础块1456,最终形成新控制流程图。
2.2 关键路径生成
对智能合约而言,其漏洞特点可以通过EVM关键指令体现,如果攻击者利用漏洞进行合约攻击,那么会执行一条或多条含有关键指令的路径来完成。将关键路径定义为包含关键指令的执行路径,若关键路径与给定的某种漏洞规则匹配,则表明存在此漏洞风险。
对CFG的路径探索是典型的静态路网中求解路径的问题,本文采用A*算法探索路径[20],其中路径代价定义为该路径在CFG中遍历的分支数。基本思路是从每类漏洞的关键指令集选择一个指令,利用A*算法进行路径探索,每一步执行后,检查是否仍然可以从当前路径访问关键指令集中至少一个其他剩余的指令,如果无法访问,则放弃对该路径的进一步探索,路径生成示意图如图3所示。
图3 路径生成示意Fig.3 The critical-path generation phase
2.3 规则匹配
给定关键路径后,即生成初步执行路径集合,将每条路径进行规则匹配。此过程需要解析路径中每个指令及其参数,通过匹配相应的漏洞规则,给出最终检测结果。
1)可重入漏洞规则匹配。
依据可重入漏洞检测规则,若要对初步生成的路径进行规则匹配,需要解析路径中的CALL以及算数运算指令。其次,检查路径中CALL指令调用链,并检查CALL指令的第1个堆栈参数Gas是否大于2 300以及第3个堆栈参数value是否大于0。同时,检查算数逻辑指令是否出现在CALL指令之后。若满足以上规则,则提取该路径作为关键路径。
2)委托调用漏洞规则匹配。
对委托调用漏洞进行规则匹配,需要解析路径中的DELEGATECALL等指令,即检查路径中是否存在DELEGATECALL指令且DELEGATECALL指令的参数中是否存在CALLDATALOAD以及CALLVALUE读取的数据。若满足以上规则,则提取该路径作为关键路径。
3)时间戳依赖漏洞规则匹配。
对时间戳依赖漏洞进行规则匹配,需要解析路径中的TIMESTAMP等指令。即检查路径中是否存在TIMESTAMP以及NUMBER指令;同时检查检查是否存在CALL指令,且GAS≤2 300。最后检查是否存在CALL指令,且第3个参数value>0。若满足以上规则,则提取该路径作为关键路径。
对漏洞进行规则匹配需要解析指令及参数,即对EVM指令进行符号化建模,完成EVM指令到符号表达式的转换。EVM中有2类指令,一类是数据长度是固定值的指令,例如CALL等;另一类是长度可变的指令,例如CALLDATACOPY等。使用Z3[21]约束求解器对指令进行建模,具体过程如下:
1)对固定数据长度的指令建模,通过Z3的位向量为:
α′m[retOffset+i]←BitVector('instruction_name+i',8)
(1)
式中:αm表示内存存储;BitVector即Z3的位向量表达式;retOffset为指令返回数据的内存地址;instruction_name是指令的名称;i为数据长度,从0一直循环到需读取的数据的总长度。每一次循环,从内存中读取固定8 bit的数据,直到循环结束。
2)对可变数据长度指令建模,通过Z3的If表达式来表示:
α′m[destoffset+i]←If(i i],αm[destoffset+i]) (2) 式中:EI为当前指令执行的符号环境;destOffset、offset、length为可变长指令的数据地址,源数据地址,数据长度。If即Z3的If表达式,i为复制数据的长度,从0循环到length。 综上,本文提出了基于关键路径的智能合约漏洞检测方法,在仅给定字节码的情况下检测合约是否存在可重入漏洞、委托调用漏洞以及时间戳依赖漏洞,依据漏洞特点为不同类别的漏洞定义关键指令及规则,构建CFG分析合约执行路径,通过规则匹配完成漏洞检测。 本文使用PC作为实验环境,使用python语言实现提出的方法,具体操作系统及软件配置见表5。从以太坊网站上获取8 000份真实智能合约及其字节码进行验证,实验结果表明本文方法具有高效的漏洞检测能力。如表6所示,在294个真实漏洞中成功检测到244个漏洞,包括15个可重入漏洞,9个委托调用漏洞以及220个时间戳依赖漏洞。 表5 实验环境Table 5 Experimental environment configuration 表6 漏洞检测结果Table 6 Vulnerability detection results 1)可重入漏洞。 在可重入漏洞方面,本文方法充分挖掘了可重入漏洞的底层特点,在8 000个合约中成功检测到15个可重入漏洞(共有16个可重入漏洞),人工检测证明仅有1个假阴性结果(即1份可重入漏洞合约没有检测到),检测准确率高达93.75%。 2)委托调用漏洞。 在委托调用漏洞方面,本文方法成功检测到9个漏洞(共有11个漏洞),其中8个真阳性结果和1个假阳性结果。此外,出现3个假阴性结果,检测准确率为72.72%。存在假阴性结果是因为在委托调用过程中,有些合约的调用链过长,本文方法没有捕捉到这种过长的调用关系。 3)时间戳依赖漏洞。 在时间戳依赖漏洞方面,本文方法成功检测到220个漏洞(共有267个漏洞),其中218个真阳性结果和2个假阳性结果,检测准确率为81.65%。此外,出现49个假阴性结果,原因在于本文定义时间戳依赖漏洞的检测规则时,没能充分考虑与时间戳有关数据调用链,会在后续工作中进一步改进。 最后,与2种现有方法进行比较:模糊测试方法ContractFuzzer[22]和静态符号执行方法Oyente[23],本文方法整体表现更优,如表7所示,由于Oyente不支持对委托调用漏洞的检测,所以相应结果用“—”表示。 表7 与ContractFuzzer和Oyente对比结果Table 7 Comparison with ContractFuzzer and Oyente 由表7可知,针对可重入漏洞和时间戳依赖漏洞,本文方法的准确度明显高于对比方法,原因在于这两类漏洞的特点比较明显,且调用链数量小,相对于ContractFuzzer与Oyente定义的复杂检测规则,基于关键路径的检测方法具备更高效的漏洞定位能力。 1)定义与漏洞相关的关键指令及检测规则,通过反编译字节码构建控制流图,基于符号表达式建模,对关键执行路径进行规则匹配并完成漏洞检测。 2)本文采用以太坊上真实智能合约数据进行实验,结果表明本文方法优于智能合约现有漏洞检测工具,准确度提升近10%。 未来的研究主要考虑结合动静态方法的漏洞检测技术,例如形式化验证与模糊匹配方法相结合,亦可使用机器学习或深度学习实现智能合约的漏洞检测。3 实验结果
4 结论