Tornado Cash Governance Takeover: When Proposals Become Exploits

In May 2023, an attacker gained permanent control of Tornado Cash’s governance by submitting a proposal that the community voted to approve. The proposal description was mundane. The bytecode was not.

The attack exploited a gap that exists in almost every DAO: voters read descriptions and trust proposers, but they almost never verify that the deployed contract bytecode matches what the description claims.

The Setup

Tornado Cash governance worked like most DAO systems. Any address holding 1,000 TORN could submit a proposal. After a voting period, if the proposal passed, its target contract was called and executed with protocol-level permissions. This is standard—and it means every passed proposal is arbitrary code execution authorized by token holders.

The attacker submitted a proposal claiming to adjust penalties for certain relayer addresses. This was a plausible, low-controversy change. The proposal passed.

The Hidden Code

The proposal contract contained more than it appeared to. Alongside the relayer penalty logic was a hidden function that manipulated the locked TORN accounting:

// What the proposal appeared to do:
contract ProposedChange {
    function executeProposal() external {
        relayerRegistry.updatePenalties(newParameters);
    }
}

// What the deployed contract actually did:
contract ProposedChange {
    function executeProposal() external {
        relayerRegistry.updatePenalties(newParameters);
        _grantVotingPower(attacker, 1_200_000e18);  // Hidden
    }

    function _grantVotingPower(address to, uint256 amount) internal {
        // Directly write to the locked TORN balance mapping
        // Attacker now holds 1.2 million votes
    }
}

When the proposal executed, the attacker was credited with 1.2 million TORN worth of governance votes—far exceeding any legitimate stakeholder. They now controlled the protocol.

The selfdestruct + CREATE2 Trick

The attacker used a technique that makes pre-vote code review nearly futile: deploy an innocent contract, collect approvals, then swap the code before execution.

The key insight is that CREATE2 produces deterministic addresses. The deployed contract’s address is a function of the deploying contract’s address, a salt, and the bytecode hash. But if you deploy a contract that contains a selfdestruct function, you can destroy it and redeploy different bytecode at the same address—because CREATE2’s address derivation depends on the deployer and salt, not the bytecode of the final contract.

The sequence:

  1. Deploy an innocent-looking proposal contract at address X
  2. Verify it on Etherscan; let community review proceed
  3. After the vote passes but before execution, call selfdestruct on the innocent contract
  4. Redeploy malicious bytecode to the same address X using CREATE2
  5. The governance timelock calls X.executeProposal()—now running the malicious version

Voters approved address X. They had no mechanism to notice that the code at X changed between their vote and the execution call.

Why Voter Due Diligence Does Not Scale

Even a careful voter who reads the proposal description, checks the Etherscan verification, and reviews the source code would not have caught this. The verification and code review would target the innocent version. The selfdestruct function, which is the actual threat, might be visible in the verified source—but most voters do not look for it specifically, and many proposals do not have verified source at all.

The deeper issue is that DAO governance asks voters to make trust decisions about code, but does not give them the tools to verify code in any practical sense. Reading Solidity or Vyper is a specialized skill. Verifying that deployed bytecode matches claimed source requires tooling most voters do not have.

The result is that governance security degrades to reputation—voters trust or distrust proposers based on identity and history, not technical verification. An attacker willing to build credibility over time, or willing to disguise malicious intent in an otherwise-legitimate proposal, can exploit that trust gap.

The Aftermath

With 1.2 million votes, the attacker controlled every subsequent governance decision. They could pass proposals to drain the protocol’s treasury, modify any parameter, or lock out other governance participants. The community responded by forking the governance contract and attempting to establish a clean state. Trust in the protocol’s governance was substantially damaged.

What Secure Governance Actually Requires

The Tornado Cash attack points to three concrete fixes, each addressing a different part of the attack chain.

Bytecode hash locking. When a proposal is queued, record the bytecode hash of the target contract. Before execution, verify the hash still matches:

function queueProposal(uint256 proposalId) external {
    address target = proposals[proposalId].target;
    proposalCodeHash[proposalId] = target.codehash;
    timelockEnd[proposalId] = block.timestamp + DELAY;
}

function executeProposal(uint256 proposalId) external {
    require(block.timestamp >= timelockEnd[proposalId]);
    require(
        proposals[proposalId].target.codehash == proposalCodeHash[proposalId],
        "Contract code changed since queuing"
    );
    // Execute
}

This prevents code swapping between vote and execution. Even with selfdestruct + CREATE2, the hash at execution time would not match the hash recorded at queue time.

Prohibiting selfdestruct in proposal contracts. The governance contract can scan proposal bytecode for the SELFDESTRUCT opcode (0xff) during the queuing step:

function queueProposal(address target) external {
    bytes memory code = target.code;
    for (uint256 i = 0; i < code.length; i++) {
        require(code[i] != 0xff, "SELFDESTRUCT not permitted in proposals");
    }
    // Proceed with queuing
}

This is a blunt check but effective—any proposal contract containing the opcode is rejected outright.

Parameterized actions instead of arbitrary execution. The most robust approach is to not execute arbitrary code at all. Instead, governance proposals select from a predefined set of permitted operations:

enum ActionType { UPDATE_FEE, UPDATE_RELAYER, PAUSE_PROTOCOL }

struct Proposal {
    ActionType action;
    bytes params;
}

function execute(uint256 proposalId) external {
    Proposal storage p = proposals[proposalId];
    if (p.action == ActionType.UPDATE_FEE) {
        _updateFee(abi.decode(p.params, (uint256)));
    } else if (p.action == ActionType.UPDATE_RELAYER) {
        _updateRelayer(abi.decode(p.params, (address, uint256)));
    }
    // No path for arbitrary code execution
}

This approach trades flexibility for security. Large, complex protocols may genuinely need arbitrary execution for upgrades. Smaller protocols with narrower governance scope can often constrain it.

The READ-WHAT-YOU-SIGN Problem

The Tornado Cash attack is one instance of a broader problem in DAO governance: the disconnect between human-readable descriptions and machine-executable bytecode. In traditional legal and financial systems, what you sign is what you agree to. In DAO governance, what you read (the description) is not what executes (the bytecode).

Closing that gap completely would require either restricting governance to parameterized actions—where there is no discrepancy possible—or building tools that translate bytecode into human-readable descriptions that are independently generated from the code itself, not supplied by the proposer.

Neither solution is fully deployed at scale. Until one is, DAO participants should treat proposal contracts as untrusted code that requires independent technical verification before approval.


References