安全问题:
- 函数可重入性
- 超出调用栈深度
- 跨函数的竞态条件
- 交易顺序依赖与非法预先交易导致的漏洞
- 时间戳依赖
- 整数的上溢和下溢导致的漏洞
- 存储操作中的深度下溢
- 利用交易失败,促使意外恢复
- 利用区块燃料上限引发漏洞
- 强行给智能合约中加入以太币,引发程序逻辑漏洞
安全问题
漏洞一:函数可重入性
竞态条件(Race Condition):计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。
可重入性(Reentrancy)一般可以理解为一个函数在同时多次调用,例如操作系统在进程调度过程中,或者单片机、处理器等的中断的时候会发生重入的现象。
这个漏洞第一种可能出现的情况是:在调用其他函数的操作完成之前,这个被调的函数可能会多次执行。对这个函数不断的调用可能会造成极大的破坏。
它使用了 call ,默认发送所有剩余的 gas。因为用户的余额一直没有被置0,直到函数执行的结束。第二次(之后一次)调用其他函数的操作仍会成功,并且会一次一次地取消对账户余额的置0操作。The DAO事件中以太坊被盗就是因为攻击者执行了这样的操作。
在给出的示例中,为了避免碰到这个漏洞,我们的解决方案是:使用函数send()而不是函数call.value(),这将阻止任何外部代码的执行。
但使用send()有一些危险:如果调用堆栈深度为1024(这可以始终被强制调用),则传输失败,如果接收方耗尽gas,则传输失败。 默认情况下最好使用transfer(因为内置了执行失败的处理)。
解决方案:
1. 尽量避免外部调用
调用不受信任的外部合约可能会引发一系列意外的风险和错误。外部调用可能在其合约和它所依赖的其他合约内执行恶意代码。因此,每一个外部调用都会有潜在的安全威胁,尽可能的从你的智能合约内移除外部调用。
2. 仔细权衡“send()”、“transfer()”、以及“call.value()”
当发送Ether时,需要仔细权衡“someAddress.send()”、“someAddress.transfer()”、和“someAddress.call.value()()”之间的差别。
-
x.transfer(y)
和if (!x.send(y)) throw;
是等价的。send是transfer的底层实现,建议尽可能直接使用transfer。 -
someAddress.send()
和someAddress.transfer()
能保证可重入 安全 。 尽管这些智能合约方法可以被触发执行,但补贴给智能合约的2,300 gas仅仅只够捕获一个event。 -
someAddress.call.value()()
将会发送指定数量的Ether并且触发对应代码的执行。执行的代码被给予了账户所有可用的gas,通过这种方式发起的交易对于可重入来说是 不安全的。
使用send()
或transfer()
可以通过制定gas值来预防可重入, 但是这样做可能会导致在和合约调用fallback函数时出现问题,由于gas可能不足,而合约的fallback函数执行至少需要2,300 gas消耗。
一种被称为push 和pull的 机制试图来平衡两者, 在 push 部分使用send()
或transfer()
,在pull 部分使用call.value()()
。在需要对以太坊执行write
操作时使用send()
或transfer()
,read
操作使用call.value()
。
需要注意的是使用send()
或transfer()
进行转账并不能保证该智能合约本身重入安全,它仅仅只保证了这次转账操作时重入安全的。
3. 处理外部调用错误
Solidity提供了一系列在raw address上执行操作的底层方法,比如: address.call()
,address.callcode()
, address.delegatecall()
和address.send
。这些底层方法不会抛出异常,只是会在遇到错误时返回false。另一方面, contract calls 比如,ExternalContract.doSomething()
会自动传递异常,比如,doSomething()
抛出异常,那么ExternalContract.doSomething()
同样会进行throw
。
如果你选择使用底层方法,一定要检查返回值来对可能的错误进行处理。
// bad
someAddress.send(55);
someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn't check for result
someAddress.call.value(100)(bytes4(sha3("deposit()"))); // if deposit throws an exception, the raw call() will only return false and transaction will NOT be reverted
// good
if(!someAddress.send(55)) {
// Some failure code
}
ExternalContract(someAddress).deposit.value(100);
4. 不要假设你知道外部调用的控制流程
无论是使用raw calls 或是contract calls,如果这个ExternalContract
是不受信任的都应该假设存在恶意代码。即使ExternalContract
不包含恶意代码,但它所调用的其他合约代码可能会包含恶意代码。一个具体的危险例子便是恶意代码可能会劫持控制流程导致竞态。(浏览Race Conditions获取更多关于这个问题的讨论)
5. 对于外部合约优先使用pull 而不是push
外部调用可能会有意或无意的失败。为了最小化这些外部调用失败带来的损失,通常好的做法是将外部调用隔离到其内部的交易中,调用发起方只负责初始化外部调用。这种做法对付款操作尤为重要,比如让用户自己撤回资产而不是直接发送给他们。事先设置需要付给某一方的资产的值,表明接收方可以从当前账户撤回资金的额度,然后由接收方调用当前合约提现函数完成转账。(这种方法同时也避免了造成 gas limit相关问题。)
// bad
contract auction {
address highestBidder;
uint highestBid;
function bid() payable {
if (msg.value < highestBid) throw;
if (highestBidder != 0) {
if (!highestBidder.send(highestBid)) { // if this call consistently fails, no one else can bid
throw;
}
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
// good
contract auction {
address highestBidder;
uint highestBid;
mapping(address => uint) refunds;
function bid() payable external {
if (msg.value < highestBid) throw;
if (highestBidder != 0) {
refunds[highestBidder] += highestBid; // record the refund that this user can claim
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
if (!msg.sender.send(refund)) {
refunds[msg.sender] = refund; // reverting state because send failed
}
}
}
6. 标记不受信任的合约
当你自己的函数调用外部合约时,你的变量、方法、合约接口命名应该表明和他们可能是不安全的。
// bad
Bank.withdraw(100); // Unclear whether trusted or untrusted
function makeWithdrawal(uint amount) { // Isn't clear that this function is potentially unsafe
Bank.withdraw(amount);
}
// good
UntrustedBank.withdraw(100); // untrusted external call
TrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corp
function makeUntrustedWithdrawal(uint amount) {
UntrustedBank.withdraw(amount);
}
7. 使用assert()
强制不变性
当断言条件不满足时将触发断言保护 -- 比如不变的属性发生了变化。举个例子,代币在以太坊上的发行比例,在代币的发行合约里可以通过这种方式得到解决。断言保护经常需要和其他技术组合使用,比如当断言被触发时先挂起合约然后升级。(否则将一直触发断言,你将陷入僵局)
例如:
contract Token {
mapping(address => uint) public balanceOf;
uint public totalSupply;
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
totalSupply += msg.value;
assert(this.balance >= totalSupply);
}
}
注意断言保护 不是 严格意义的余额检测, 因为智能合约可以不通过deposit()
函数被 强制发送Ether!
8.正确使用assert()
和require()
在Solidity 0.4.10 中assert()
和require()
被加入。require(condition)
被用来验证用户的输入,如果条件不满足便会抛出异常,应当使用它验证所有用户的输入。 assert(condition)
在条件不满足也会抛出异常,但是最好只用于固定变量:内部错误或你的智能合约陷入无效的状态。遵循这些范例,使用分析工具来验证永远不会执行这些无效操作码:意味着代码中不存在任何不变量,并且代码已经正式验证。
漏洞二:超出调用栈深度
在以太坊中,一个合约调用另一个合约可以通过send指令或直接调用另一个合约的函数。然而在调用过程中可能会出现错误,调用的合约就会回退到之前的状态。使用 .send() 时如果超出调用栈 并不会 抛出异常,而是会返回 false。 低级的函数比如 .call(),.callcode() 和 .delegatecall() 也都是这样的。那么这个异常就可能无法很好地被调用者获知,这取决于调用方式。所以,通过send指令调用的合约应该通过检查返回值来验证合约是否被正确执行。
攻击方法举例:
有个名叫KingOfTheEtherThrone(KoET)的智能合约:用户可以通过一定数量的以太币成为“以太币国王”,支付的数额由现任国王决定。很显然,当前国王可以通过买卖国王获得利润。当一个用户声称为国王后,合约就发送赔偿金给现任国王,并指定这个用户为新的国王。然而,这个合约并没有检查支付赔偿金的交易的结果。 这样一旦合约在执行过程中产生了异常,现任国王就有可能同时失去王座和赔偿金。
可能的攻击方式就是敌手故意超出调用栈的大小限制。以太坊虚拟机规定调用栈的深度为1024。敌手在攻击之前,首先调用自身1023次,然后发送交易给KoET合约,这样就造成了合约的调用栈超出了限制,从而出现了错误。合约出错后,因为这个合约没有检查合约的返回值,那么如果合约在发送赔偿金给现任国王的过程中出现了异常,那么现任国王极有可能失去王座和赔偿金。
外部函数调用随时会失败,因为它们超过了调用栈的上限 1024。 在这种情况下,Solidity 会抛出一个异常。 恶意行为者也许能够在与你的合约交互之前强制将调用栈设置成一个比较高的值。
但是如果无法避免要调用外部函数时,防止这种攻击的下一个简便方法就是确保在你调用外部函数时已完成所有要执行的内部操作。
请注意,如果你有另一个函数也调用了withdrawBalance(),那么它也可能会受到相同的攻击,因此你必须将这种调用不可信合约的函数视为不可信函数,接下来我会进一步讨论潜在的解决方案。
漏洞三:跨函数的竞态条件
攻击者也可以对共享相同状态的两个不同函数进行类似的攻击。
在这种情况下,攻击者可以在代码执行到调用withdrawBalance()时调用transfer() 函数,由于他们的余额在此时还未被置0,所以即使他们已经收到退款,他们也还能转移通证,这个漏洞也被用在了The DAO事件中。
同样的原理,同样的注意事项。注意在这个例子中,这两个函数都是同一个智能合约的组成部分,同样的,当多个合约共享同一状态时,这几个合约之间也可能会出现这个漏洞。
由于竞态条件可能发生在多个函数之间,甚至是多个智能合约之间,所以旨在防止重入现象的解决方案都是明显不够的。
解决方案,这儿有两种解决方案,一是我们建议先完成所有的内部工作,然后再调用外部函数;二是使用互斥锁。
1.首先第一种解决方案,先完成所有的内部工作,然后再调用外部函数。如果你在编写智能合约时仔细地遵循这个规则,那么就可以避免出现竞态条件。但是,你不仅需要注意避免过早地调用外部函数,还要注意这个外部函数调用的外部函数,例如,下面的操作就是不安全的。
尽管函数getFirstWithdrawalBonus()不直接调用外部的合约,但在函数withdraw()中的调用足以使其进入竞态条件之中。因此,你需要将函数withdraw()视为不可信函数。
除了修复漏洞使这种重入现象变得不可能外,还要标记出不可信的函数。这种标记要注意一次次的调用关系,因为函数untrustedGetFirstWithdrawalBonus()调用了不可信函数untrustedWithdraw(),这意味着调用了一个外部的合约,因此你必须将函数untrustedGetFirstWithdrawalBonus()也列为不可信函数。
2.第二中解决方案是使用互斥锁。即让你“锁定”某些状态,后期只能由锁的所有者对这些状态进行更改,如下所示,这是一个简单的例子:
如果用户在第一次调用结束前尝试再次调用withdraw() 函数,那么这个锁定会阻止这个操作,从而使运行结果不受影响。这可能是一种有效的解决方案,但是当你要同时运行多个合约时,这种方案也会变得很棘手,以下是一个不安全的例子:
这种情况下攻击者可以调用函数getLock()锁定合约,然后不再调用函数releaseLock()解锁合约。如果他们这样做,那么合约将被永久锁定,并且永远不能做出进一步的更改。如果你使用互斥锁来防止竞态条件,你需要确保不会出现这种声明了锁定但永远没有解锁的情况。在编写智能合约时使用互斥锁还有很多其他的潜在风险,例如死锁或活锁。如果你决定采用这种方式,一定要大量阅读关于互斥锁的文献,避免“踩雷”。
有些人可能会反对使用竞态条件这个术语,因为以太坊并没有真正地实现并行性。然而,逻辑上不同的进程争夺资源的基本特征仍然存在,所以同样的漏洞和潜在的解决方案也同样适用。
漏洞四:交易顺序依赖与非法预先交易导致的漏洞
交易顺序依赖(Transaction-Ordering Dependence,TOD)
非法预先交易(Front Running)非法预先交易是经纪人从客户交易中获利的一种不道德做法。在手中持有客户交易委托的情况下抢先为自己的账户进行交易。
最常见的竞态条件为先检测后执行。执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题。
以下是区块链固有的不同类型的竞态条件:在区块内部,交易本身的顺序很容易受到人为操控。
由于在矿工挖矿时,每笔交易都会在内存池中待一段时间,因此可以想象到交易被打包进区块前会发生什么。对于去中心化的市场,可更改的交易顺序会带来很多的麻烦。比如市场上常见的买入某些代币的交易。而防范这一点十分地困难,因为它会涉及到合约中具体的实现细节。例如,在去中心化市场中,由于可以防止高频交易,故批量拍卖的效果更好。另一种解决方法就是采用预先提交方案的机制,别着急,后面我会详细介绍这个机制的细节。
交易顺序依赖就是智能合约的执行随着当前交易处理的顺序不同而产生差异。例如,有两个交易T和T[j],两个区块链状态S[1]和S[2],并且S[1]状态处理完交易T[j]后才能转化为状态S[2]。那么,如果矿工先处理交易T,交易T调用的就是S[1]状态下的智能合约;如果矿工先处理交易T[j]再处理交易T,那么由于先执行的是T[j],合约状态就转化为S[2],最终交易T执行的就是状态S[2]时的智能合约。
攻击方法举例
攻击者提交一个有奖竞猜合约,让用户找出这个问题的解,并允诺给予丰厚的奖励。攻击者提交完合约后就持续监听网络,如果有人提交了答案的解,此时提交答案的交易还未确认,那么攻击者就马上发起一个交易降低奖金的数额使之无限接近0。当矿工处理这两个交易时,当前交易池就有两个待确认交易:一个交易是提交答案,一个交易是更改奖金数额。如果矿工先处理的是敌手更改奖金的交易,而敌手可以通过增加交易费用让矿工先处理自己的交易,那么等到矿工处理提交答案的交易时,答案提交者所获得的奖励将变得极低,敌手就能几乎免费的获得正确答案。
漏洞五:时间戳依赖
矿工处理一个新的区块时,如果新的区块的时间戳大于上一个区块,并且时间戳之差小于900秒,那么这个新区块的时间戳就是合法的。这是以太坊协议所规定的。时间戳依赖顾名思义就是指智能合约的执行依赖当前区块的时间戳,随着时间戳的不同,合约的执行结果也有差别。
攻击方法举例:
如果有一个抽奖合约,要求由当前的时间戳和其它可提前获知的变量计算出一个“幸运数”,与“幸运数”相同的编码的参与者将获得奖品。那么矿工在挖矿过程中可以提前尝试不同的时间戳来计算好这个“幸运数”,从而将奖品送给自己想给的获奖者。
请注意,区块的时间戳可被矿工人为操纵,所以要留意时间戳的所有直接和间接使用。
类似的还有随机数的使用
如果不想让矿工作弊的话,在智能合约中使用随机数会很棘手 ,在智能合约中使用随机数很难保证节点不作弊, 这是因为智能合约中的随机数一般要依赖计算节点的本地时间得到, 而本地时间是可以被恶意节点伪造的,因此这种方法并不安全。 通行的做法是采用 链外off-chain 的第三方服务,比如 Oraclize 来获取随机数。
漏洞六:整数的上溢和下溢导致的漏洞
想象一个很简单的转移通证的场景:
如果你的账户余额达到了以太坊中最大的无符号整型值(2^256),那么你的余额再增加就无法表示了。因为平时遇到这种现象进位就可以了,但在这里无符号整型值只有256位,进位的第257位是不显示的,所以你没有猜错,当你进位后你的余额就会回到0。在计算机科学中这种现象就叫做整数的上溢。
当然了,这种现象也不太常见,因为它需要同时保证你真的有这么多余额,你的智能合约中还没考虑到上溢问题。考虑一下这个无符号整型值是否有机会达到这么大一个数字,再考虑一下这个无符号整型值如果改变当前数值,以及谁有权做出这样的改变。如果智能合约中任何用户都可以调用函数来更新这个无符号整型值,那么这个智能合约就会很容易受到攻击。如果只有管理员可以做出更改,那么它才可能是安全的。如果合约中规定用户的账户余额每次只能增加1,那么这个合约可能也很安全,因为现在还没有可行的方法让你短时间内达到这个限制。
账户余额达到最大时再增加就会被清零,你会瞬间从最富有的人变成最穷的人。不知你有没有想到可以从最穷的人变成最富有的人?没错,下溢也是这个道理,如果这个无符号整型值小于0,那么它需要向前借位,而借的那一位并不显示,所以你的余额就会下溢达到最大值。
看到这里,你一定要小心使用像8位,16位和24位的无符号整型值,因为8位无符号整型值最大仅可以表示255,所以相比之下它们更容易达到最大值而发生溢出现象。
Solidity中的整数安全场景
SMT 和 BEC 都是以太坊代币生态下的一个普通的 ERC-20 代币。转账流通都是通过以太坊的solidity合约进行实现。
区块链1.0的比特币也有脚本语言,但是为了安全阉割掉了循环和递归等图灵完备性语言才有的功能。以太坊 Solidity设计之初就被定位为图灵完备性语言。 Solidity的图灵完备性也为后续的合约漏洞陆续埋下了伏笔,如 The Dao 漏洞事件直接导致以太坊硬分叉成了eth 和旧链etc。
Solidity语言暂不支持类似于C中的 float double 等浮点型数据类型。支持int/uint变长的有符号或无符号整型。变量支持的步长以8 递增,支持从uint8 到uint256,以及int8 到int256 。需要注意的是,uint和int 默认代表的是uint256 和int256。uint8 的数值范围与 C中的uchar相同,即取值范围是0 到 2^8-1,uint256支持的取值范围是 0 到 2^256-1,余下数据类型以此类推。
Solidity语言中对于运算符的支持如下。
比较:< ,<= ,== ,!= ,>= ,> ,返回值为bool 类型。
位运算符:& ,|,(^ 异或),(~ 非)。
数学运算:+ ,-,一元运算+ ,* ,/,(% 求余),(** 平方)。
Solidity合约代码的逻辑都相对简单,运算符的使用中加法,减法和乘法居多。
以太坊提供有一个Solidity语言的在线编译测试工具。我们以加法和减法运算作为举例,简单说明下整数溢出在Solidity 语言中的常规情况。
编写如下solidity测试代码。
pragma solidity ^0.4.0;
contract C {
// (2**256 - 1) + 1 = 0
function overflow() returns (uint256 _overflow) {
uint256 max = 2**256 - 1;
return max + 1; }
// 0 - 1 = 2**256 - 1
function underflow() returns (uint256 _underflow) {
uint256 min = 0;
return min - 1; }
}
在线测试工具中编译运行结果如下如图3,图4。
可以看到uint256当取最大整数值,上溢之后直接回绕返回值为0 , uint256当取0下溢之后直接回绕,返回值为 2^256-1 。这是 solidity中整数溢出场景的常规情况。
Solidity中的整数溢出缓解和SafeMath库
为了减少solidity合约开发中产生的安全问题,以太坊的官方开发博客陆续发布了一些与 solidity开发安全相关的博文。在 2017年 8月 6日 单独发过一篇使用SafeMath 库进行整数安全操作的文章。
/**
* @title SafeMath
* @dev Math operations with safety checks that throw on error
*/
library SafeMath {
function mul(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a * b;
assert(a == 0 || c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal constant returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal constant returns (uint256) {
assert(b
return a - b;
}
function add(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
可以看到相关的可能产生溢出的操作都单独封装成函数,加入assert 断言做判断避免溢出。调用 SafeMath库的方法如下:
contract MyContract {
using SafeMath for uint256;
uint256 result;
function SafeAdd(uint256 a, uint256 b) {
result = 0;
result = a.add(b);
}
}
SMT合约中的整数安全问题简析
与大型软件或者操作系统动辄十万行甚至千万行代码不同,智能合约的代码行数通常不多,功能也不是很复杂。一起来看下SMT合约的相关代码。
SMT的合约地址是 https://etherscan.io/address/0x55f93985431fc9304077687a35a1ba103dc1e081#code
问题存在于transferProxy()函数 代码如下,如图6
在进行加法操作的时候没有采用Safemath库进行约束。 _feeSmt参数和 _value参数均可以被外界进行控制, _feeSmt和 value均是 uint256 无符号整数,相加后最高位舍掉,结果为0。如图7
直接溢出绕过代码检查导致可以构造巨大数量的smt代币并进行转账。如图8
攻击者的恶意转账记录可以从如下链接进行看到:
https://etherscan.io/tx/0x1abab4c8db9a30e703114528e31dee129a3a758f7f8abc3b6494aad3d304e43f
如图9
BEC合约中的整数安全问题简析
BEC的合约地址是0xC5d105E63711398aF9bbff092d4B6769C82F793D ,合约代码可以访问 etherscan的如下网址进行查看https://etherscan.io/address/0xc5d105e63711398af9bbff092d4b6769c82f793d#code 。代码一共是299行。
问题函数如图10。
可以看到batchTransfer函数中 ,语句
balances[msg.sender] = balances[msg.sender].sub(amount)
和
balances[_receivers[i]] = balances[_receivers[i]].add(_value)
中,调用 Safemath库中的安全函数来完成加减操作,但是在第三行代码,
uint256 amount = uint256(cnt) * _value
却直接使用乘法运算符。
其中变量cnt为转账的地址数量,可以通过外界的用户输入 _receivers进行控制, _value为单地址转账数额,也可以直接进行控制。乘法运算溢出,产生了非预期 amount 数值,并且外界可以通过调整_receivers 和_value 的数值进行操控。紧接着下面有一句对 amount进行条件检查的代码require(_value > 0&& balances[msg.sender] >= amount);
其中 balances[msg.sender]代表当前用户的余额。amount代表要转的总币数。代码意思为确保 当前用户拥有的代币余额大于等于转账的总币数才进行后续转账操作。因为通过调大单地址转账数额 _value的数值,amount 溢出后可以为一个很小的数字或者0 ,很容易绕过balances[msg.sender] >= amount
的检查代码。从而产生巨大_value数额的恶意转账。
攻击者的恶意转账记录,可以从如下链接看到https://etherscan.io/tx/0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f 。如图11
可以从代码totalSupply进行看到,bec 代币总量共计才 7 000 000 000枚。 如图12
但是攻击者通过溢出amount绕过后续 require中的判定条件分别向两个地址恶意转账了 57,896,044,618,658,100,000,000,000,000,000,000,000,000,000,000,000,000,000,000.792003956564819968 枚bec 代币。
小心整数除法的四舍五入
所有整数除数都会四舍五入到最接近的整数。 如果您需要更高精度,请考虑使用乘数,或存储分子和分母。
(将来Solidity会有一个fixed-point类型来让这一切变得容易。)
// bad
uint x = 5 / 2; // Result is 2, all integer divison rounds DOWN to the nearest integer
// good
uint multiplier = 10;
uint x = (5 * multiplier) / 2;
uint numerator = 5;
uint denominator = 2;
漏洞七:存储操作中的深度下溢
Doug Hoyte在2017年的以太坊黑客比赛中提出了这个漏洞,这也让他获得了比赛中的荣誉。这个想法很有意思,因为它引起了人们对C类语言下溢如何影响以太坊编程语言Solidity的担忧。这是一个简化了的版本:
一般来说,如果不经过keccak256哈希计算(当然,这是不现实的),变量manipulateMe的存储位置就不会被影响。但由于动态数组是按顺序存储的,如果攻击者想要改变manipulateMe这个变量,他只需要这样做:
调用函数popBonusCode()来实现下溢。(请注意,以太坊编程语言Solidity并没有内置的pop函数。)
计算变量manipulateMe的存储位置。
使用函数modifyBonusCode()修改和更新变量manipulateMe的值。
实际上,人们都知道这种数组存在的漏洞。但如果这样的数组被掩埋在更复杂的智能合约架构之下,谁又能轻易发现呢?这样它就可以任意地对变量进行恶意篡改。
解决方案:在考虑使用动态数组时,使用一个容器式的数据结构是一种不错的选择。
漏洞八:利用交易失败,促使意外恢复
考虑一个简单的拍卖合同:
当智能合约准备给商品原主人付款时,如果付款失败,它将恢复。这意味着一个恶意的投标人可以在拍下商品的同时确保给商品原主人的付款总是付款失败。这样他们可以阻止其他人调用bid()函数,成为商品的新主人。如前所述,为了资金安全,建议拍卖时建立一个预授权方式的付款合约。
另一个例子是当智能合约通过数组的迭代向用户付款时,例如给众筹合约的支持者退款。通常要确保每笔付款都成功,如果哪一笔付款失败了,则会恢复,重新付款。问题是如果一笔付款失败了,那么你要恢复整个付款系统。这意味着如果哪一笔付款卡住了,这次迭代付款永远都不会完成,因为一个地址出错,所有人都拿不到这笔钱。
解决方案:这里我们的建议是使用预授权方式付款。
漏洞九:利用区块燃料上限引发漏洞
你可能已经注意到了前一个例子中的另一个问题:如果要一次性地支付给所有人,你可能会遇到达到区块中燃料上限的情况。每个以太坊的区块都只能处理一定的最大计算量,如果你试图超过这个限制,那么你的交易将会失败。
即使没有黑客故意攻击你,这都是一个问题。如果攻击者能够操控你所需的燃料,情况就会变得更加糟糕。在前面的例子中,攻击者可以添加一堆地址,每个地址都需要很少量的退款。因此,加上给攻击者地址退款使用的燃料,可能会导致超过区块燃料上限,从而阻止退款交易的发生。
解决方案,我们推荐使用预授权方式付款来解决这个漏洞。
如果你绝对需要遍历未知大小的数组,那么你应该规划一下应该把它们分到多少个区块中,每个区块需要多少笔交易。这样你只需要留意现在进行到哪个区块中的交易了,出错后仅需从当前区块开始恢复,如下所示:
你需要确保在等待payOut()函数的下一次迭代时处理的其他交易不出现错误。所以只有在绝对必要的时候再使用这种模式。
漏洞十:强行给智能合约中加入以太币,引发程序逻辑漏洞
原则上,我们可以将以太币强制发送到智能合约中而不触发回退函数。当给回退函数加入重要功能或计算智能合约的收支平衡时,这是一个重要的考虑因素。请看下面这个例子:
这个智能合约的逻辑似乎不允许对智能合约付款,以防发生一些“不好的事情”。但是还是存在一些方法可以强制将以太币送到合约中,使智能合约的余额大于0。
智能合约中的自毁方法允许用户向指定的受益人发送任意数量的以太币,而这个自毁方法并不会触及合约的回退功能。
在部署一个智能合约之前,可以预先算出合约的地址并将以太币发送到该地址。
攻击者可以强制发送wei到任何账户,而且这是不能被阻止的(即使让fallback函数throw也不行)
攻击者可以仅仅使用1 wei来创建一个合约,然后调用selfdestruct(victimAddress)。在victimAddress中没有代码被执行,所以这是不能被阻止的。
解决方案:智能合约的开发者应该意识到以太币可以被强制送到智能合约中,并应该相应地设计智能合约逻辑。一般情况下,需要假设无法限制智能合约的资金来源。因为攻击者可以在合约创建之前向合约的地址发送wei。所以合约不能假设它的初始状态包含的余额为零。
参考文章