Back to blogs
Written by
Hans
Published on
June 10, 2025

Solodit Checklist Explained (9): Replay Attack

Learn how replay attacks exploit valid signatures in smart contracts and how to prevent them using nonces, chain IDs, and domain separators.

Table of Contents

Welcome back to the "Solodit Checklist Explained" series.

Today, we dive into Replay Attack, a threat in which a valid transaction or signature is maliciously duplicated and re-executed. We'll examine two Solodit checklist items (SOL-AM-ReplayAttack-1 and SOL-AM-ReplayAttack-2) to see how these attacks can compromise the integrity of your protocol and how to defend against them effectively.

This is part of the "Solodit Checklist Explained" series. You can find the previous articles here:


For the best experience, open a tab with the Solodit checklist to refer to it as you read. Examples are available on my GitHub here.

Understanding replay attacks

A replay attack occurs when a valid data transmission, such as a signed message or transaction, is maliciously repeated. In smart contracts, an attacker intercepts a legitimate, signed user action. Then they "replay" it later or on a different network to trigger an unintended state change, such as draining funds or executing a permissioned function without authorization.

To defend against these attacks, developers use several critical tools:

  • Nonce (a "number used once”): In blockchain, a nonce is a unique, sequential counter tied to a user's address. By requiring each signed action to include the user's current nonce, the contract can ensure that each action is executed only once. After processing, the nonce is incremented, so the signature can’t be reused.

  • Chain-specific parameters: With the rise of multiple Ethereum Virtual Machine (EVM)-compatible blockchains (e.g., Ethereum, Polygon, Arbitrum, etc.), a user's private key and address are often the same across networks. A signature created for a contract on Ethereum could be valid for an identical contract on Polygon if it doesn't contain chain-specific data. Including the block.chainid in the signed data ties the signature to a single, specific blockchain, preventing cross-chain replay attacks.

  • Domain separator: This is a cryptographic mechanism, standardized in EIP-712, that binds a signature to a specific application context. It's a unique hash containing information like the contract's name, version, address, and the chainid. This ensures a signature intended for one decentralized application (DApp) cannot be replayed in another, providing robust protection against both cross-chain and cross-DApp replay attacks.

Now, let's explore how to apply these mechanisms to specific vulnerabilities.

SOL-AM-ReplayAttack-1: Are there protections against replay attacks for failed transactions?

Description: Failed transactions can become susceptible to replay attacks if not properly protected.

Remediation: Implement nonce-based or other mechanisms to ensure that each transaction can only be executed once. This prevents replay attacks, even if the transaction initially failed.

This check focuses on a common implementation flaw: only incrementing a user's nonce after a transaction succeeds. If a transaction fails due to a temporary condition (e.g., the contract lacks sufficient funds), the nonce remains unchanged. The signed message is still valid and can be replayed by anyone once the condition is resolved.

Consider this RewardSystem contract, which allows users to claim rewards they've earned.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


import "openzeppelin-contracts/contracts/access/Ownable.sol";
contract RewardSystem is Ownable {
    mapping(address => uint256) public rewards;
    mapping(address => uint256) public nonces;


    constructor() Ownable(msg.sender) {}


    // For PoC simplicity, we don't use real signatures
    function claimReward(address user, uint256 amount, uint256 nonce, bytes memory signature) external {
        require(rewards[user] >= amount, "Insufficient reward balance");
        require(nonces[user] == nonce, "Invalid nonce");


        // Vulnerability: Signature can be replayed once contract has funds
        bytes32 messageHash = keccak256(abi.encode(
            user,
            amount,
            nonce
        ));


        // For PoC, use a simple signature check
        bytes32 signedHash = abi.decode(signature, (bytes32));
        require(signedHash == messageHash, "Invalid signature");


        // Attempt to transfer reward
        rewards[user] -= amount;
        (bool success,) = msg.sender.call{value: amount}("");


        // Vulnerability: Nonce is only incremented if transfer succeeds
        if (success) {
            nonces[user]++;
        } else {
            revert("Transfer failed");
        }
    }


    // Helper function to add rewards - only owner can call
    function addReward(address user, uint256 amount) external onlyOwner {
        rewards[user] += amount;
    }


    // Helper function to receive ETH
    receive() external payable {}
}


The claimReward function only increments the user's nonce if the ETH transfer is successful. If the contract has no ETH, the msg.sender.call will fail, the transaction will revert, and nonces[user] will not be incremented. The user's signature, which was valid for that nonce, remains valid.

An attacker can monitor these failed claims. Once the RewardSystem contract is funded, the attacker can replay the user's original transaction, directing the reward to their own address.

This Forge test illustrates the attack:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


import "forge-std/Test.sol";
// Assume RewardSystem contract is defined above or imported


contract ReplayAttackTest is Test {
    RewardSystem public rewardSystem;
    address public user = address(1);
    address public attacker = address(2);
    uint256 constant REWARD_AMOUNT = 1 ether;


    function setUp() public {
        vm.prank(address(this));
        rewardSystem = new RewardSystem();
        // Setup initial reward for user
        rewardSystem.addReward(user, REWARD_AMOUNT);
    }


    function testReplayAttack() public {
        uint256 nonce = rewardSystem.nonces(user);


        // 1. User creates a signature to claim their reward.
        // For this PoC, the "signature" is just the message hash.
        bytes32 messageHash = keccak256(abi.encode(user, REWARD_AMOUNT, nonce));
        bytes memory signature = abi.encode(messageHash);


        // 2. User attempts to claim, but the transaction fails because
        // the contract has no ETH to send. The user's nonce is not incremented.
        vm.prank(user);
        vm.expectRevert("Transfer failed");
        rewardSystem.claimReward(user, REWARD_AMOUNT, nonce, signature);
        assertEq(rewardSystem.nonces(user), nonce, "Nonce should not increment on failure");


        // 3. The contract is funded by the owner or another source.
        vm.deal(address(rewardSystem), REWARD_AMOUNT);


        // 4. An attacker, who was monitoring, replays the user's valid signature.
        // The attacker is the msg.sender, so the reward is sent to them.
        vm.prank(attacker);
        rewardSystem.claimReward(user, REWARD_AMOUNT, nonce, signature);


        // 5. The attacker successfully steals the user's reward.
        assertEq(address(attacker).balance, REWARD_AMOUNT);
        assertEq(rewardSystem.rewards(user), 0);
        // Nonce is now incremented
        assertEq(rewardSystem.nonces(user), nonce + 1);
    }
}

How the attack works

The attack unfolds as follows:

  1. Signature creation: A legitimate user prepares a signed message to claim their reward.

  2. Failed claim: The user attempts to claim their reward, but the transaction reverts because the RewardSystem contract has no ETH to send them. Because the transaction reverts before the nonces[user]++ line is executed, the user's nonce remains unchanged. The signature is still valid for that nonce.

  3. Condition change: Later, the contract is funded, satisfying the condition that caused the initial failure.

  4. The replay: An attacker who observed the initial failed transaction now replays it. The signature is still valid because the nonce matches. The claimReward function deducts the reward from the user's balance but sends the ETH to the msg.sender of this transaction, which is the attacker.

  5. Successful theft: The attack succeeds. The test confirms the attacker received the funds, the user's reward balance is now zero, and the nonce has finally been incremented.

Remediation: consume the nonce in every execution path

Ensure a nonce is consumed (marked as used) in every execution path, whether it succeeds or fails. The best practice is to consume the nonce immediately after validating it and ensure the transaction does not revert after that point. This way, even if the transaction fails, the nonce is still incremented, preventing replay attacks.

// Corrected claimReward function (inside RewardSystem)
function claimReward(address user, uint256 amount, uint256 nonce, bytes memory signature) external {
    require(rewards[user] >= amount, "Insufficient reward balance");
    require(nonces[user] == nonce, "Invalid nonce");


    // For PoC, use a simple signature check
    bytes32 messageHash = keccak256(abi.encode(user, amount, nonce));
    bytes32 signedHash = abi.decode(signature, (bytes32));
    require(signedHash == messageHash, "Invalid signature");


    // REMEDIATION 1: Consume nonce right after validating it
    nonces[user]++;


    rewards[user] -= amount;
    (bool success,) = msg.sender.call{value: amount}("");


    if (!success) {
        // Revert the reward deduction if transfer failed
        rewards[user] += amount;
    }
    // REMEDIATION 2: No revert to ensure nonce is consumed
}


With this fix, even if the transfer fails, the nonce is invalidated. The attacker cannot replay the signature because the contract will now expect nonce + 1.

SOL-AM-ReplayAttack-2: Is there protection against replaying signatures on different chains?

Description: Signatures that are valid on one blockchain may be replayed on another, leading to potential security breaches.

Remediation: Use chain-specific parameters, such as block.chainid, or domain separators as defined in EIP-712 to ensure signatures are only valid on the intended chain.

This check item addresses a critical multi-chain vulnerability. Because a user's private key controls their address on all EVM chains, a signature created for a contract on Ethereum can be replayed on an identical deployment of that contract on Polygon, Arbitrum, or any other EVM chain. If the signature doesn't include the chain ID, it becomes a universal key that an attacker can use across networks.

Let's examine a VulnerableVault contract designed to let its owner change a recipient via a signed message.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


contract VulnerableVault {
    address public owner;
    address public recipient;
    mapping(bytes32 => bool) public isConsumed;


    constructor(address _owner, address _recipient) {
        owner = _owner;
        recipient = _recipient;
    }


    function changeRecipient(address _newRecipient, uint256 _expiry, bytes memory _signature) external {
        require(block.timestamp <= _expiry, "Signature expired");


        // VULNERABILITY: Missing chain ID in the signature data
        bytes32 messageHash = keccak256(abi.encode(
            msg.sender, // In the PoC, this is the test contract's address
            _newRecipient,
            _expiry
        ));


        // For PoC, use a simple signature check
        bytes32 signedHash = abi.decode(_signature, (bytes32));
        require(signedHash == messageHash, "Invalid signature");
        require(!isConsumed[messageHash], "Signature already used");


        isConsumed[messageHash] = true;
        recipient = _newRecipient;
    }
}

The messageHash includes the new recipient and an expiry timestamp, but it's missing block.chainid. This means a signature generated for this action is portable, i.e. an attacker can take a valid signature from one chain and use it on another.

The attack scenario assumes the VulnerableVault is deployed on both Ethereum (Chain A) and Polygon (Chain B).

This Forge test simulates the attack:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;


import "forge-std/Test.sol";
// Assume VulnerableVault contract is defined above or imported


contract ReplayAttackTest is Test {
    VulnerableVault public vault;
    address owner = address(1);
    address recipient = address(2);
    address newRecipient = address(3);
    uint256 expiry;


    function setUp() public {
        expiry = block.timestamp + 1 hours;
        vault = new VulnerableVault(owner, recipient);
    }


    function testCrossChainReplayAttack() public {
        // 1. Owner creates a signature to change the recipient on "Chain A".
        bytes32 messageHash = keccak256(abi.encode(
            address(this),
            newRecipient,
            expiry
        ));
        bytes memory signature = abi.encode(messageHash);


        // 2. The transaction is executed on "Chain A".
        vm.prank(address(this));
        vault.changeRecipient(newRecipient, expiry, signature);
        assertEq(vault.recipient(), newRecipient, "Failed on Chain A");
        assertTrue(vault.isConsumed(messageHash), "Signature should be consumed on Chain A");


        // 3. Simulate moving to "Chain B" by manually resetting the state.
        vm.store(
            address(vault),
            keccak256(abi.encode(messageHash, uint256(2))), // Storage slot for isConsumed[messageHash]
            bytes32(0)
        );
        assertFalse(vault.isConsumed(messageHash), "Simulated reset for Chain B failed");


        // 4. Attacker replays the same signature on "Chain B".
        vm.prank(address(this));
        vault.changeRecipient(newRecipient, expiry, signature);


        // 5. The attack succeeds on "Chain B".
        assertEq(vault.recipient(), newRecipient, "Replay on Chain B should succeed");
    }
}

How the attack works

The messageHash lacks any chain-specific identifier.

  1. Signature for "Chain A": The owner (simulated by the test contract address(this)) creates a valid signature to change the recipient. This action is intended for an initial chain, which we'll call "Chain A".

  2. Execution on "Chain A": The transaction is successfully executed on "Chain A". The test verifies that the recipient has been updated and, importantly, the isConsumed mapping has been set to true for that signature's hash, preventing a replay on this specific chain.

  3. Simulating "Chain B": This is the key part of the simulation. A real second chain ("Chain B") would have its own independent state for the contract, where isConsumed would naturally be false. To mimic this in a single test environment, we use Foundry's vm.store cheatcode to directly write to the contract's storage and reset isConsumed[messageHash] to false. This effectively places our test environment in the state of "Chain B", where the signature has never been seen before.

  4. Replaying on "Chain B": The attacker submits the original signature to the contract in the simulated state of our "Chain B". Since the signature is not chain-specific and is not marked as consumed on this chain, the transaction is accepted as valid.

  5. Successful attack: The test confirms the recipient was changed again, but this time without the owner's intent for this chain. The attack succeeded because the signature's validity was not confined to its intended network.

Remediation: embed chain-specific data in signatures

The direct fix is to include block.chainid in the data that gets hashed and signed. This permanently binds the signature to a single chain. For a more robust and standardized solution, adopt EIP-712, which builds this protection directly into its structure via the domain separator.

Here is the simple fix applied to a new SecureVault contract.

// Corrected changeRecipient function (inside a new SecureVault contract)
contract SecureVault {
    // ... same state variables and constructor as VulnerableVault ...
    address public owner;
    address public recipient;
    mapping(bytes32 => bool) public isConsumed;


    constructor(address _owner, address _recipient) {
        owner = _owner;
        recipient = _recipient;
    }


    function changeRecipient(address _newRecipient, uint256 _expiry, bytes memory _signature) external {
        require(block.timestamp <= _expiry, "Signature expired");


        // REMEDIATION: Include the chain ID to make the signature chain-specific.
        bytes32 messageHash = keccak256(abi.encode(
            msg.sender,
            _newRecipient,
            _expiry,
            block.chainid // Added chain ID
        ));


        // For PoC, use a simple signature check
        bytes32 signedHash = abi.decode(_signature, (bytes32));
        require(signedHash == messageHash, "Invalid signature");
        require(!isConsumed[messageHash], "Signature already used");


        isConsumed[messageHash] = true;
        recipient = _newRecipient;
    }
}

Conclusion

Replay attacks exploit valid user intentions in invalid contexts. By understanding how attackers can duplicate actions across time or across chains, we can build more precise and secure validation logic into our contracts.

Developing secure contracts requires an adversarial perspective:

  • Always ask: Have I fully invalidated this signature after use? A nonce should be consumed regardless of whether the transaction succeeds or fails.

  • Interrogate the signature's context: Where is this signature valid? If your protocol operates or may operate on multiple chains, every off-chain signature must be bound to a specific chain via block.chainid or an EIP-712 domain separator.

  • Validate every assumption: Does my code assume a transaction will succeed? Does it assume a signature is only for one network? Proactively identifying and closing these implicit assumptions is key to preventing replay attacks.


By rigorously applying these principles and leveraging tools like the Solodit checklist, you can build systems that are resilient to these subtle but dangerous exploits.

Stay tuned for the next installment of "Solodit Checklist Explained!"

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.