Cointime

Download App
iOS & Android

Exploring Tornado Cash In-Depth to Reveal Malleability Attacks in ZKP Projects

In the previous article, we explained the inherent malleability vulnerability in the Groth16 proof system theoretically.

https://glacier-screen-c36.notion.site/Beosin-s-Research-Transaction-Malleability-Attack-of-Groth16-Proof-c090649950804af686e276baa3ba8182

In this article, we take the Tornado.Cash project as an example, modifying parts of its circuit and code to demonstrate malleability attack flows and the corresponding mitigations in the project, hoping to raise awareness for other zkp projects. Tornado.Cash uses the snarkjs library with the following development flow, so we'll dive right in - please refer to the first article in the series if you are unfamiliar with the library.

1 Tornado.Cash Structure

There are 4 main entities in the interaction flow of Tornado.Cash:

  • User: Uses this DApp to conduct private coin mixing transactions, including deposits and withdrawals.

Source: https://docs.circom.io/

  • Web page: The frontend web page of the DApp, contains some user buttons.
  • Relayer: To prevent on-chain nodes from recording privacy-related info like IP addresses, this server replays transactions on behalf of users to further enhance privacy.
  • Contract: Contains a proxy contract Tornado.Cash Proxy, which selects the specified Tornado pool based on deposit/withdrawal amounts. Currently there are 4 pools for amounts: 0.1, 1, 10, 100.

First the user initiates deposit or withdrawal on Tornado.Cash frontend. Then the Relayer forwards the transaction request to the Tornado.Cash Proxy contract on-chain, which further forwards it to the corresponding Pool based on amount, and finally performs the deposit/withdrawal processing. The architecture is as follows:

As a coin mixer, Tornado.Cash has two main business functions:

  • deposit: When a user makes a deposit, they first select the token (BNB, ETH etc) and amount on the frontend. To better ensure privacy, only 4 preset amounts can be deposited.

Source: https://ipfs.io/ipns/tornadocash.eth/

The server then generates two 31-byte random numbers - nullifier and secret. Concatenating and hashing them generates the commitment. The nullifier + secret is returned to the user as a note, like below:

Then a deposit transaction is initiated, sending the commitment to the on-chain Tornado.Cash Proxy contract. The proxy forwards the data to the corresponding Pool based on deposit amount. Finally the Pool contract inserts the commitment as a leaf node into the merkle tree, and stores the computed root in the Pool contract.

  • withdraw: When a user makes a withdrawal, they first enter the note data returned during deposit, and recipient address on the frontend;

The server then retrieves all Tornado.Cash deposit events off-chain, withdraws the commitments to build a local merkle tree, and uses the user provided note (nullifier + secret) to generate the commitment and corresponding merkle path and root. This is input into a circuit to obtain a zero-knowledge SNARK proof. Finally, a withdraw transaction is initiated to the on-chain Tornado.Cash Proxy contract, which forwards it to the corresponding Pool to verify the proof, and sends the money to the user's specified receiving address.

The core of Tornado.Cash's withdraw is toprove that a certain commitment exists in the Merkle tree without revealing the user's nullifier and secret.

The Merkle tree structure is as follows:

2 Tornado.Cash Vulnerable Version After Modification

2.1 Tornado.Cash Modification

Based on the previous article about Groth16 malleability attack principles, we know attackers can generate multiple different Proofs using the same nullifier and secret, so if developers don't consider replay attacks leading to double-spending, it can threaten project funds. Before modifying Tornado.Cash, this article will first introduce the Pool contract code that handles withdraws in Tornado.Cash:

As shown in the image above, to prevent attackers from double spending using the same Proof, while not revealing the nullifier and secret, Tornado.Cash added a public signal called nullifierHash in the circuit, which is the Pedersen hash of the nullifier, and can be passed as a parameter on-chain. The Pool contract then uses this variable to check if a valid Proof has been used before.However, what if instead of modifying the circuit, the project simply records Proofs to prevent double spending attacks? This would reduce circuit constraints and save costs, but would it work?To test this hypothesis, this article will remove the added nullifierHash public signal from the circuit, and change the contract verification to just check the Proof.Since Tornado.Cash retrieves all deposit events to build the merkle tree on each withdraw, then verifies if the root values are within the last 30 generated, which is cumbersome, this article will also remove the merkleTree circuit, leaving just the core withdraw logic, as follows:

Note: We discovered during the experiments that the latest TornadoCash code on GitHub lacks output signals in the withdraw circuit, requiring manual fixes to run properly. (https://github.com/tornadocash/tornado-core**)**

Based on the modified circuit above, following the development process outlined earlier using snarkjs etc, a normal Proof is generated, denoted as proof1:

2.2 Experimental Verification

2.2.1 Verification with Default circom Contract

First we use the default contract generated by circom. Since it does not record any used Proof info, attackers can replay proof1 multiple times to achieve double-spending attacks. In the following experiment, the same input's proof can be replayed unlimited times and still pass verification.

The image below shows proof1 passing verification in the default contract, including the Proof parameters A, B, C from the previous article, and the final result:

The next image shows the results of calling the verifyProof function multiple times with the same proof1. The experiment finds that for the same input, no matter how many times proof1 is used by the attacker, it always passes:

Testing in the native snarkjs js library also does not defend against reused Proofs, with results as follows:

2.2.2 Verification with Basic Anti-Replay Contract

To fix the replay vulnerability in the default circom contract, this article records a value from the valid Proof(proof1) to prevent replaying already verified proofs for double-spending attacks, as shown below:

Continuing to verify with proof1, the experiment finds the transaction reverts with "The note has been already spent" when reusing the same proof, as shown:

However,although this achieves the goal of preventing basic proof replay attacks, as covered earlier Groth16 has malleability vulnerabilities that can bypass this. The following PoC constructs a forged SNARK proof for the same input based on the algorithm from previous article, and it still passes verification. The PoC code to generate forged proof2 is:

The generated forgery PROOF2 is shown below:

Again using this parameter to call verifyProof function for proof verification, the experiment found that the same input in the case of using proof2 verification has passed again, as shown below:

Although the forged proof2 can only be used once more, since there are nearly unlimited forged proofs for the same input, this could lead to contract funds being withdrawn unlimited times.

Testing in the circom js library also shows proof1 and the forged proof2 passing verification:

2.2.3 Verification with Tornado.Cash Anti-Replay Contract

After so many failed attempts, is there no way to solve this once and for all? Here, following Tornado.Cash's method of checking if the original input has been used, this article further modifies the contract code as:

It should be noted thatto demonstrate simple mitigations against Groth16 malleability attacks, this article takes the approach of directly recording original circuit inputs, which does not conform to zero knowledge principles of keeping inputs private.For example in Tornado.Cash the inputs are private, so a new public input is added to identify a proof. Since this article's circuit does not add an identifier, the privacy is poorer compared to Tornado.Cash - this is just an experimental demo. The results are as follows:

It can be seen that with the same input, only the first proof1 passes verification. After that, both proof1 and the forged proof2 cannot pass verification.

3 Summary and Recommendations

Through modifying TornadoCash's circuit and using the default contract verification generated by the commonly used Circom, this article has verified the existence and risks of replay vulnerabilities. It further proves that using common measures at the contract level can defend against replay attacks, but cannot prevent Groth16 malleability attacks. Based on this, we suggest Zero Knowledge Proof projects note the following during development:

  • Unlike traditional DApps that use unique addresses to generate node data, zkp projects typically use combined random numbers to generate Merkle tree nodes. Pay attention if business logic allows inserting duplicate node values, as the same leaf node data can lead to some user funds being locked in contracts, or the same leaf data having multiple Merkle Proofs confusing business logic.
  • zkp projects typically record used Proofs in a mapping to prevent double-spending attacks. When using Groth16, malleability attacks exist, so recording should use original node data rather than just Proof data.
  • Complex circuits can have circuit uncertainty, lack of constraints etc, leading to incomplete validation conditions and logical vulnerabilities in contracts. We strongly recommend projects seek comprehensive audits from security audit firms well-versed in circuits and contracts before launch, to ensure security.

Beosin is a leading global blockchain security company co-founded by several professors from world-renowned universities and there are 40+ PhDs in the team, and set up offices in 10+ cities including Hong Kong, Singapore, Tokyo and Miami. With the mission of "Securing Blockchain Ecosystem", Beosin provides "All-in-one" blockchain security solution covering Smart Contract Audit, Risk Monitoring & Alert, KYT/AML, and Crypto Tracing. Beosin has already audited more than 3000 smart contracts including famous Web3 projects PancakeSwap, Uniswap, DAI, OKSwap and all of them are monitored by Beosin EagleEye. The KYT AML are serving 100+ institutions including Binance.

Contact

If you need any blockchain security services, welcome to contact us:

Official WebsiteBeosin EagleEyeTwitterTelegramLinkedin

Comments

All Comments

Recommended for you

  • Russia's Central Bank Adds Bitcoin, Ethereum, and USDT to Publicly Tradable Cryptocurrency List

    On August 11, the Central Bank of Russia included Bitcoin, Ethereum, and Tether (USDT) in the list of cryptocurrencies that can be publicly traded on domestic exchanges.

  • BTC Breaks Above $64,000

    Market data shows BTC has broken through $64,000 and is currently reported at $64,000.33, with a 24-hour decline of 1.53%. Market volatility is high, so please exercise caution and manage risks accordingly.

  • BTC Falls Below $64,000

    Market data shows BTC has fallen below $64,000, currently at $63,999.77, with a 24-hour decline of 1.89%. Market volatility is significant; please exercise risk control.

  • Vitalik Updates Ethereum Roadmap: Privacy, Post-Quantum Scaling, and Native Rollups Become New Priorities

    On August 10, Vitalik Buterin stated that he had compared the 2023 Ethereum roadmap with the current Strawmap. The overall direction still overlaps considerably, but some priorities and technical paths have been clearly adjusted, including raising the priority of quantum safety, downweighting VDF and some EVM improvements, and replacing old designs with solutions such as a unified binary tree, PBT, and new state types. He noted that the most notable change in the current Strawmap is the emergence of several new topics not included in the 2023 roadmap, reflecting a shift in Ethereum's R&D focus. These new priorities include: stronger native privacy support, aggressive scaling in a post-quantum context, specification streamlining for formal verification, Blob and Gas futures, native Rollups, and a more open design space for the future shape of the EVM. Vitalik also emphasized that Ethereum's scaling approach is shifting from 'expanding all activities comprehensively' to 'designing more scalable dedicated mechanisms for specific high-load scenarios,' and he regards STARK proofs and AI-accelerated formal verification as important foundations for the protocol's future. Overall, this update shows that the Ethereum roadmap is evolving toward quantum safety, privacy-first, censorship resistance, high performance, and simpler protocol design.

  • ETH Falls Below $1900

    Market数据显示,ETH has fallen below $1900, currently reported at $1899.19, with a 24-hour decline of 1.28%. The market is highly volatile. Please exercise risk control.

  • Microsoft Plans to Release Next-Gen MAIA 300 AI Chip in September

    On August 10, according to reports, Microsoft plans to release its next-generation MAIA 300 AI chip in September.

  • BitMine Adds 7,391 ETH and Buys Back 3 Million Shares Last Week

    As of August 9, Eastern Time, BitMine's total cryptocurrency + cash holdings + "Moonshot" initiative amounted to $11.6 billion. BitMine held 5,805,238 ETH (up 7,391 ETH from last week), representing 4.8% of the total Ethereum supply (120.7 million ETH). It also held 209 BTC, $180 million in Beast Industries shares, $69 million in Eightco Holdings (NASDAQ: ORBS) shares, and $104 million in unsecured cash. BitMine repurchased 3 million common shares last week, bringing the total common shares repurchased since early July to over 19 million. As of August 9, 2026, BitMine's total staked ETH was 5,067,309 (worth approximately $9.8 billion at $1,928 per ETH).

  • Strategy Sells 1,691 BTC in Past Week

    Regulatory filings show that Strategy sold 1,691 BTC over the past week, increasing its dollar reserves by $650 million.

  • Unitree Technology: Final Online Issuance Winning Rate 0.0181%

    On August 10, Unitree Technology (688836.SH) announced that the company's initial public offering of shares and the online issuance subscription status and winning rate on the Sci-Tech Innovation Board were disclosed. The issuing price was RMB 150.80 per share, with a total of 40,446,434 shares issued. After the activation of the clawback mechanism, the final online issuance was 9,707,000 shares, accounting for approximately 30.00% of the total shares issued after deducting the final strategic placement, and the final online issuance winning rate was 0.01809759%.

  • BTC Breaks Through $65,000

    Market data shows that BTC has broken through $65,000, currently reporting at $65,001.99, with a 24-hour increase of 0.19%. The market is highly volatile; please exercise risk control.