Signature Replay — Missing Nonce
A permit-style transfer contract authorizes token transfers via ECDSA signatures. The signed message omits a nonce and chainId, so any valid signature can be submitted repeatedly on the same chain or replayed on another network where the contract is deployed.
~7 min read
Solidity uses ECDSA signatures to let an off-chain signer authorize on-chain actions. But a signature is just bytes — without a nonce or chainId in the signed message, it can be replayed indefinitely. This lab's contract lets anyone submit a valid signature multiple times, draining the token balance.
1. ECDSA signatures on Ethereum
ECDSA (Elliptic Curve Digital Signature Algorithm) lets a private key sign a hash and anyone with the public key verify it. In Solidity, ecrecover(hash, v, r, s) returns the address of the signer. If the recovered address matches an authorized signer, the action is permitted.
A naive authorization flow: signer signs keccak256(abi.encodePacked(recipient, amount)) off-chain. User submits the signature on-chain. Contract recovers the signer and executes the transfer. The problem: the same (recipient, amount) pair always produces the same hash → the same signature is always valid → it can be submitted N times.
A nonce is a per-signer counter incremented after each use. The signed message includes the nonce: keccak256(abi.encodePacked(recipient, amount, nonces[signer])). After execution, nonces[signer]++ makes the hash unique for that usage. A chainId prevents the same signature from being valid on a different network where the contract is deployed at the same address.
2. EIP-712 — typed structured data signing
EIP-712 is the standard for typed structured data signing. It defines a domain separator (includes contract address, chainId, verifying contract name and version) that is mixed into every signed hash, preventing cross-domain and cross-chain replay. MetaMask and hardware wallets show human-readable EIP-712 data to users instead of raw bytes.
domain separator = keccak256(abi.encode(DOMAIN_TYPEHASH, name, version, block.chainid, address(this))). The message hash includes both the domain separator and the typed struct hash. Any change to the domain (network, contract address) produces a different final hash, invalidating the signature.
3. The attack — same hash, infinite replays
The VulnerablePermitTransfer contract signs over (to, amount) with no nonce and no chainId. Attack pseudocode:
// Off-chain: legitimate signer authorizes one transfer
hash = keccak256(abi.encodePacked(attacker, 100e18))
signature = sign(hash, signerPrivKey)
// On-chain: attacker submits repeatedly
executeTransfer(attacker, 100e18, signature) // succeeds
executeTransfer(attacker, 100e18, signature) // succeeds again
executeTransfer(attacker, 100e18, signature) // and again...Every call recovers the same signer address (ecrecover succeeds) because the hash is identical. There is no consumed-nonce check. The transfer executes as many times as the contract has balance.
Attack timeline
- 1
- 2
- 3
- 4
- 5
4. The fix — nonce + EIP-712
Add a per-signer nonce mapping, include the nonce in the signed hash, and increment it after each execution. Use EIP-712 domain separation to prevent cross-chain replay:
For production contracts, use the full EIP-712 EIP-712 typed-data standard from OpenZeppelin. The domain separator includes contractName, version, chainId, and verifyingContract, making every signed hash domain-specific and chain-specific.
Never roll your own signing scheme. Use OpenZeppelin's EIP712 base contract. Audit the hash construction: every field that scopes the authorization (who, what, how much, nonce, chain) must be included.
5. What to look for as an auditor
- Find every call to ecrecover or ECDSA.recover(). For each, check: is the signed hash unique per-usage? Does it include a nonce? A chainId or domain separator?
- Look for a nonces mapping and its increment. If there is no nonce mapping, or the nonce is not included in the hash, the signature is replayable on the same chain.
- Check for chainId or domain separator in the hash. Its absence means cross-chain replay is possible on any chain where the contract is deployed at the same address (common with CREATE2 or multi-chain deployments).
- Check the abi.encodePacked encoding. abi.encodePacked can produce hash collisions for variable-length arguments (two fields that concatenate to the same bytes). Prefer abi.encode with typed field boundaries.
- Permit functions (ERC-2612) must implement the full EIP-712 domain + nonce pattern. Audit any custom permit-like function the same way.
- Signature malleability: Ethereum allows two valid (v, r, s) forms for the same signature. If your contract caches signatures by their bytes (not by the derived address + nonce), this can be exploited. OpenZeppelin ECDSA guards against this.
With the replay model clear, open the Hunt tab. Find what is missing from the signed hash, then describe how an attacker exploits the missing nonce and chainId.