Skip to main content
Sigvex

Signature Replay Exploit Generator

Sigvex exploit generator that validates missing replay protection in meta-transaction and permit functions by testing same-chain, cross-chain, and cross-contract replay scenarios.

Signature Replay Exploit Generator

Overview

The signature replay exploit generator validates findings from the signature-replay detector by executing the target contract’s signature-based function under four scenarios: first use (baseline), same-chain replay, cross-chain replay (different chain ID), and cross-contract replay (different address). A properly protected contract should allow the first call and reject all three replay attempts. Any replay that succeeds confirms a specific dimension of the vulnerability.

Signature replay vulnerabilities have caused losses across multiple bridge protocols, meta-transaction relayers, and ERC-20 permit() implementations. An attacker who observes a valid signature on-chain can reuse it to transfer funds they were never authorized to touch.

Note: Exploit generation in Sigvex is for vulnerability validation purposes only.

Attack Scenario

Same-chain replay (no nonce):

  1. A user signs a meta-transaction: “transfer 100 tokens to recipient”.
  2. The victim contract processes the signature, verifying it against the message hash but not tracking used signatures or nonces.
  3. The attacker calls executeMetaTx(recipient, 100, v, r, s) again with the same signature.
  4. The contract processes it as a fresh, valid authorization.
  5. The attacker repeats until the signer’s balance is exhausted.

Cross-chain replay (no chain ID):

  1. A user signs a meta-transaction on Ethereum mainnet (chain ID 1).
  2. The same contract is deployed on BSC (chain ID 56). The message hash does not include block.chainid.
  3. The attacker submits the Ethereum signature to the BSC contract.
  4. The BSC contract accepts it — the signature is valid for the identical encoded message without chain binding.

Cross-contract replay (no contract address):

  1. A user authorizes an action on ContractA.
  2. ContractB has the same function signature and does not bind the message hash to address(this).
  3. The attacker replays the signature against ContractB.

Exploit Mechanics

The generator executes four independent runs with different world state configurations:

Scenario chain_id contract_address Storage slot 2 (nonce)
1 — First use 1 contract_a 0
2 — Same-chain replay 1 contract_a 1 (incremented)
3 — Cross-chain 56 (BSC) contract_a 0 (fresh)
4 — Cross-contract 1 contract_b 0 (fresh)

Storage layout:

  • Slot 0: Signer address (authorized)
  • Slot 1: Signer token balance (1000)
  • Slot 2: Nonce for signer
  • Slot 3: Chain ID

Calldata encodes: executeMetaTx(recipient, 100, v=27, r=0xAA..., s=0xBB...) using selector 0x0c53c51c.

Verdict mapping (highest-priority match wins):

  • Scenario 1 succeeds AND Scenario 2 succeeds → same-chain replay (confidence 0.95)
  • Scenario 1 succeeds AND Scenario 3 succeeds → cross-chain replay (confidence 0.95)
  • Scenario 1 succeeds AND Scenario 4 succeeds → cross-contract replay (confidence 0.90)
  • All replay scenarios revert → fully protected

The generated PoC demonstrates all three attack variants with commentary explaining EIP-712 compliance:

// VULNERABLE: No nonce, no chain ID
function executeMetaTx(address recipient, uint256 amount,
                        uint8 v, bytes32 r, bytes32 s) external {
    bytes32 hash = keccak256(abi.encodePacked(recipient, amount));
    address signer = ecrecover(hash, v, r, s);
    // Signature can be replayed indefinitely!
    balances[signer] -= amount;
    balances[recipient] += amount;
}

// SECURE: EIP-712 with nonce + chain ID + contract address
bytes32 structHash = keccak256(abi.encode(
    TYPE_HASH,
    recipient,
    amount,
    nonces[signer]++ // prevents same-chain replay
));
bytes32 digest = keccak256(abi.encodePacked(
    "\x19\x01",
    DOMAIN_SEPARATOR // encodes chainId + address(this)
));
address recovered = ecrecover(digest, v, r, s);

Remediation

All three replay protection elements are required simultaneously:

  1. Nonce: Increment nonces[signer]++ after each valid signature use.
  2. Chain ID: Include block.chainid in the signed message (EIP-712 domain separator).
  3. Contract address: Include address(this) in the domain separator.

Use OpenZeppelin’s EIP712 base contract, which handles the domain separator automatically:

import "@audited/utils/cryptography/EIP712.sol";
import "@audited/utils/cryptography/ECDSA.sol";

contract SafeMetaTx is EIP712 {
    mapping(address => uint256) public nonces;

    constructor() EIP712("MyContract", "1") {}

    function executeMetaTx(
        address recipient, uint256 amount,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        bytes32 structHash = keccak256(
            abi.encode(TYPE_HASH, recipient, amount, nonces[msg.sender]++)
        );
        address signer = ECDSA.recover(_hashTypedDataV4(structHash), v, r, s);
        require(signer == msg.sender, "Invalid signature");
        // proceed with transfer
    }
}

References