Signature Malleability Exploit Generator
Sigvex exploit generator that validates ECDSA signature malleability vulnerabilities by computing the alternative valid s value and confirming that both signatures recover to the same address.
Signature Malleability Exploit Generator
Overview
The signature malleability exploit generator validates findings from the signature-malleability, ecrecover, and related detectors by computing the mathematically equivalent alternative ECDSA signature for any given (r, s, v) tuple and confirming the vulnerability exists. If a contract uses signature hashes as nonces but does not enforce EIP-2 compliance (low-s requirement), an attacker can replay the same logical signature by substituting the malleable form.
ECDSA signatures on the secp256k1 curve have an inherent malleability property: for any valid signature (r, s, v), the tuple (r, n - s, v') where n is the curve order is also a valid signature for the same message and signer. Both signatures recover to the same Ethereum address via ecrecover. This means any system that tracks “used signatures” by hashing (r, s, v) can be replayed using the alternate form.
Note: Exploit generation in Sigvex is for vulnerability validation purposes only.
Attack Scenario
- A contract allows one-time operations validated by signature:
require(!usedSignatures[sigHash], "Used"). - The contract tracks the signature itself as the nonce:
sigHash = keccak256(abi.encodePacked(r, s, v)). - The owner signs a privileged operation. The attacker observes the signature
(r, s, v)on-chain. - The attacker computes
s' = n - s(wherenis the secp256k1 curve order:0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) and flipsv(27↔28). - The attacker submits
(r, s', v').ecrecoverreturns the same owner address, sosigner == ownerpasses. sigHash' = keccak256(r, s', v')is different fromsigHash, so!usedSignatures[sigHash']is true.- The operation executes a second time, bypassing the replay protection.
Exploit Mechanics
The generator computes the malleable signature mathematically:
secp256k1 curve order n =
115792089237316195423570985008687907852837564279074904382605163141518161494337
s_original = 5 × 10^16 (example low-s value)
s_malleable = n - s_original
Both (r, s_original, v) and (r, s_malleable, v') are valid for the same message and signer.
If s_original == s_malleable (impossible in practice), the finding is rejected. Otherwise the generator raises a confirmed finding with the evidence map containing:
s_original: The originalscomponents_malleable:n - s_originalcurve_order_n: The secp256k1 ordereip2_compliant: Whethers <= n/2(low-s requirement per EIP-2)
The generator also checks whether the signature is EIP-2 compliant (low-s). Even with low-s, contracts that hash the full signature as a nonce are still vulnerable because the high-s alternate form is a different hash.
The PoC demonstrates the full attack via SignatureMalleabilityExploit.fullAttack():
function fullAttack(bytes32 messageHash, uint8 v, bytes32 r, bytes32 s) public {
// Execute original signature (count: 0 → 1)
target.executeWithSignature(messageHash, v, r, s);
// Compute malleable signature
uint256 s_malleable = CURVE_ORDER - uint256(s);
uint8 v_malleable = v == 27 ? 28 : 27;
// Execute malleable signature (count: 1 → 2)
// Different sigHash, same logical operation, same signer
target.executeWithSignature(messageHash, v_malleable, r, bytes32(s_malleable));
}
Remediation
- Detector: Signature Malleability Detector
- Remediation Guide: Signature Malleability Remediation
The most effective remediation combines EIP-2 low-s enforcement with tracking message hashes instead of signature hashes:
// SECURE: EIP-2 check + message hash nonce
function executeWithSignature(bytes32 messageHash, uint8 v, bytes32 r, bytes32 s) public {
// EIP-2: Require low-s value (eliminates one of the two valid forms)
require(
uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"Malleable signature"
);
require(v == 27 || v == 28, "Invalid v");
address signer = ecrecover(messageHash, v, r, s);
require(signer == owner && signer != address(0), "Invalid signer");
// Track MESSAGE hash as nonce, not SIGNATURE hash
require(!executedMessages[messageHash], "Already executed");
executedMessages[messageHash] = true;
}
The preferred approach is to use OpenZeppelin’s ECDSA.recover(), which enforces the EIP-2 low-s check automatically. For multi-step workflows, include a sequential nonce inside the signed payload:
// Include nonce in the signed data (prevents all replay forms)
bytes32 messageHash = keccak256(abi.encodePacked(
message,
nonce++,
block.chainid,
address(this)
));