Appearance
Exploiting Signature Verification Vulnerabilities: A Deep Dive into the Impersonator Two CTF Challenge
Introduction
In the rapidly evolving landscape of blockchain security, smart contract vulnerabilities continue to present significant challenges for developers and security professionals alike. The "Impersonator Two" challenge from OpenZeppelin's Ethernaut platform serves as an excellent case study in understanding how seemingly secure signature verification mechanisms can be compromised through careful analysis and exploitation of implementation details. This technical article will dissect the challenge, explore the underlying cryptographic principles, and demonstrate a practical exploitation strategy.
Understanding the Challenge Context
The Impersonator Two Contract Overview
The Impersonator Two contract presents a deceptively simple security challenge: steal all funds from a contract that implements signature-based authorization mechanisms. At first glance, the contract appears to employ standard cryptographic practices using Ethereum's ECDSA (Elliptic Curve Digital Signature Algorithm) for verification. However, as we'll discover, subtle implementation choices create exploitable vulnerabilities.
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Ownable} from "openzeppelin-contracts-08/access/Ownable.sol";
import {ECDSA} from "openzeppelin-contracts-08/utils/cryptography/ECDSA.sol";
import {Strings} from "openzeppelin-contracts-08/utils/Strings.sol";
contract ImpersonatorTwo is Ownable {
using Strings for uint256;
error NotAdmin();
error InvalidSignature();
error FundsLocked();
address public admin;
uint256 public nonce;
bool locked;
constructor() payable {}
}
The contract inherits from OpenZeppelin's Ownable contract, establishing a clear ownership structure. It maintains three key state variables:
admin: The current administrator addressnonce: A counter to prevent replay attackslocked: A boolean flag controlling fund withdrawal
The Authorization Mechanism
The contract implements a sophisticated authorization system where critical operations require signatures from the contract owner. Let's examine the core functions:
solidity
modifier onlyAdmin() {
require(msg.sender == admin, NotAdmin());
_;
}
function setAdmin(bytes memory signature, address newAdmin) public {
string memory message = string(abi.encodePacked("admin", nonce.toString(), newAdmin));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
admin = newAdmin;
}
function switchLock(bytes memory signature) public {
string memory message = string(abi.encodePacked("lock", nonce.toString()));
require(_verify(hash_message(message), signature), InvalidSignature());
nonce++;
locked = !locked;
}
function withdraw() public onlyAdmin {
require(!locked, FundsLocked());
payable(admin).transfer(address(this).balance);
}
The security model appears robust: both setAdmin and switchLock functions require valid signatures from the contract owner, and withdraw is restricted to the admin only when funds are unlocked.
Cryptographic Foundations and Vulnerabilities
ECDSA Signature Verification in Ethereum
To understand the vulnerability, we must first comprehend how Ethereum implements ECDSA signature verification. The contract uses OpenZeppelin's ECDSA library, which provides the recover function to extract the signer's address from a signature.
solidity
function hash_message(string memory message) public pure returns (bytes32) {
return ECDSA.toEthSignedMessageHash(abi.encodePacked(message));
}
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
return ECDSA.recover(hash, signature) == owner();
}
The toEthSignedMessageHash function prepends the Ethereum signed message prefix ("\x19Ethereum Signed Message:\n" + len(message)) before hashing. This is a security measure to prevent signatures from being valid for other purposes.
The Critical Vulnerability: Signature Malleability
The vulnerability in this contract stems from a fundamental property of ECDSA signatures: signature malleability. In ECDSA, if (r, s) is a valid signature, then (r, -s mod n) is also a valid signature for the same message, where n is the order of the elliptic curve.
Ethereum addresses this by requiring the s value to be in the lower half of the curve order (less than n/2 + 1). However, the contract doesn't enforce this check, and more importantly, the signatures provided in the challenge context appear to have been generated without this restriction.
Analyzing the Provided Signatures
Signature Structure in Ethereum
An Ethereum ECDSA signature consists of three components:
r(32 bytes): The x-coordinate on the elliptic curves(32 bytes): The proofv(1 byte): The recovery id (27 or 28 for non-homestead chains)
The challenge provides two signatures:
solidity
bytes memory setAdminSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"1427c398b494f2ebd5e1fb53474b7efe55a5b1852132eae509c695fa7b958597",
uint8(27)
);
bytes memory switchLockSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"2a04aa67c7760a7bec982fde4b387e1e62dc26ba69dd74444e68ffe28851375e",
uint8(28)
);
Signature Analysis
Notice that both signatures share the same r value (e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40). This is unusual but possible in ECDSA when:
- The same nonce (k-value) is reused for different messages
- There's a specific mathematical relationship between the signatures
The v values differ (27 vs 28), indicating different parity in the y-coordinate recovery. More importantly, we need to examine the relationship between the s values.
Mathematical Exploitation Strategy
Understanding the Vulnerability
Given two signatures with the same r value but different s values and different v values, we can derive a crucial relationship. In ECDSA:
- Signature 1:
s1 = k⁻¹(z1 + r*d) mod n - Signature 2:
s2 = k⁻¹(z2 + r*d) mod n
Where:
kis the ephemeral key (nonce)z1,z2are the message hashesdis the private keynis the curve order
Since r is the same, we know k was reused. We can solve for the private key d:
d = (s2*z1 - s1*z2) / (r*(s1 - s2)) mod n
However, in our case, we don't need to recover the private key. The vulnerability is simpler: the contract owner has already signed the necessary messages, and we have those signatures.
The Exploitation Path
The exploitation strategy becomes clear when we analyze the sequence:
- Initial State: The contract is deployed with the player as the owner (based on Ethernaut's setup)
- Owner Actions: The owner has already signed two messages:
- Message 1:
"admin0<player_address>"(to set admin) - Message 2:
"lock1"(to switch the lock)
- Message 1:
- Signature Availability: These signatures are provided in the challenge
- Exploitation: We can use these signatures directly since we're the owner
Step-by-Step Exploitation Implementation
Setting Up the Environment
First, we need to understand the deployment context. In Ethernaut challenges, the player's address typically becomes the contract owner during level instantiation.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {ImpersonatorTwo} from "../test/impersonator-two/ImpersonatorTwo.sol";
contract MyScript is Script {
function run() external {
address IMPERSONATORTWO_INST = address(0x...);
uint256 playerpk = vm.envUint("PRIVATE_KEY");
address player = vm.addr(playerpk);
vm.startBroadcast(playerpk);
// Exploitation code here
vm.stopBroadcast();
}
}
Crafting the Exploit
The key insight is that we can use the provided signatures directly because they were created by the contract owner (which is us in the challenge context).
solidity
// The signatures provided in the challenge
bytes memory setAdminSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"1427c398b494f2ebd5e1fb53474b7efe55a5b1852132eae509c695fa7b958597",
uint8(27)
);
bytes memory switchLockSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"2a04aa67c7760a7bec982fde4b387e1e62dc26ba69dd74444e68ffe28851375e",
uint8(28)
);
Executing the Attack
The complete exploitation sequence involves three steps:
solidity
// Step 1: Set ourselves as admin using the owner's signature
ImpersonatorTwo(IMPERSONATORTWO_INST).setAdmin(setAdminSig, player);
// Step 2: Unlock the funds using the owner's signature
ImpersonatorTwo(IMPERSONATORTWO_INST).switchLock(switchLockSig);
// Step 3: Withdraw all funds as the admin
ImpersonatorTwo(IMPERSONATORTWO_INST).withdraw();
Complete Exploit Script
Here's the complete exploitation script:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {ImpersonatorTwo} from "../test/impersonator-two/ImpersonatorTwo.sol";
contract MyScript is Script {
function run() external {
// Replace with the actual instance address
address IMPERSONATORTWO_INST = address(0x4C20884aDF68C7102a1aeb94FCD973278dD6e9a4);
// Load player's private key from environment
uint256 playerpk = vm.envUint("PRIVATE_KEY");
address player = vm.addr(playerpk);
console.log("Player address:", player);
console.log("Contract address:", IMPERSONATORTWO_INST);
vm.startBroadcast(playerpk);
// Signatures provided in the challenge
bytes memory setAdminSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"1427c398b494f2ebd5e1fb53474b7efe55a5b1852132eae509c695fa7b958597",
uint8(27)
);
bytes memory switchLockSig = abi.encodePacked(
hex"e5648161e95dbf2bfc687b72b745269fa906031e2108118050aba59524a23c40",
hex"2a04aa67c7760a7bec982fde4b387e1e62dc26ba69dd74444e68ffe28851375e",
uint8(28)
);
console.log("Setting player as admin...");
ImpersonatorTwo(IMPERSONATORTWO_INST).setAdmin(setAdminSig, player);
console.log("Switching lock state...");
ImpersonatorTwo(IMPERSONATORTWO_INST).switchLock(switchLockSig);
console.log("Withdrawing funds...");
ImpersonatorTwo(IMPERSONATORTWO_INST).withdraw();
console.log("Exploit completed successfully!");
vm.stopBroadcast();
}
}
Security Analysis and Lessons Learned
What Went Wrong?
Signature Reuse Vulnerability: The contract owner reused the same nonce (
k) for signing two different messages, which is a critical cryptographic error.Lack of Signature Malleability Protection: The contract doesn't verify that
svalues are in the lower half of the curve order.Trust in Pre-signed Messages: The system assumes that pre-signed messages will only be used by the intended party, but in a public blockchain, anyone can submit valid signatures.
Best Practices for Secure Signature Implementation
To prevent similar vulnerabilities, consider these security measures:
solidity
// Improved signature verification with malleability protection
function _verify(bytes32 hash, bytes memory signature) internal view returns (bool) {
address recovered = ECDSA.recover(hash, signature);
// Additional security checks
require(recovered != address(0), "Invalid signature: zero address");
require(recovered == owner(), "Invalid signature: not owner");
// Extract s value and check for malleability
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 0x20))
s := mload(add(signature, 0x40))
v := byte(0, mload(add(signature, 0x60)))
}
// Prevent signature malleability
require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"Invalid signature: s value too high");
return true;
}
Additional Security Recommendations
Use Nonces Properly: Implement a more robust nonce system that prevents any form of replay attack.
Implement Deadline/Timestamp: Add expiration times to signed messages to prevent indefinite validity.
Domain Separation: Use EIP-712 typed structured data hashing for better domain separation and security.
Access Control Layering: Don't rely solely on signatures; implement additional access control measures.
Broader Implications and Real-World Applications
Similar Vulnerabilities in Production Code
The Impersonator Two vulnerability mirrors real-world incidents where signature verification flaws led to significant losses. Notable examples include:
Parity Wallet Hack (2017): Vulnerabilities in multi-signature wallet implementations led to the loss of over $30 million.
Uniswap/Lendf.Me Hack (2020): Reentrancy attacks combined with improper validation led to $25 million in losses.
Poly Network Exploit (2021): A combination of vulnerabilities including signature verification issues led to a $600 million exploit.
The Importance of Formal Verification
This challenge highlights why formal verification and thorough security audits are essential for smart contracts dealing with signatures and cryptographic operations. Tools like:
- MythX: Security analysis platform for Ethereum smart contracts
- Slither: Static analysis framework
- Echidna: Property-based fuzzer for Ethereum
- Certora: Formal verification tool
should be integral parts of the development lifecycle for security-critical contracts.
Conclusion
The Impersonator Two CTF challenge provides valuable insights into the complexities of cryptographic signature verification in smart contracts. While the solution appears straightforward once understood, the underlying lessons are profound:
Cryptographic primitives must be used correctly: Even established libraries like OpenZeppelin's ECDSA require proper implementation.
Defense in depth is crucial: Single points of failure, like signature verification, should be complemented with additional security layers.
Audit and verify: All cryptographic implementations should undergo rigorous security reviews.
Stay updated: Cryptographic best practices evolve; developers must stay informed about new vulnerabilities and mitigation strategies.
As blockchain technology continues to mature, understanding and addressing these fundamental security challenges becomes increasingly important. The Impersonator Two challenge serves as both a warning and a learning opportunity, emphasizing that in the world of smart contract security, details matter, and cryptographic implementations require meticulous attention to prevent catastrophic failures.
Further Reading and Resources
- EIP-712: Ethereum Improvement Proposal for typed structured data hashing
- OpenZeppelin Security Center: Best practices for smart contract security
- Consensys Diligence: Smart contract security best practices
- Trail of Bits: Blockchain security research and tools
- Ethereum Smart Contract Security: Official Ethereum Foundation resources
By studying challenges like Impersonator Two and understanding their underlying principles, developers and security professionals can build more robust and secure blockchain applications, contributing to the overall health and adoption of decentralized technologies.