从一道CTF题看智能合约的安全问题
作者:网友投稿 时间:2018-07-25 21:49
belluminarbank 是俄罗斯战队在 WCTF 上出的一道 EVM 题目,其中用到了很多 ETH 中经典的漏洞。虽然难度都不是很大,但是如果对 EVM 相关特性不了解的话还是有一定难度的,本文以这个题目为例详细介绍一下 ETH 智能合约中的安全性问题 。
题目是一个存储交易类合约,用户可以通过给合约发送 ether 实现将 ether 存储在合约中的目的。攻击者的目标就是将存储在这个合约里的所有 ether 全部取走。
合约的代码如下 :
pragma solidity ^0.4.23;contract BelluminarBank { struct Investment { uint256 amount; uint256 deposit_term; address owner; } Investment[] balances; uint256 head; address private owner; bytes16 private secret; function BelluminarBank(bytes16 _secret, uint256 deposit_term) public { secret = _secret; owner = msg.sender; if(msg.value > 0) { balances.push(Investment(msg.value, deposit_term, msg.sender)); } } function bankBalance() public view returns (uint256) { return address(this).balance; } function invest(uint256 account, uint256 deposit_term) public payable { if (account >= head && account < balances.length) { Investment storage investment = balances[account]; investment.amount += msg.value; } else { if(balances.length > 0) { require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years); } investment.amount = msg.value; investment.deposit_term = deposit_term; investment.owner = msg.sender; balances.push(investment); } } function withdraw(uint256 account) public { require(now >= balances[account].deposit_term); require(msg.sender == balances[account].owner); msg.sender.transfer(balances[account].amount); } function confiscate(uint256 account, bytes16 _secret) public { require(msg.sender == owner); require(secret == _secret); require(now >= balances[account].deposit_term + 1 years); uint256 total = 0; for (uint256 i = head; i <= account; i++) { total += balances[i].amount; delete balances[i]; } head = account + 1; msg.sender.transfer(total); } }
可以看到合约的代码逻辑非常简单,invest 函数负责将传入的 ether 保存进 bank;withdraw函数负责取出保存的 ether;confiscate 函数可以将某些用户的账户全部转走。简单分析可以看出攻击者最终可以通过函数confiscate将所有的 ether 都转走。
经典的 Integer OverFLow想要调用函数confiscate需要满足几个 require 条件,首先的一个就是要求require(now >= balances[account].deposit_term + 1 years); 调用时间必须在设定的 deposit_term 一年之后。这个条件没有对溢出进行判断,可以通过传入一个超长的 deposit_term 绕过判断。
这里展示了一个智能合约中典型的整数溢出问题。
EVM 使用的存储单位 uint256,即其中的栈、storage、memory 都是以 0×20 个字节为单位存储的,其可以表示的数据范围为 0 到 2^256 ,0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 可以看的其能够表示的范围是很大的,但是即使是这么大的范围依然存在溢出的可能性,当运算操作的结果大于 0×20 byte 所能表示的范围时,就会造成溢出导致判断失败。
在这个例子中 1 year 是 Solidity 语法中的时间单位,可以通过下面的单位换算转换成 uint :
1 years == 31536000
1 == 1 seconds
1 minutes == 60 seconds
1 hours == 60 minutes
1 days == 24 hours
1 weeks == 7 days
1 years == 365 days
如果我们将 deposit_term 设置为 超过0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF - 31536000 那么在计算 require(now >= balances[account].deposit_term + 1 years); 时就会产生溢出,导致加法运算的结果很小从而可以通过检测。
整数溢出问题在智能合约当中非常常见,合约的编写者或由于考虑不周或由于编写失误导致合约计算产生问题,进而影响整个合约。一个著名的例子 。
为了应对这问题有很多合约使用 SafeMath 来进行运算操作,一旦在运算过程中产生溢出便会回滚。但是依然有很多合约没有使用 SafeMath 或者使用了 SafeMath 但是忽略了某些操作。
通过了时间检查之后,合约的调用还需要满足 require(secret == _secret); 即传入的 secret 值需要与合约中设定的一个值相同。这个值在合约初始化时设定,属性为 private。
在传统的编程语言中我们习惯于使用public和 private来区分类中对象的可见性,一般而言声明为 private 的对象是外部不可见的。智能合约中同样沿用了这一规定,在智能合约给外部提供的接口中确实没有 private 对象的访问路径。但是智能合约与其他传统可执行程序不同,合约本身连同其所有数据(storage) 都是保存在区块上的,而区块对所有人可见,这也就意味着即使是 private 对象在区块上也是可见的(实际上对于传统可执行程序而言,private 属性对象在内存中也是可见的)。
在本例中,_secret是一个全局对象,又称为 StateVarible ,其存储位置为 storage,和合约一起位于区块中。在调试合约时查看合约的 storage ,可以清晰的看到合约存储情况,在 slot 3 位置保存着我们初始化合约时填入的 secret 值
如果合约的编写者对 private 的了解存在误区,将合约的某些敏感功能依赖于 private 对象,那么就有可能出现权限问题。
小心 Storage Pointer


