按:原文发表于2016年6月19日。
原谅我标题党了...
这次TheDAO被攻击的事件中,构造攻击的手法已经不是秘密。攻击者的攻击的思路很好理解,无非是利用以太坊开发者社区最近在讨论一个非常容易掉进去的坑,详细解释可见朱立剖析TheDAO攻击手法一文。
但是容易想到,仅仅靠这一点还不足以制造出这个大事件: 以太坊的每个block都有一个隐性的上限, gasLimit,类似比特币中的区块容量。在当前的设置(大约400多万gas)下,攻击者可以触发的递归调用次数是非常受限的。而在一次攻击完成,也就是递归调用结束之后,他的TheDAO Token balance会归0,也就无法再发动下一次攻击。(调用必须正常结束,否则整个事务会回滚,前功尽弃)换句话说,一个split proposal只能被利用一次。想要像这次事件一样大规模的盗走以太币,该怎么办呢?有了,我准备大量的备用proposal,发起一次又一次的冲锋不就行了?
然而攻击者微微一笑,轻松打脸...
截图为攻击者地址产生的消息,注意其中的block number一直在变化,也就是说,攻击者只用了一个proposal/token holder address, 就成功的进行了多次攻击。-_-!!
那他是怎么做到的呢?
思路
还是回到历史上有名的splitDAO方法:
// Burn DAO Tokens
Transfer(msg.sender, 0, balances[msg.sender]);
withdrawRewardFor(msg.sender); // be nice, and get his rewards
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0; // <---------- 看我看我
paidOut[msg.sender] = 0;
return true;
静静的观察一会儿我们可以发现,最后销毁token的那一行,与方法入口时检查msg.sender的余额不为0的代码(onlyTokenHolders modifier),可以构成一个经典的check-and-set的陷阱,check & set并不是原子性的。翻译成普通话就是:如果我在递归快要结束的时候,把我的余额先转给别人,会发生什么呢?销毁token的一行依然会忠实的执行balance = 0的操作,只不过是把本来就是0的记录再复写一次0罢了... 真正的token已经被转移到了别处。这样,在一次攻击完成之后,就可以把别处保管的token再转移回来,重新实施攻击了。
TheDAO中包含了一个标准的token合约,transfer的代码如下:
function transfer(address _to, uint256 _amount) noEther returns (bool success) {
if (balances[msg.sender] >= _amount && _amount > 0) {
balances[msg.sender] -= _amount;
balances[_to] += _amount;
Transfer(msg.sender, _to, _amount); // <----------- 看我看我
return true;
} else {
return false;
}
}
transfer的作用是让TheDAO token的持有者可以将自己的token转给任意其他人。在每次转账的时候都会产生Transfer的事件,我们可以从这里入手进行验证。
验证
有了想法就可以求证。打开geth console, 通过filter把所有和攻击者地址0xf835a0247b0063c04ef22006ebe57c5f11977cc4相关的Transfer event都找出来:
// address是TheDAO的地址
// topics[0]是Transfer event的id
// topics[1]是攻击者的proxy合约地址
> eth.filter({fromBlock: '0x19f0a0', address: '0xbb9bc244d798123fde783fcc1c72d3bb8c189413', topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000f835a0247b0063c04ef22006ebe57c5f11977cc4']})
...
{
address: "0xbb9bc244d798123fde783fcc1c72d3bb8c189413",
blockHash: "0x5965c954a9f9c60ade7bd81d1dc39e695f2be26239169315d74f1240c364baaa",
blockNumber: 1720245,
data: "0x00000000000000000000000000000000000000000000000dfd3f956d86e775fe",
logIndex: 55,
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x000000000000000000000000f835a0247b0063c04ef22006ebe57c5f11977cc4", "0x0000000000000000000000000000000000000000000000000000000000000000"],
transactionHash: "0xa348da60799bff3ca804b3e49c96edebea44c5728a97f64bec3e21056d42f6e3",
transactionIndex: 0
}, {
address: "0xbb9bc244d798123fde783fcc1c72d3bb8c189413",
blockHash: "0x5965c954a9f9c60ade7bd81d1dc39e695f2be26239169315d74f1240c364baaa",
blockNumber: 1720245,
data: "0x00000000000000000000000000000000000000000000000dfd3f956d86e775fe",
logIndex: 57,
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x000000000000000000000000f835a0247b0063c04ef22006ebe57c5f11977cc4", "0x0000000000000000000000000000000000000000000000000000000000000000"], // 这里的收款人地址为0
transactionHash: "0xa348da60799bff3ca804b3e49c96edebea44c5728a97f64bec3e21056d42f6e3",
transactionIndex: 0
}, {
address: "0xbb9bc244d798123fde783fcc1c72d3bb8c189413",
blockHash: "0x5965c954a9f9c60ade7bd81d1dc39e695f2be26239169315d74f1240c364baaa",
blockNumber: 1720245,
data: "0x00000000000000000000000000000000000000000000000dfd3f956d86e775fe",
logIndex: 58,
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x000000000000000000000000f835a0247b0063c04ef22006ebe57c5f11977cc4", "0x000000000000000000000000c0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89"], // 这里的收款人地址不是0
transactionHash: "0xa348da60799bff3ca804b3e49c96edebea44c5728a97f64bec3e21056d42f6e3",
transactionIndex: 0
}
]
这里贴出了最后一部分Transfer事件,其中topics[0]代表了Transfer这个名字, topics[1]是TheDAO token转账人的地址,topics[2]是TheDAO token收款人的地址。可以看到,由于不停的递归,一直在触发将攻击者持有的token归零的Transfer事件(归零也就是把持有者的TheDAO token都发往0地址。注意Transfer event的触发是在withdrawRewardFor之前,此时还没有真正执行归零的操作),但是最后一次Transfer, 却是把0xdfd3f956d86e775fe个token转移到了0xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89这个账户中!在这个Transfer之后,递归中积累的那些等待归人的归零操作才开始一遍又一遍自娱自乐的玩耍...
现在我们可以形成一次完整的攻击了:
- 准备工作,提交proposal,准备好作为receiver的恶意合约,等待
- 触发splitDAO, 递归造成TheDAO不停流血
- 递归在快要触到block gasLimit的时候结束,进行收尾工作,把自己的余额转到准备好的另外一个地址,这个地址也受到攻击者的控制。
- 一次攻击成功结束。此时再将余额从刚才的隐藏地址转回来。
- 回到2,进行下一次攻击
由于可以反复的利用同一个proposal进行攻击,这个漏洞的严重程度被放大了许多倍,使得攻击者可以在短时间内盗走大量的以太币。
一些想法
网上最早对于这个攻击思路的描述似乎是来自这篇6月9号的博客,Solidity的作者Christian接着在6月10号也在以太坊官方博客上发表了文章提醒大家注意这个问题。6月11号,MakerDAO发现自己的合约中存在这样的问题, 所幸处置及时,并未造成什么损失。6月12日,有用户在TheDAO的论坛上表示TheDAO也存在同样的问题,TheDAO团队研究过后作出结论不会有立即的影响,因为TheDAO还没有任何的reward产生,而问题代码是在获取reward的方法之中。随后Slock更新了自己的官方博客公布了这个结论。遗憾的是,攻击者发现获取reward的方法(withdrawRewardFor)也在另一个方法(splitDAO)中被调用。现在我们还知道,攻击者还找到了一个完美的收尾方法,使得攻击能够在一个proposal上重复。
这两天关于TheDAO的讨论铺天盖地。在技术层面,我并不认为Slock团队和进行安全审查的专家有太多的责任。事实并不是如所说的,合约的代码很烂,漏洞很低级,等等等等。TheDAO的合约经过了许多人的审查,包括我自己也在第一时间研究过,代码很规范,也很小心,例如一个这么长的合约,内部没有用到过任何的循环,完全规避了已知的一些漏洞。大多数攻击手法,在解释之后都很好理解,但是在实施之前是很难想到的。何况一次成功的攻击,还需要组合多个手法构造一个完整的方案,再采取各种方式隐藏自己。
从这次攻击中我们可以学到很多东西。对于合约将要承载的资产容量,我们需要有准确的预计,未来也许可以根据容量规划来进行相应程度的合约审计和试运行。对于call调用用到的地址,也应该包括在审计之内,要求其提供合约源代码。这些东西将成为社区的宝贵经验。
我们面对的是一个前所未有的世界。在我看来,我们正在进行的探索不亚于火星冒险。遗憾的是,我见到的许多讨论没有意识到这一点。他们既没有意识到正在进行的事情有着回收火箭一样高的失败概率,也没有意识到回收火箭失败并不代表冒险者的无能。
附:
攻击路径上涉及的地址及合约:
- 发起攻击的地址: 0xf35e2cc8e6523d683ed44870f5b7cc785051a77d
- proxy: 0xf835a0247b0063c04ef22006ebe57c5f11977cc4
- TheDAO rewardAccount: 0xd2e16a20dd7b1ae54fb0312209784478d069c7b
- proxy2: 0x56bcc40e5e76c658fad956ee32e4250bf97468a1
- 隐藏地址: 0xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89
- 转移回token交易的发起地址: 0x969837498944ae1dc0dcac2d0c65634c88729b2d
- TheDAO: 0xbb9bc244d798123fde783fcc1c72d3bb8c189413