Signature Verification Remediation
Overview
Signatures authorize meta-transactions, permits, relays, and governance votes. Verification fails when the contract does not compare the recovered signer to the expected address, builds the hash with abi.encodePacked over dynamic types (which collide), or omits chain and contract binding so a signature replays elsewhere. The fix is EIP-712 typed-data hashing with a domain separator, a nonce, and an explicit signer comparison.
Related Detector: Signature Verification
Recommended Fix
Before (Vulnerable)
function execute(address to, uint256 amount, bytes memory sig) external {
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = ECDSA.recover(hash, sig);
require(signer == owner, "Not owner");
_transfer(to, amount);
}
After (Fixed)
bytes32 constant EXECUTE_TYPEHASH =
keccak256("Execute(address to,uint256 amount,uint256 nonce)");
function execute(address to, uint256 amount, uint256 nonce, bytes memory sig)
external
{
require(!usedNonces[nonce], "Nonce used");
usedNonces[nonce] = true;
bytes32 structHash = keccak256(abi.encode(EXECUTE_TYPEHASH, to, amount, nonce));
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, sig);
require(signer == owner, "Not owner");
_transfer(to, amount);
}
abi.encode with a typehash gives each field a fixed slot, removing the collision risk of packed encoding. _hashTypedDataV4 binds the digest to the contract’s domain separator (chain ID and contract address), and the nonce makes each signature single-use.
Alternative Mitigations
- Verify smart-contract signers with EIP-1271
isValidSignatureso account-abstraction wallets are supported alongside EOAs. - Add an explicit
deadlinefield to the signed struct so authorizations expire. - Reject malleable signatures by enforcing the low-half
svalue and a canonicalv, which a vetted ECDSA library does for you.
Common Mistakes
- Using the recovered address without comparing it to an expected signer;
ecrecoverreturnsaddress(0)for malformed input, so a missing check can accept the zero address. - Reusing the same struct hash across functions, letting a signature for one action authorize another.
- Setting the nonce after the external call, opening a reentrancy window before it is marked used.
- Computing the domain separator once in the constructor and never accounting for a chain fork, where the cached chain ID goes stale.