在前文中,我们简单提及了“智能合约”的概念以及其安全属性。之后针对一些严重的事件展开合约安全的深入分析。在本文中,我们详细的介绍一下智能合约的概念,并且为大家讲述一些关于智能合约方面的攻击事例。并针对相关事例列举防御手段。
而我们知道智能合约的漏洞常存在于以太坊的Solidity中,所以本文中会有大量的合约代码分析。我也会为大家讲述相关合约分析流程。
本文为原创稿件,如有疑问大家可以在下方留言。
智能合约是20世纪90年代被提出的一个概念。由于缺少可信的执行环境,所以智能合约并没有被应用到实际的产业中。自比特币诞生以后,人们逐渐认识到区块链是天生的可以为智能合约提供可信执行环境的平台。简单地说,智能合约是代码与数据的集合。其被部署在区块链的某个具体地址中。智能合约类似于区块链平台中的一个自动化机器人代理,它有自己的账户并可以在某些时间与事件的驱使下自动执行一些事情。例如可以传递信息、修改区块链状态等。
“智能合约”的程序不仅仅是一个可以自动执行的计算机程序,它可以对接收到的信息进行回应。作为一个系统必不可少的参与者,其可以接收和存储价值并向外部发送信息。以太坊的智能合约是以太坊特定的字节码,我们称为EVM字节码。
不同的区块链项目使用不同的程序语言作为其智能合约的编程语言。R3的Corda使用的是Java;HyperLedger Fabric使用Java、Go开发其ChainCode链码。而根据大量的文献参考,我发现现在合约的问题多自以太坊的Solidity,所以我们在这里多讲述一下相关内容。
以太坊智能合约默认的编程语言是Solidity,智能合约的运行环境是以太坊虚拟机(EVM)。在报智能合约部署到以太坊网络之前需要先将智能合约编译成字节码,然后以交易的形式将智能合约的字节码发送到以太坊网络中,以太坊会为每一个智能合约创建一个合约账户并保存于此。
DApp全程Decentralized APP,是以太坊中基于智能合约应用的去中心化的应用程序。DApp的目标是让智能合约有一个友好的界面。DApp可以在以太以太坊节点交互的中心化服务器上运行,也可以在任意一个以太坊平等节点上运行。
然而DApp不能在普通的服务器上运行,其需要提交交易到区块链并且从区块链中提取重要数据。对于用户登录系统,用户很有可能被表示成一个“钱包”地址,而其他用户数据被保存到本地。
下面交代一下其流程:
1用Solidity编写智能合约。
2用solc编译器将合约编译为EVM字节码。
3编译好的字节码发送到DApp前端。
4前端将合约部署到区块链中。
5区块链返回智能合约地址。
6前端通过地址+ABI+nonce调用只能合约。
7智能合约开始处理。
以太坊虚拟机(EVM)对于合约能够做的事情存在许多硬性的限制。为了平台安全的考虑,许多函数都有其潜在的内容,如果不了解其用法就盲目的使用有可能会带来十分严重的安全隐患。
例如在以太坊的Solidity中有unit8变量,而uint8的存储范围为0 ~ 255,而int8的范围为-127 ~ 127。
而我们下面看一组代码:
contract Baduse
{
address[] employees;
function pay()
{
for(var i = 0 ; i < employees.length; i++)
{
address employee = employees[i];
uint bonus = calculatebonus(employee);
employee.send(bonus);
}
}
function calculateBonus(address employee) return (uint)
{
funcs;//许多花费巨大的计算
}
}
我们大致阅读下上述代码,我们发现代码含义为传入employees地址,并且放入到相关地址数组中,之后循环send相应的数值。
然而不只读者有没有发现,作为智能合约其中潜在了三个问题。这里介绍第一种。
这里存在一个陷阱,是一些编译器微妙的情况还没有告诉你。
首先我们需要知道,在for (var i = 0; i < arrayName.length; i++) { ... }, i的类型是uint8,因为这是存放值0最小的类型。如果数组元素超过255个,则循环将不会终止,知道Gas用尽。
所以上述代码中倘若地址数组中的数量超过了255,那么用户就会发现自己的合约莫名其妙的一直处于运行状态,到了最后Gas被耗尽。
而要解决上述的问题,我们需要将var变量修改为nint,因为uint8是保持值为0所需的最小类型。而我们需要将函数修改为如下:
contract Baduse
{
address[] employees;
function pay()
{
for(uint i = 0 ; i < employees.length; i++)//在此处修改uint
{
address employee = employees[i];
uint bonus = calculatebonus(employee);
employee.send(bonus);
}
}
function calculateBonus(address employee) return (uint)
{
funcs;//许多花费巨大的计算
}
}
根据往期文章中所写的内容,我们讲述过在以太坊中为了防止恶意者的作恶攻击,我们特意设立了Gas机制。而上述的代码中同样存在了一些不完善的情况导致用户Gas用尽的情况出现。
我们在代码中可以关注下面的语句:
uint bonus = calculatebonus(employee);
employee.send(bonus);
calculatebonus()
函数在一些复杂的基础计算中会对每一位员工所获得的奖金进行计算,然而就想计算项目的利润,这会花费用户大量的Gas。而用户的Gas是有限的,所以势必会有一天Gas被快速消耗。如果Gas达到消耗的限制,那么所有的变化将会被修复,但是Gas费用仍然无法退回。所以每每我们发现自己的代码中存在循环时,我们应该注意可变的Gas成本问题。
对于此类问题,我们可以通过将奖金计算从for循环中分离出来并进行优化。
contract Baduse
{
address[] employees;
mapping(address => uint) bonuses;
function pay()
{
for(uint i = 0 ; i < employees.length; i++)
{
address employee = employees[i];
uint bonus = calculatebonus(employee);
employee.send(bonus);
//直接调用send函数,并没有进行检查验证
}
}
function calculateBonus(address employee) return (uint)
{
uint bonus = 0;//可以手动对此进行调控
bonuses[employee] = bonus;
//修改奖金的额度
}
}
调用深度(call depth)被限制为 1024。EVM 中一个智能合约可以通过 message call 调用其它智能合约,被调用的智能合约可以继续通过 message call 再调用其它合约,甚至是再调用回来(recursive)。
这意味着如果嵌套调用的数量达到1024,合约将会失败。攻击者可以递归调用一个合约1023次,然后调用开发者合约函数,造成“send”因为这个限制而失败。
例如下面的代码:
function sendether() {
address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b;
addr.send(20 ether);
//用户会认为无论如何此函数都会被处理
var thesendok = true;
//用户会认为无论如何都会返回true
...
}
之后我们也初始化fallback函数:
function fallback() {
//fallback函数内容
}
至此用户会认为我们既然已经定义了fallback函数,那么应该就万无一失了。然而我们看下面的攻击代码:
function hack() {
var count = 0;
while (count < 1023) {
this.hack();
count++;
}
if (count == 1023) {
thecallingaddr.call("sendether");
}
}
此处攻击者利用了1024这个限度,当嵌套调用数量执行到1024个的时候,攻击者调用了sendether()
函数,并使系统在执行到send的时候失败。
这也为我们写合约代码的时候提了一个醒。那我们如何更改相关内容呢?
正确的写法应该是在每次涉及到 call depth 增加的地方都检查调用返回是否正确,如下:
function sendether() {
address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b;
if (!addr.send(20 ether)) {
throw;
//防止有人攻击,提前进行检查
}
var thesendok = true;
...
}
我们知道,一笔交易在被传播出去之前需要经过矿工的同意并将其记账在一个区块内。而我们知道倘若一个攻击者在监听到网络中对应合约的交易后发送一个新的合约交易来改变当前的合约状态,那么系统中会因为交易的先后问题而产生安全隐患。例如悬赏相关的合约,倘若两个悬赏合约放出,第二个合约减少了合约的回报,则定几率使这两笔交易包含在同一个区块下面,并且使修改过的那个合约由于某种原因先进行记账处理。这意味着,如果某个用户正在揭示拼图或其他有价值的秘密的解决方案,恶意用户可以窃取解决方案并以较高的费用复制其交易,以抢占原始解决方案。
对于此类问题,我们提出一种基本的攻击模型:
1 攻击者可以提出一个有奖竞猜合约,而此合约的目的是让用户参与某个问题的求解,并承诺给找到解的用户发放丰厚的奖励。
2 当合约提交后攻击者开始监听整个网络,并观察是否有人提及相关问题的解。
3 当有人提交答案后,由于交易还未被记账(存在时间差)所以攻击者可以立刻发起一个新的交易以降低奖金的数额使之无限接近0。
4 此时攻击者增大了第二次发送的交易的gas值,所以此交易会被优先处理。
-5 矿工将第二个交易优先处理后,答案提交者所获得的奖励将变得极低,攻击者就能几乎免费的获得正确答案。
ERC-20 标准是在2015年11月份推出的,使用这种规则的代币,表现出一种通用的和可预测的方式。简单地说,任何 ERC-20 代币都能立即兼容以太坊钱包(几乎所有支持以太币的钱包,包括Jaxx、MEW、imToken等,也支持 erc-20的代币),由于交易所已经知道这些代币是如何操作的,它们可以很容易地整合这些代币。这就意味着,在很多情况下,这些代币都是可以立即进行交易的。
ERC20 让以太坊区块链上的其他智能合约和去中心化应用之间无缝交互。一些具有部分但非所有ERC20标准功能的代币被认为是部分 ERC20兼容,这还要视其具体缺失的功能而定,但总体是它们仍然很容易与外部交互。
因此ERC-20协议是目前数字货币交易体系中较为主流的一种协议体系。但是该协议也存在不完善的地方。正如同清扫房间,总会有没有看到的地方一样,智能合约的协议只能不断地根据漏洞来改进,却不能一劳永逸的解决所有漏洞。黑客们也正是利用这些漏洞来实现自己的目的。
详细概念请参考ERC-20
我们在这里简单地给大家讲解一下某次ERC-20标准下的真实顺序交易攻击案例。
ERC20标准中定义,approve(address _spender,uint256 _value):允许_spender提取限额_value的代币
。
ERC-20代币合约内置了很多函数,允许用户查找账户的余额,以及通过各种各样的合约从一个用户转移到其他用户中。
approve()
这个函数通过两个步骤实现从一个用户发送给其他用户token。第一步token拥有者给另一个地址(通常都是智能合约的地址)批准转出一个最大上限的token,也就是额限。token拥有者使用approve()
函数提供这个信息。
然而这个漏洞是由于这个approve()功能而产生的。
假设我们现在存在两种用户A与B,而B是攻击者,A是受害者。
1 A允许B通过调用approve()
方法传输N个A的token值。
2 过了一段时间,A发现他需要改变对B的传输token值的个数,从N到M。之后她再次调用approve()
方法并在这个时间传递给B的地址和M作为方法参数。
3 B发现此消息后快速发送A的第二笔交易调用方法转移N个A的token的事件(使第一次事件的执行时间比第二次提前)。
4 如果B的交易将在A交易之前执行,那么B将会执行成功转移N个A的代币并获得转移另一次M个代币的能力。
5 在A注意到出现问题之前,B调用了transferFrom方法,这次转移M个A的代币。
1 http://finance.sina.com.cn/blockchain/coin/2018-04-26/doc-ifztkpin0534100.shtml
3 https://blog.csdn.net/xuguangyuansh/article/details/81329671
4 https://blog.csdn.net/xuguangyuansh/article/details/83416560
5 https://blog.csdn.net/weixin_42451205/article/details/80966049
6 http://wiki.jikexueyuan.com/project/solidity-zh/miscellaneous.html
本稿为原创稿件,转载请标明出处。谢谢。