查看原文
其他

【区块链智能合约安全系列】Tornado Cash 遭受恶意治理攻击完整始末

蚂蚁安全实验室 蚂蚁技术AntTech
2024-08-22


随着区块链技术的广泛应用,智能合约已经成为区块链应用开发的重要组成部分。然而,智能合约的安全问题一直是亟待解决的难题。针对区块链智能合约中的一些常见漏洞问题,本专栏将对多个案例进行分析和研究,帮助读者更好地了解智能合约的漏洞类型、攻击方式和防范措施。本篇文章是蚂蚁安全天宸实验室区块链智能合约安全系列文章中的第3篇,希望能为广大读者提供一些有价值的参考和帮助,共同提高区块链应用的安全性,促进区块链技术的进一步发展。



01事件回顾

北京时间 2023 年 5月 20 日,Tornado.Cash 遭受提案攻击,攻击者获得 47.3万枚 TORN 代币,且短期内大量抛售 37.9万枚,获利 375 枚 ETH,并存入 Tornado Cash 隐藏踪迹。TORN 是一个 ERC20 兼容代币,是 Tornado Cash 社区的治理代币,目前总发行量是 9,999,997 枚。攻击发生之前每枚代币价值 $6.08。总市值约 6000 万美金。攻击发生后,TORN 代币价值一度下跌至 $3.49, 目前已经回升到 $4.2。


02背景知识

Tornado Cash 是一个基于零知识证明技术的隐私保护项目,目前已经运行在多个区块链平台上,如 Ethereum, BSC, Polygon, Optimism, Arbitrum, Gnosis, Avalanche。它使用zk-SNARK(Zero-Knowledge Succinct Non-Interactive Argument of Knowledge),能够以不可追溯的方式将 ETH 以及 ERC20 代币(目前支持DAI,cDAI,USDC,USDT,WBTC)发送到任何地址。Tornado Cash 提供了更高的隐私保护,帮助用户避免被跟踪和监视,同时也可以降低加密货币交易和余额泄露的风险。该项目对于那些希望保持其数字资产私密性和匿名性的加密货币交易者来说非常有用。所以也常常被黑客用于隐藏自己的行踪。


Tornado Cash 现在由社区维护,任何决策都需要社区支持。Tornado Cash 的治理合约采用完全去中心化的方式,没有任何中央机构或组织控制,任何人都可以参与治理和投票,这是 Tornado Cash 的一个核心价值观。Tornado Cash 的治理合约采用的是代币持有人投票机制,持有 TORN 代币的用户可以投票决定 Tornado Cash 的未来发展方向和优先事项。


每个 TORN 代币的持有人都有发起提案和投票的权利,每张选票代表着一个 TORN 代币。任何人只需锁定 1000 枚 TORN 代币就可以提出提案。社区人员看到提案后,可通过治理合约对提案进行投票,既可以投赞成票又可以投反对票。如果提案得到足够多的赞成票,就可以被执行,反之,则不被执行。

2.1 锁定代币


如果用户希望参与治理,需要先锁定 TORN 代币作为票数。用户可通过治理合约提供的接口来锁定 TORN 代币。锁定代币后,代币会转账到治理合约所使用的钱包合约 userVault(后面简称:钱包合约)中,转账完成后,治理合约为用户发放票数。从智能合约代码中可以看到,锁定的代币数量和增加的投票数量是 1:1 的关系。用户的投票数量保存在治理合约的 lockedBalance结构中。后续关于投票数量的增删改查,都会操作这个结构,就不再赘述。


function _transferTokens(address owner, uint256 amount) internal virtual override { require(torn.transferFrom(owner, address(userVault), amount), "TORN: transferFrom failed"); lockedBalance[owner] = lockedBalance[owner].add(amount); }

2.2 为关注的提案投票


用户可以用已经获得的票数为提案投票。如果用户赞成提案可以投赞成票,反之,则可以投反对票。如果用户之前已经对同一个提案投过票,那么本次投票要扣除之前所投的票数(参照代码片段 12~18 行)。如此一来就可以防止用户重复投票。投票操作的最后阶段是给代币加上锁定期限,锁定阶段禁止用户取出代币,直到锁定期过,方可取出,具体可以参照解锁代币小节。锁定期限的计算可以参考 37 行代码。


锁定期限=投票结束时间+投票扩展时间+执行过期时间+执行耽搁时间。锁定期限能确保用户不会在提案结束之前取出代币。


用户可以同时对多个提案进行投票,如果投票了多个提案,锁定时间该如何计算?根据 43 行代码所示,用户的锁定时间将会取所有提案中最晚的一个。

function _castVote( address voter, uint256 proposalId, bool support ) internal override(Delegation) { require(state(proposalId) == ProposalState.Active, "Governance::_castVote: voting is closed"); Proposal storage proposal = proposals[proposalId]; Receipt storage receipt = proposal.receipts[voter]; bool beforeVotingState = proposal.forVotes <= proposal.againstVotes; uint256 votes = lockedBalance[voter]; require(votes > 0, "Governance: balance is 0"); if (receipt.hasVoted) { if (receipt.support) { proposal.forVotes = proposal.forVotes.sub(receipt.votes); } else { proposal.againstVotes = proposal.againstVotes.sub(receipt.votes); } }
if (support) { proposal.forVotes = proposal.forVotes.add(votes); } else { proposal.againstVotes = proposal.againstVotes.add(votes); }
if (!proposal.extended && proposal.endTime.sub(getBlockTimestamp()) < CLOSING_PERIOD) { bool afterVotingState = proposal.forVotes <= proposal.againstVotes; if (beforeVotingState != afterVotingState) { proposal.extended = true; proposal.endTime = proposal.endTime.add(VOTE_EXTEND_TIME); } }
receipt.hasVoted = true; receipt.support = support; receipt.votes = votes; _lockTokens(voter, proposal.endTime.add(VOTE_EXTEND_TIME).add(EXECUTION_EXPIRATION).add(EXECUTION_DELAY)); emit Voted(proposalId, voter, support, votes); }

function _lockTokens(address owner, uint256 timestamp) internal { if (timestamp > canWithdrawAfter[owner]) { canWithdrawAfter[owner] = timestamp; } }


2.3 投票结果决定是否执行提案


提案的投票期为3天。如果提案获得了赞成票多余反对票,且赞成票和反对票的总投票数至少达到 25,000票,则提案能够被执行。如果反对票多于赞成票,或者总票数不足 25,000 票,则提案失败,无法执行。


2.4 解锁代币


如果用户不想再参与社区治理,或者其他理由需要取出锁定在治理合约中一定数量的代币,就可以通过调用 unlock()接口解锁并取出代币。解锁阶段需要检查是否已经到达锁定期限。如投票提案小节所述,用户参与提案投票时,代币会被锁定一段时间,aka,锁定到提案过期之后。解锁接口需要管控不能在提案有效期间将币取出,防止用户取出代币后,再次为同一提案投票。


解锁代币时,将从钱包合约中转出 TORN 代币给用户,如第 4 行代码所示。
function unlock(uint256 amount) public virtual override { require(getBlockTimestamp() > canWithdrawAfter[msg.sender], "Governance: tokens are locked"); lockedBalance[msg.sender] = lockedBalance[msg.sender].sub(amount, "Governance: insufficient balance"); userVault.withdrawTorn(msg.sender, amount); }

2.5 项目小结


这种去中心化的治理方式,给了社区足够的自主权。一些机制,如扣除已投票数,投票后对票数/代币加锁定期限,又能够保证不会对同一个提案进行重复投票,且能够防止使用闪电贷一次性投大量票。这些机制在一定程度上保障了提案以相对公平公正的方式被投票。但是所有的代币都锁定在治理合约(早期)及钱包合约(升级后)中,又让治理合约和钱包合约拥有大量资产,成为攻击者的目标。本攻击就是一个案例。本攻击中,攻击者几乎窃取了钱包合约的所有资产,这些资产又全部来自社区用户,最终还是由社区用户蒙受损失。


03攻击概述

此次攻击由多个阶段组成,攻击时间线如下图所示。



3.1 Step1 发布提案


攻击者先部署一个看起来正常实则有陷阱的提案合约来迷惑社区,让社区为此提案投票。发布提案至少需要锁定 1000 枚 TORN,攻击者是如何获得初始资金的呢?追踪攻击者的行踪可知,最初的资金来自 Tornado Cash,攻击者先从 Tornado Cash 里取回了 10 枚 ETH。用 3.6 枚 ETH,先后从 Sushiswap/Uniswap/1inch 这些 DEX 中,兑换出 1016 枚TORN,并全部锁定到治理合约中,这样就达到了提交提案合约的门槛。


发布提案交易 tx 如下
0x34605f1d6463a48b818157f7b26d040f8dd329273702a0618e9e74fe350e6e0d


提案内容是处罚一些地址,并且使用的是 16 号提案的逻辑。


但是跟 16 号提案不同的是,这个提案协议存在一个预埋的雷 -- 一个自毁函数 emergencyStop(),这也是后面能够把提案内容更新成恶意内容的关键铺垫。这个自毁函数做了权限控制,仅攻击者可以调用。这样就能够保证攻击者的提案不会被其他人销毁。

function emergencyStop() public onlyOwner {
selfdestruct(payable(0));
}

社区没有发现这个自毁函数的潜在威胁,纷纷为其投票,支持 20 号提案。


3.2 Step2 创建100个僵尸账户


攻击者的提案得到了社区的支持,在投票有效期内一共收集到 72w 张有效赞成票,已经具备了执行的条件。



此时攻击者用另外一个账号创建了 100 个账户,每创建一个账户,就锁定 0 TORN 到治理合约, aka, Governance 合约。这样做的目的是把攻击者控制的 100 个合约账户写入 Governance 的存储空间。此时这 100 个账户的票数余额是 0,aka TORN 的数量是 0。这一步是后面获利的关键铺垫。如果不是回顾攻击过程,是很难发现这个隐藏的铺垫的。


创建交易如下

0x1417e2408a890fd8bc41014d2448490abc8e9981c88cae3c20d455781ae9c0f6



3.3 Step3 自毁提案


攻击者的提案收集到足够的投票后,攻击者调用了蓄谋已久的自毁函数 emergencyStop(),把 0x7dc8 和提案合约都自毁了。


交易 tx 如下

0xd3a570af795405e141988c48527a595434665089117473bc0389e83091391adb



如此一来,攻击者在第一步创建的提案合约,及其提案合约的创建合约0x7dc8 都被销毁了。


3.4 Step4 重新部署提案


我们已经来到此次攻击关键的地方了,攻击者再次在相同的地址上部署了提案合约,但是提案合约内容却完全不同了。这是怎么做到的呢?


先来看一下攻击者部署交易使用的指令。


攻击者使用了 create2 先部署了
0x7dc86183274b28e9f1a100a0152dac975361353d 合约。


之后,上述合约使用 create 部署了
0xc503893b3e3c0c6b909222b45f2a3a259a52752d。



为什么重新部署的地址和 step1 中的地址完全一样,而提案合约的内容却完全不同了?


这是因为 create 和create2 计算合约地址的方式非常不同。


create vs create2

create new_address = keccak256(sender, nonce);

create2 new_address = keccak256(0xFF, sender, salt, bytecode);


攻击者使用 create2 来部署 0x7dc8 合约,只要 sender,salt,bytecode 完全相同,那么就能够得到相同的合约地址,即,0x7dc86183274b28e9f1a100a0152dac975361353d。之后,0x7dc8 合约使用 create 来部署提案合约,create 计算新合约的地址仅跟 sender 和 nonce 有关。显然,step1 和step3中 sender 都是 0x7dc8 合约,nonce 都是 0,所以提案合约的地址也会相同,跟提案合约的字节码无关。借助这个方式,攻击者能够把提案合约升级成恶意版本。


提案合约的字节码从哪里来?


尽管用 create 方式创建提案合约时,合约地址的计算方式跟合约字节码无关,但是依然需要字节码来创建合约。那么,提案合约的字节码从哪里来的?有多种方式可以达成目标。攻击者采取的方式是,事先预留接口,调用接口时,通过 msg.data 的方式传给合约。通过反编译攻击者的合约,得到如下接口代码。可以看到在0xce40d339 中,获取到了 msg.data 的数据作为字节码的来源。



接下来看一下攻击者如何传入 msg.data 的。从以下截图可以看到,攻击者通过调用 0xce40d339 接口,传入提案合约的字节码。这样攻击者就可以把提案合约的字节码更新成恶意版本。



是否还有其他的方式提供提案合约的字节码呢?答案是肯定的,还有一种可行的方案:和攻击合约,即用于创建 0x7dc8 的合约,一同部署到链上。根据时间戳,不同的时间使用不同字节码创建提案合约。我们给出示例代码如下,示例代码中可以看到,每一个合约都需要存在自毁函数。
contract CreateDeployer { address public deployer; function createDeployer(bytes32 salt, uint arg) public { deployer = address(new Deployer{salt: salt}(arg)); }
function emergencyStop() public { Deployer(deployer).emergencyStop(); }}
contract Deployer { constructor(uint endBlock){ if(block.number <= endBlock){ proposal = address(new Proposal()); }else if(block.number > endBlock){ proposal = address(new Proposal2()); } }
function emergencyStop() public { Proposal(proposal).emergencyStop(); selfdestruct(payable(0)); }}
contract Proposal { function executeProposal() external { address[4] memory VIOLATING_RELAYERS = [ 0xcBD78860218160F4b463612f30806807Fe6E804C, // thornadope.eth 0x94596B6A626392F5D972D6CC4D929a42c2f0008c, // 0xgm777.eth 0x065f2A0eF62878e8951af3c387E4ddC944f1B8F4, // 0xtorn365.eth 0x18F516dD6D5F46b2875Fd822B994081274be2a8b // abc321.eth ];
}
function emergencyStop() public onlyOwner { selfdestruct(payable(0)); }}
contract Proposal2 { uint256[59] private _pad; mapping(address => uint256) private _balances;
function executeProposal() external {
_balances[0x1C406ABB1c6a3Bb12447f933b5D4293701b6e9f2] = 10000; _balances[0xb4d47EE99E132e441Ae3467EB7D70F06d61b10C9] = 10000; _balances[0x57400EB021F940B258F925c57cD39F240B7366F2] = 10000; _balances[0xbD23c3ed3DB8a2D07C52F7C6700fDf0888f4f730] = 10000; }
function emergencyStop() public onlyOwner { selfdestruct(payable(0)); }}


所以,提案合约的代码既可以采用攻击者的方式,从 msg.data 动态传入,又可以采用上述示例代码的方式静态部署过去。需要注意的是,如果采取静态部署的方式,要提前获知 100 个僵尸账户的地址。做到这一点很容易,创建 100 个僵尸地址时用 create2 的方式,即可预测地址;或者,把创建僵尸账户的动作提前完成,提前创建僵尸账户不会影响攻击效果。所以达成一个攻击目标有多种方法。


3.5 Step5 执行提案


攻击者要调用治理合约的 execute() 接口去执行提案合约,第 8 行代码可以看到是通过 delegatecall 的方式调用提案合约的executeProposal()接口。


交易 tx 为

0x3274b6090685b842aca80b304a4dcee0f61ef8b6afee10b7c7533c32fb75486d
function execute(uint256 proposalId) external payable virtual { require(state(proposalId) == ProposalState.AwaitingExecution, "Governance::execute: invalid proposal state"); Proposal storage proposal = proposals[proposalId]; proposal.executed = true; address target = proposal.target; require(Address.isContract(target), "Governance::execute: not a contract"); (bool success, bytes memory data) = target.delegatecall(abi.encodeWithSignature("executeProposal()")); if (!success) { if (data.length > 0) { revert(string(data)); } else { revert("Proposal execution failed"); } } emit ProposalExecuted(proposalId); }
在执行提案的时候,由于使用了 delegatecall 指令,恶意的提案合约只要修改自己的存储空间,就可以同步修改 Governance 合约的存储空间。所以,恶意的提案合约把 step2 中创建的 100 个合约账户的余额改成了 10,000,Governance 合约的存储空间中这 100 个合约账户的余额也被修改成 10,000。攻击者用这种方式凭空给自己控制的合约账户印发了 1,000,000 张选票。要知道超过 25,000 张合法选票就可以通过一个提案。攻击者拥有 1,000,000 张选票,相当于控制了治理合约,进一步控制了社区的发展。


执行提案的交易 tx 如下:

0x3274b6090685b842aca80b304a4dcee0f61ef8b6afee10b7c7533c32fb75486d


交易导致的状态改变,如下:


3.6 Step6 提币获利


当提案被执行的时候,攻击者控制的 100 个合约账户的选票数量就从 0 被修改为 10,000。每一张选票就是一枚 TORN 代币,攻击者相当于拥有了大量的 TORN 代币。那么攻击者只需要解锁账户,把币提走即可。实际上,攻击者一次性转走了 47.3 万枚 TORN。为什么攻击者转走的数量是这些呢?这是因为钱包合约一共拥有 473270 枚 TORN。攻击者基本上把钱包合约抽干了,仅为之留下 270 枚TORN。


提币交易如下

0x13e2b7359dd1c13411342fd173750a19252f5b0d92af41be30f9f62167fc5b94


攻击者到底是偷走了谁的代币呢?表面来看,攻击者是偷走了钱包合约的 TORN代币,但是钱包合约的代币又是社区用户在每一笔锁定代币时转入的。钱包合约的代币被攻击者偷走之后,相当于银行被抢了,用户空有账户余额,却兑换不了。最终还是社区用户蒙受损失。



04攻击后续
攻击者后来又提出了一个提案,内容是从治理合约转账 483,000 TORN 给钱包合约,并且把其操控的 100 个僵尸账号在治理合约中锁定的票数清零。代码片段如下:
function executeProposal() external { IERC20(0x77777FeDdddFfC19Ff86DB637967013e6C6A116C).transfer( 0x2F50508a8a3D323B91336FA3eA6ae50E55f32185, 483000 ether ); _balances[0x1C406ABB1c6a3Bb12447f933b5D4293701b6e9f2] = 0; _balances[0xb4d47EE99E132e441Ae3467EB7D70F06d61b10C9] = 0; _balances[0x57400EB021F940B258F925c57cD39F240B7366F2] = 0;... }
提案链接如下:

https://etherscan.io/address/0x1fad009ad35689b5a9b91486148f2f32afe31e23#code


社区对这个提案又展开了讨论,本提案获得了社区很多人的支持。5月26日获得通过,共 51.7万票赞成,0票反对。5月28日,攻击者的提案被执行。治理合约向钱包合约转账 483,000 TORN,且攻击者控制的 100 个僵尸账号的票数被清零。至此,治理合约的控制权又交回到社区手中。


执行交易的 id 如下:

0xaac00510632902b70dff192fade97c54baf2ee9491dd3763df94a7f5f950d157


一个有趣的问题是,治理合约的钱从哪里来的?考察治理合约的收入来源有 2 个大类:1是来自项目方运营的收益,2是来自用户的锁定,早期用户锁定代币会转账到治理合约中的,后来才升级到转账给钱包合约。所以治理合约有大量的 TORN,具备填补亏空的条件。


第二个有趣的问题是,为什么是治理合约负责填补了 TORN 的亏空,而不是攻击者归还攻击所得?恐怕不是所有的攻击者都会归还非法所得,尤其是攻击者确保自己不会被追踪到的情况下,更难归还所得。追溯这个攻击者的行踪可知,攻击者用于攻击的 2 个账号均从 Tornado Cash 获得攻击启动资金。获利后,也是把所得的 ETH 再次存入 Tornado Cash。攻击者的整个行踪都是隐藏的。


05经验教训
纵观整个过程,如果说要防范,该怎么防范呢?恐怕是很难。DAO的治理方式固然是去中心化了,但是社区的人并不都是专业的审计人员,很容易被迷惑,进而被诱导投票。所有依靠人为的环节都存在不可控因素。


尽管如此,我们依然能够学习到一个重要的教训,存在自毁行为的提案应当提高警惕。

区块链智能合约安全系列分享持续更新中,敬请期待
——————————
第一篇:完整起底Euler攻击事件
第二篇:Tornado Cash 遭受恶意治理攻击完整始末
第三篇:MEV Bot 反向套利事件追踪
第四篇:Checks-Effects-Interactions 魔咒:以太坊DeFi平台2500万美金被盗


继续滑动看下一个
蚂蚁技术AntTech
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存