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.
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:
block.chainid
in the signed data ties the signature to a single, specific blockchain, preventing cross-chain replay attacks.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.
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);
}
}
The attack unfolds as follows:
user
prepares a signed message to claim their reward.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.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.attacker
received the funds, the user's reward balance is now zero, and the nonce has finally been incremented.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
.
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");
}
}
The messageHash
lacks any chain-specific identifier.
address(this)
) creates a valid signature to change the recipient. This action is intended for an initial chain, which we'll call "Chain A".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.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.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.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;
}
}
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:
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!"