Back to Blog

안전한 스마트 컨트랙트 개발 (2) — NFT (마켓플레이스)에서 디지털 서명을 올바르게 사용하는 방법

August 12, 2022
6 min read

1. 소개

디지털 서명은 진위성과 무결성을 보장하기 위해 사용됩니다. 이 글에서 설명한 것처럼, "전제 조건이 충족된 유효한 디지털 서명은 수신자에게 메시지가 알려진 발신자에 의해 생성되었다는 높은 신뢰(진위성)와 전송 중 메시지가 변조되지 않았다는 확신(무결성)을 제공합니다."

디지털 서명은 허용 목록 민팅(allowlist mint) 및 주문서 기반 NFT 마켓플레이스 등 스마트 컨트랙트에서 널리 사용되고 있습니다. 이는 트랜잭션 비용을 절감하는 데 도움이 되기 때문입니다(오프체인 서명 및 온체인 검증). 그러나 개발자의 잘못된 사용은 NFT 마켓플레이스에 위험을 초래하기도 합니다. 이 블로그에서는 NFT 생태계에서 디지털 서명의 잘못된 사용에 대해 이야기하고자 합니다.

2. 응용 사례

디지털 서명은 NFT 컨트랙트에서 허용 목록 민팅(유효한 서명을 가진 사용자만 NFT를 민팅할 수 있음)과 NFT 마켓에서 주문 검증(예상된 서명이 있는 주문만 실행 가능)에 널리 사용되고 있습니다. 데이터 서명은 가스를 절약하기 위해 오프체인으로 이루어집니다. 아래에서 이 두 가지 사용 시나리오를 설명하겠습니다.

2.1. 허용 목록 민팅

"NFT 민팅"은 블록체인에서 NFT를 생성하는 절차입니다. 대부분의 NFT 프로젝트는 제품을 널리 알리고자 하며, 허용 목록 민팅(프리세일 등이라고도 함)을 통해 사용자를 유인하는 방식을 선호합니다. 자리를 획득한 사람들은 더 낮은 가격(심지어 무료)으로 토큰을 민팅할 수 있습니다. 디지털 서명은 허용 목록 민터와 일반(공개) 민터를 구별하는 데 사용됩니다. 아래는 허용 목록 민팅 구현의 예시입니다.

function mint_approved(
        vData memory info,
        uint256 number_of_items_requested,
        uint16 _batchNumber
    ) external {
        ...
        require(verify(info), "Unauthorised access secret");
        ...
    }
    function verify(vData memory info) public view returns (bool) {
        require(info.from != address(0), "INVALID_SIGNER");
        bytes memory cat =
            abi.encode(
                info.from,
                info.start,
                info.end,
                info.eth_price,
                info.dust_price,
                info.max_mint,
                info.mint_free
            );
        bytes32 hash = keccak256(cat);
        require(info.signature.length == 65, "Invalid signature length");
        bytes32 sigR;
        bytes32 sigS;
        uint8 sigV;
        bytes memory signature = info.signature;        assembly {
            sigR := mload(add(signature, 0x20))
            sigS := mload(add(signature, 0x40))
            sigV := byte(0, mload(add(signature, 0x60)))
        }        bytes32 data =
            keccak256(
                abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
            );
        address recovered = ecrecover(data, sigV, sigR, sigS);
        return signer == recovered;
    }

이 코드 스니펫은 Association NFT에서 가져온 것입니다(취약점이 있으므로 이 코드를 복사하지 마십시오). mint_approved() 함수는 허용 목록 민팅을 구현하려는 의도로 작성되었습니다: 프로젝트 소유자가 민팅 메시지(info 변수)에 서명하고 허가된 민터(NFT를 민팅할 수 있는 사람)에게 해당 메시지를 전송합니다. 그러면 민터는 서명된 변수와 함께 approved_mint를 호출할 수 있습니다. 컨트랙트는 메시지가 프로젝트에 의해 서명되었는지 여부를 검증합니다(signer == recovered). 만약 그렇다면, 함수를 호출한 사람은 NFT를 민팅할 수 있습니다(그러나 함수를 호출한 사람이 실제로 허용 목록에 있는 사람인지 여부를 검증하지 않으므로 이는 안전하지 않습니다).

2.2. 주문 검증

주문 검증은 NFT 생태계에서 디지털 서명의 또 다른 응용 사례입니다. NFT 마켓플레이스는 NFT의 거래 기능을 제공하므로 NFT 생태계에서 중요한 역할을 합니다. 각 NFT 토큰은 대체 불가능하기 때문에 자동화된 시장 조성자(AMM) 거래 방식을 NFT 마켓에 적용하기 어렵습니다. 따라서 대부분의 NFT 마켓플레이스, 예를 들어 OpenSea, LooksRare, X2Y2는 주문서 거래 모델을 채택하고 있습니다.

주문서 거래는 간단합니다. 특정 가격에 자산을 팔고자 하는 메이커(maker)와 판매자의 가격에 자산을 구매하고자 하는 테이커(taker)가 있습니다. 이 경우 주문이 매칭됩니다. 주문서 기반 NFT 마켓플레이스에서도 동일한 과정이 이루어집니다. 유일한 차이점은 주문 제시 과정으로, NFT 마켓플레이스는 주문 검증을 위해 디지털 서명을 사용합니다. 그림 1은 주문서 기반 마켓플레이스 중 하나인 OpenSea의 전체 거래 과정 예시를 보여줍니다.

**그림 1. OpenSea 거래 프로세스**
그림 1. OpenSea 거래 프로세스

구체적으로, 판매자는 판매 주문에 서명하고 이를 OpenSea 서버에 저장합니다. 구매자는 OpenSea 서버에서 서명된 판매 주문 정보를 가져와 서명된 판매 주문을 매개변수로 하여 NFT 마켓 컨트랙트를 호출할 수 있습니다. 마켓 컨트랙트는 판매자가 판매 주문에 서명했는지 확인하기 위해 주문을 검증합니다(구매자가 트랜잭션을 시작하기 때문입니다). — 이는 판매자의 동의 없이 구매자가 자산을 구매하는 것을 방지하기 위함입니다.

3. 보안 사고

호튼 원칙(Horton Principle)은 암호화 시스템에 대한 격언으로, "말하는 것이 아니라 의미하는 것을 인증하라" 또는 "서명하는 것을 의미하고, 의미하는 것을 서명하라"로 표현될 수 있으며, 행동을 완전하고 정확하게 서명할 것을 요구합니다. 서명이 부분적이거나 부정확한 경우 그 결과는 재앙적일 수 있습니다.

3.1 Association NFT

2.1절의 NBA NFT 컨트랙트를 다시 살펴보겠습니다. verify 함수는 표준 서명 검증을 수행하지만, 하나의 중요한 구성 요소가 누락되어 있습니다. 서명 검증은 메시지가 프로젝트에 의해 서명되었음을 보장하지만, 컨트랙트에 서명을 제공하는 사람이 서명된 메시지의 허용 목록 민터와 일치하는지에 대한 강제 검증이 없습니다. 결과적으로 누구든지 동일한 서명을 사용하여 검증을 통과하고 NFT를 민팅할 수 있습니다.

3.2 OpenSea

또 다른 보안 문제는 OpenSea와 관련이 있습니다. 2022년 초, 연구자들은 NFT 거래의 핵심 기능을 구현하는 OpenSea 마켓플레이스 컨트랙트(버전: wyvern 2.2)의 잠재적 취약점을 공개했습니다.

Wyvern 프로토콜에서 사용자는 오프체인에서 리스팅(판매 제안) 또는 오퍼(구매 제안)를 작성하며, 오퍼의 서명은 온체인에서 검증됩니다. Wyvern 오퍼에는 많은 매개변수가 포함되어 있으며, 이 매개변수들은 오퍼의 다이제스트를 계산하기 위해 단일 바이트 문자열로 집계됩니다. 그런 다음 컨트랙트는 다이제스트의 서명을 검증합니다. 매개변수 집계 방법은 다음 방법을 사용하여 매개변수를 바이트 문자열로 단순히 패킹합니다.

index = ArrayUtils.unsafeWriteAddress(index, order.target);
index = ArrayUtils.unsafeWriteUint8(index, uint8(order.howToCall));
index = ArrayUtils.unsafeWriteBytes(index, order.calldata);
index = ArrayUtils.unsafeWriteBytes(index, order.replacementPattern);
index = ArrayUtils.unsafeWriteAddress(index, order.staticTarget);
index = ArrayUtils.unsafeWriteBytes(index, order.staticExtradata);
index = ArrayUtils.unsafeWriteAddress(index, order.paymentToken);

예를 들어, 매개변수가 2개의 구성 요소 (address, bytes)로 구성되고, 매개변수가 (0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xc098")인 경우, 집계된 바이트는 0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fc098, 즉 address + bytes가 됩니다. 간단하고 명확해 보이죠?

이제 더 복잡한 예시를 생각해봅시다. 매개변수의 구조가 (address, bytes, bytes)인 경우입니다.

매개변수 1은 _(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xab", "0xcdef")_입니다.

매개변수 2는 _(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xabcd", "0xef")_입니다.

집계된 바이트는 다음과 같습니다:

매개변수 1: _0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.

매개변수 2: _0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.

이런! 두 개의 서로 다른 매개변수가 동일한 집계 결과를 가지며, 이는 두 다이제스트가 동일하다는 것을 의미합니다. 결과적으로 하나의 서명으로 두 개의 서로 다른 매개변수를 검증할 수 있게 됩니다.

이는 매개변수에 가변 길이 구성 요소가 많기 때문입니다. 공격자는 변수의 일부를 잘라내어 이전 또는 이후 구성 요소에 붙일 수 있습니다. 안타깝게도 Wyvern 컨트랙트에는 아래와 같이 가변 길이 매개변수가 많이 포함되어 있습니다.

......
    address target;
    /* HowToCall. */
    AuthenticatedProxy.HowToCall howToCall;
    /* Calldata. */
    bytes calldata;
    /* Calldata replacement pattern, or an empty byte array for no replacement. */
    bytes replacementPattern;
    /* Static call target, zero-address for no static call. */
    address staticTarget;
    /* Static call extra data. */
    bytes staticExtradata;
    ......

이 취약점의 영향으로 공격자는 (가능한 경우) 피해자의 계정을 제어하여 일부 악의적인 행동을 실행할 수 있습니다. 취약점에 대한 자세한 분석은 여기에서 확인할 수 있습니다.

이 섹션에서 언급된 두 가지 보안 사고는 모두 호튼 원칙을 위반합니다. 구체적으로, NBA 컨트랙트는 서명된 메시지에 민터를 포함하지 않았으며(또는 서명된 메시지에 포함된 정보와 실제 호출자의 일관성을 확인하지 않았으며), Wyvern 컨트랙트는 구조가 없는 매개변수에 서명하여 매개변수의 표현(말하는 것)은 유지되면서 행동의 의미가 변경될 수 있었습니다.

4. 제안 사항

호튼 원칙을 따르고, 말하는 것이 아닌 의미하는 것을 서명하십시오. 서명에는 필요한 포괄적이고 정확한 정보가 모두 포함되어야 합니다.

  • 검증이 필요한 모든 정보를 서명에 포함하십시오. 서명된 메시지의 데이터와 런타임 값(예: 서명된 메시지의 의도된 사용자와 실제 사용자)의 일관성을 확인하십시오.
  • 서명할 메시지는 결정론적으로 인코딩되어야 합니다. 예를 들어, 구조가 다르지만 동일한 인코딩 결과를 가지는 메시지가 존재해서는 안 됩니다.

이 시리즈의 다른 글 읽기

Sign up for the latest updates
~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리
Security Insights

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 22~28일 발생한 주요 사건 2건을 다루며, 이더리움과 카르다노에서 약 410만 달러의 피해가 확인됐습니다. Taiko 브릿지 공격은 노출된 SGX 서명 키와 디버그 엔클레이브를 거부하지 못한 증명 정책 결함을 이용해 악성 증명자를 등록하고 L2 상태 증명을 위조했습니다. SecondFi 지갑은 Ed25519 논스 도출 시 비밀 입력이 제거되는 결함으로 공개 트랜잭션 데이터만으로 개인 키 복구가 가능했습니다.

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리
Security Insights

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리

이 주간 블록체인 보안 보고서는 2026년 6월 15일~21일을 다루며, 이더리움과 BNB 체인에서 3건의 주요 사고가 발생해 약 $18.3M의 손실이 발생했습니다. jaredFromSubway 사건은 MEV 봇이 차익거래를 위해 신뢰할 수 없는 제3자 컨트랙트에 자산을 승인한 역방향 승인 공격으로, 가짜 래퍼 토큰과 스왑 풀을 이용해 약 $15M 손실이 발생했습니다. Aztec은 이스케이프 해치 ZK 회로의 제약 누락으로 공격자가 가짜 머클 트리로 온체인 검증을 통과했습니다.

~$598만 달러 손실: Aztec, Raydium 등 | BlockSec 위클리
Security Insights

~$598만 달러 손실: Aztec, Raydium 등 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 8일~14일을 다루며, 이더리움과 솔라나에서 발생한 4건의 주요 사고를 분석하고 총 손실액은 약 598만 달러입니다. Aztec Connect의 입력 검증 누락으로 롤업 증명 경로와 L1 정산 불일치가 발생했고, Raydium의 레거시 AMM v3 검증 누락으로 LP 토큰 상환 계산이 조작되어 4개 풀이 탈취됐습니다. 두 취약점 모두 수년간 노출된 상태였습니다. 입력 검증 부재, 정수 오버플로우, 거버넌스 탈취 등의 공격 유형을 다룹니다.