Docs
Checking for replay protection

Checking for replay protection

Learn how to check if your contract is susceptible to replay attack.

This tutorial walks you through detecting and preventing the replay attack, which allows a malicious actor to drain the funds of a vulnerable contract by resending a previously accepted message. Even though the concept of replay attack is well-known in the field of security, it may be difficult to detect through a standard code review, as it depends on the specific execution details of TON blockchain.

We'll intentionally create a vulnerable TON smart contract, then use the TSA Blueprint plugin to identify the security flaw.

By the end, you'll know how to:

  • Set up TSA security analysis in your Blueprint project
  • Interpret vulnerability reports and understand what they mean
  • Understand the intricacies of replay protection

1. Create a Project with a Vulnerable Contract

To demonstrate how the TON replay attack checker works, we'll start by deliberately creating a smart contract with a known vulnerability — specifically, one that allows an attacker to resend the same message.

Follow the steps below to set up the project and write the vulnerable contract.

Create a Blueprint Project

Before you begin, make sure you meet all the requirements for working with Blueprint. You can find the details in the Blueprint documentation.

Once everything is set up, create a new project by running:

npm create ton@latest

You'll be prompted to enter:

  • Project name — enter TonReplayAttackDemo
  • First contract name — enter VulnerableContract
  • Contract template — choose An empty contract (Tolk)

We'll use this empty contract as a starting point to build our intentionally vulnerable example.

Enter the generated directory:

cd TonReplayAttackDemo

Implement the Vulnerable Contract

The generated empty contract is located at contracts/vulnerable_contract.tolk.

Open this file and replace its contents with the following:

import "@stdlib/gas-payments"
 
struct Storage {
    publicKey: uint256,
}
 
struct GetMoney {
    amount: uint32,
}
 
fun Storage.load() {
    return Storage.fromCell(contract.getData());
}
 
fun main() {
}
 
fun onExternalMessage(inMsg: slice) {
    var signature = inMsg.loadBits(512);
    var publicKey = Storage.load().publicKey;
    assert (isSignatureValid(inMsg.hash(), signature, publicKey)) throw 35;
    acceptExternalMessage();
    val reply = createMessage({
        bounce: BounceMode.NoBounce,
        value: ton("0.05"),
        body: GetMoney {
            amount: inMsg.loadUint(32)
        },
        dest: address("EQD9ONCYURxDAV4CzRhc_Kw77_-omip_INZUQGOKlHW523p1"), // some address
    });
    reply.send(SEND_MODE_REGULAR);
}

Even when a message's authenticity is verified through signature checks, a security issue remains. Nothing prevents a malicious actor from replaying the same message over and over. Each time, the contract reexecutes the handler logic — potentially sending new messages — and the gas fees are repeatedly deducted from the contract under attack.

2. Set Up the TSA Plugin

To detect and analyze security vulnerabilities like the one in our contract, we'll use the TSA plugin.

Refer to TSA Blueprint Plugin Installation Guide for instructions.

3. Run the TON Replay Attack Checker

Now let's analyze our vulnerable contract. Run the TSA replay-attack checker with the following command:

yarn blueprint tsa replay-attack-check -c VulnerableContract

By default, built-in checkers are interactive: TSA asks for confirmation twice — first before preparation and timeout calculation, and then again before the analysis starts. If you want to skip both prompts, add --no-interactive.

When you execute this command, TSA performs symbolic analysis of your contract. This means it systematically explores different execution paths to identify any that could lead to vulnerabilities.

Here's what the command options mean:

  • replay-attack-check refers specifically to the TON replay attack checker — a specialized analyzer that searches only for execution paths where the accepted message can be sent again.
  • -c VulnerableContract specifies the name of the contract to analyze.

You can customize the analysis with additional options. To see all available parameters, run:

yarn blueprint tsa replay-attack-check --help

The analysis process includes:

  • Path exploration — Examining various ways the contract could be called
  • Constraint solving — Determining what inputs would trigger vulnerable execution paths
  • Vulnerability detection — Flagging paths that allow an external message to be accepted twice

If vulnerabilities are detected, the tool will report the findings.

After a moment, you'll receive a vulnerability report similar to this:

⚠️ Vulnerability found!
Summary path: tsa/reports/run-<id>/summary.txt
Typed input: tsa/reports/run-<id>/typed-input.yaml
SARIF with full information: tsa/reports/run-<id>/report.sarif

The generated files are stored in tsa/reports/run-<id>:

tsa
└── reports
    └── run-<id>
        ├── contract-data.boc
        ├── message-body.boc
        ├── report.sarif
        ├── summary.txt
        └── typed-input.yaml

Manual Investigation

Examine the generated files to understand the vulnerability in detail.

Let's break down what each component of the report means:

  • Summary (summary.txt): A concise overview of the vulnerability — this is what was displayed in your terminal.
  • Typed input (typed-input.yaml): The recommended file for investigation. It contains the typed representation of the reproducing message body and contract data in one place.
  • Raw BoC inputs (message-body.boc, contract-data.boc): The exact inputs that reproduce the vulnerability.
  • SARIF report (report.sarif): A standardized format for static analysis results. This file contains comprehensive details about analyzed execution paths. For most use cases, you won't need to examine this file directly.

The checker successfully identified the missing replay protection — any message accepted by the contract can be sent and accepted again. In its current form, the vulnerable contract offers no defense: any message signed by the private key owner can also be replayed by an attacker.

4. The First Attempt to Fix the Vulnerability

The vulnerability exists because the contract lacks replay protection. To fix it, we must ensure that no message can be accepted more than once. The standard approach is to use sequence numbers — a global counter stored in the contract's data that increments with each valid message.

However, as we'll see, this alone won't fully solve the problem, as we haven't yet accounted for the full blockchain semantics. Update contracts/vulnerable_contract.tolk with the following corrected code, which implements a naive sequence number policy:

import "@stdlib/gas-payments"
 
struct Storage {
    publicKey: uint256,
    seqno: uint32, // the sequence number
}
 
struct GetMoney {
    amount: uint32,
}
 
fun Storage.load() {
    return Storage.fromCell(contract.getData());
}
 
fun Storage.save(self) {
    contract.setData(self.toCell());
}
 
get fun getSeqno() {
    return Storage.load().seqno;
}
 
fun main() {
}
 
fun onExternalMessage(inMsg: slice) {
    var signature = inMsg.loadBits(512);
    val storage = Storage.load();
    var publicKey = storage.publicKey;
    assert (isSignatureValid(inMsg.hash(), signature, publicKey)) throw 35;
 
    var msgSeqno = inMsg.loadUint(32);
    var storedSeqno = storage.seqno;
    assert (msgSeqno == storedSeqno) throw 36;
 
    acceptExternalMessage();
    Storage {
        publicKey: publicKey, seqno: storedSeqno + 1
    }.save();
 
    val reply = createMessage({
        bounce: BounceMode.NoBounce,
        value: ton("0.05"),
        body: GetMoney {
            amount: inMsg.loadUint(32)
        },
        dest: address("EQD9ONCYURxDAV4CzRhc_Kw77_-omip_INZUQGOKlHW523p1"), // some address
    });
    reply.send(SEND_MODE_REGULAR);
}
 

Can you spot a vulnerability?

Let's re-run the TSA replay attack checker

yarn blueprint tsa replay-attack-check -c VulnerableContract

After a few moments, the tool reports again that the contract is still vulnerable.

But how? Let's investigate.

We need to examine the reproduction message. Open typed-input.yaml, which is listed at the end of the console report:

messageBody:
  type: "DataCell"
  elements:
  - type: "bits"
    length: 512
    data: "<signature bits omitted>"
  - type: "uint"
    value: "4294967295"
    length: 32
  - type: "bits"
    length: 224
    data: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
  refs: []
contractData:
  type: "DataCell"
  elements:
  - type: "uint"
    value: "100651338651850902292018359318644049634946739868197503414264539403276906818573"
    length: 256
  - type: "uint"
    value: "4294967295"
    length: 32
  refs: []

Looking at messageBody, we see an element

- type: "uint"
  value: "4294967295"
  length: 32

This is the sequence number, and it is set to 4294967295 = 2^32 - 1, the maximum 32-bit unsigned integer. Why does this matter?

Inside the contract, the following happens:

  • The sequence number from messageBody is read as 2^32 - 1
  • It is compared with the value stored in contractData (which is the same number)
  • The contract increments it by one, resulting in 2^32
  • When storing this new value with a 32-bit STU instruction, an exception is raised — 2^32 doesn't fit into 32 bits

The critical detail: the exception occurs before the storage is updated. This means the sequence number never changes, and an attacker can replay the same message endlessly. Each replay executes the handler logic and burns gas, draining the contract's funds.

This illustrates a fundamental limitation of sequence numbers: a counter can only grow until it overflows. After enough transactions, the increment operation fails, leaving the contract vulnerable again. It's a constraint you must understand and accept — contracts relying on seqno for replay protection can only process a finite number of messages.

Now let's confirm that this is indeed the issue we've found. We can add an assumption to the contract that the initial sequence number is less than 2^32 - 1.

To add such a constraint, use -s getSeqno to specify the getter that returns the stored sequence number, and -r 4294967295 to require the stored seqno to be strictly less than 4294967295. The resulting command is:

yarn blueprint tsa replay-attack-check -c VulnerableContract -s getSeqno -r 4294967295

But the TSA still reports the found vulnerability. What's the catch?

5. The Last Piece for The Protection

If we investigate the message body that triggered the vulnerability, we will see the following shape in typed-input.yaml:

messageBody:
  type: "DataCell"
  elements:
  - type: "bits"
    length: 512
    data: "<signature bits omitted>"
  - type: "uint"
    value: "0"
    length: 32
  refs: []
contractData:
  type: "DataCell"
  elements:
  - type: "uint"
    value: "<public key>"
    length: 256
  - type: "uint"
    value: "0"
    length: 32
  refs: []

That is:

  • the signature (512 bits)
  • the sequence number — 0 here (32 bits)
  • no Money::amount field attached after the sequence number in messageBody

The last point is critical — it triggers a cell underflow exception inside inMsg.loadUint when constructing the message. Once the exception is raised, the transaction is aborted and all uncommitted changes are rolled back. The sequence number in storage remains unchanged.

To prevent this, we must commit the storage after updating it. Without an explicit commit, a failed transaction leaves the contract in its original state — allowing an attacker to replay the same message and drain funds (though no outgoing messages will be sent).

Update the contract file as follows (only a single line is added, marked with a comment):

import "@stdlib/gas-payments"
 
struct Storage {
    publicKey: uint256,
    seqno: uint32, // the sequence number
}
 
struct GetMoney {
    amount: uint32,
}
 
fun Storage.load() {
    return Storage.fromCell(contract.getData());
}
 
fun Storage.save(self) {
    contract.setData(self.toCell());
}
 
get fun getSeqno() {
    return Storage.load().seqno;
}
 
fun main() {
}
 
fun onExternalMessage(inMsg: slice) {
    var signature = inMsg.loadBits(512);
    val storage = Storage.load();
    var publicKey = storage.publicKey;
    assert (isSignatureValid(inMsg.hash(), signature, publicKey)) throw 35;
 
    var msgSeqno = inMsg.loadUint(32);
    var storedSeqno = storage.seqno;
    assert (msgSeqno == storedSeqno) throw 36;
 
    acceptExternalMessage();
    Storage {
        publicKey: publicKey, seqno: storedSeqno + 1
    }.save();
    commitContractDataAndActions(); // The added commit action
 
    val reply = createMessage({
        bounce: BounceMode.NoBounce,
        value: ton("0.05"),
        body: GetMoney {
            amount: inMsg.loadUint(32)
        },
        dest: address("EQD9ONCYURxDAV4CzRhc_Kw77_-omip_INZUQGOKlHW523p1"), // some address
    });
    reply.send(SEND_MODE_REGULAR);
}

After that, let's verify that the contract is finally safe:

yarn blueprint tsa replay-attack-check -c VulnerableContract -s getSeqno -r 4294967295

Finally, we get the confirmation:

 Vulnerability not found.

Conclusion

In this guide, you used the Replay Attack Checker command of the TSA Blueprint plugin to:

  • Set up TSA security analysis in a Blueprint project
  • Detect, investigate, and fix replay vulnerabilities throughout the contract development process

The TSA plugin offers additional security checkers beyond replay attack analysis — explore them with yarn blueprint tsa --help to strengthen your contract security further.

Keep your contracts safe!