Introduction

오늘은 오픈씨가 이전에 사용하던 Wyvern 프로토콜의 로직과 취약점을 살펴보겠다. Wyvern 프토콜은 EIP712 이전에 개발된 프로토콜이기 때문에 예상치 못한 취약점들이 존재하였다. 이전 블로그글 지갑 데이터 서명(Signature)과 EIP712에서 EIP712의 역할을 상기하며 Wyvern 프로토콜이 어떤 문제점을 갖고 있었는지를 확인해보자. 본 글은 CTO of Opensa: A critical vulnerability in Wyvern ProtocolHaechi Audit: Wyvern v2.2 1-day Vulnerabilities를 주로 참고하였다.

Wyvern v2.2 취약점

2022년 1분기에, 오픈씨가 사용하던 Wyvern v2.2 프로토콜 컨트랙트에서 크리티컬한 취약점이 발견되었다. 해당 취약점을 이용하면, 공격자는 offer를 수행한 사용자들에게서 ETH를 탈취할 수 있었다. 다행인 점은 문제가 발견되고 그동안의 로그를 분석해봤을 때 해당 취약점을 이용한 공격은 없었다고 한다. 오픈씨는 off-chain 서명을 통해 거래를 처리해왔는데, 해당 서명에서 사용하던 order 구조체에서 취약점이 발견되었다.

CTO of Opensa: A critical vulnerability in Wyvern Protocol에서 이 취약점에 대해 자세히 설명하고있다. 해당 취약점은 Horton 원칙을 위반하면서 발생하였다. Hortion 원칙은 암호학 시스템의 디자인 원칙으로서, “말하는 바가 아니라 의미하는 바를 인증해라 (Authenticate what is being meant, not what is being said)”라는 표현이다. 이러한 원칙은 메시지 인증 코드(Message Authentication Codes, MAC)를 사용할 때 매우 중요한데, 앨리스가 밥에게 메시지 m := a | b | c을 MAC과 함께 보내 메시지 m이 정확히 전달됐다는 것을 인증하더라도, 밥이 해당 메시지 m을 컴포넌트 a, b, c로 정확히 분할하는 방법을 모른다면 잘못된 의미로 해석될 수 있다 (a, b, c가 아닌 ab, c로 해석할 경우 잘못된 해석이다). 따라서 Hortion 원칙은 메시지 그 자체가 아닌 의미하는 바(meaning)를 인증하도록 한다. 예를 들어, MAC은 메시지 m의 바이트 스트링 자체의 무결성만을 인증하므로, 메시지 m이 구성되는 방식 또한 인증하도록 하여야한다. 이는 EIP712처럼 메시지를 구조화, 형식화하고 메시지의 포맷을 서명에 포함하도록 함으로써 문제를 해결할 수 있다. 이제 Horton 원칙을 위반해서 발생했던 Wyvern v2.2 취약점에 대해 자세히 알아보자.

오픈씨에서는 off-chain 서명을 통해 판매와 구매를 수행한다. Wyvern v2.2에서는 EIP712가 적용되기 전 시점에 작성된 코드로서, 별도로 설계한 oder구조체에 서명을 수행하였다. Order 구조체는 아래 코드에서 확인할 수 있으며, 거래에 사용할 토큰 주소 (paymentToken), 거래 가격 (basePrice), nft 컨트랙트 주소 (target), nft 전송을 수행하기 위한 파라미터 calldata 등이 포함되어 있다.

/* An order on the exchange. */
struct Order {
    /* Exchange address, intended as a versioning mechanism. */
    address exchange;
    /* Order maker address. */
    address maker;
    /* Order taker address, if specified. */
    address taker;
    /* Maker relayer fee of the order, unused for taker order. */
    uint makerRelayerFee;
    /* Taker relayer fee of the order, or maximum taker fee for a taker order. */
    uint takerRelayerFee;
    /* Maker protocol fee of the order, unused for taker order. */
    uint makerProtocolFee;
    /* Taker protocol fee of the order, or maximum taker fee for a taker order. */
    uint takerProtocolFee;
    /* Order fee recipient or zero address for taker order. */
    address feeRecipient;
    /* Fee method (protocol token or split fee). */
    FeeMethod feeMethod;
    /* Side (buy/sell). */
    SaleKindInterface.Side side;
    /* Kind of sale. */
    SaleKindInterface.SaleKind saleKind;
    /* Target. */
    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;
    /* Token used to pay for the order, or the zero-address as a sentinel value for Ether. */
    address paymentToken;
    /* Base price of the order (in paymentTokens). */
    uint basePrice;
    /* Auction extra parameter - minimum bid increment for English auctions, starting/ending price difference. */
    uint extra;
    /* Listing timestamp. */
    uint listingTime;
    /* Expiration timestamp - 0 for no expiry. */
    uint expirationTime;
    /* Order salt, used to prevent duplicate hashes. */
    uint salt;
}

/**
 * @dev Hash an order, returning the canonical order hash, without the message prefix
 * @param order Order to hash
 * @return Hash of order
 */
function hashOrder(Order memory order)
    internal
    pure
    returns (bytes32 hash)
{
    /* Unfortunately abi.encodePacked doesn't work here, stack size constraints. */
    uint size = sizeOf(order);
    bytes memory array = new bytes(size);
    uint index;
    assembly {
        index := add(array, 0x20)
    }
    index = ArrayUtils.unsafeWriteAddress(index, order.exchange);
    index = ArrayUtils.unsafeWriteAddress(index, order.maker);
    index = ArrayUtils.unsafeWriteAddress(index, order.taker);
    index = ArrayUtils.unsafeWriteUint(index, order.makerRelayerFee);
    index = ArrayUtils.unsafeWriteUint(index, order.takerRelayerFee);
    index = ArrayUtils.unsafeWriteUint(index, order.makerProtocolFee);
    index = ArrayUtils.unsafeWriteUint(index, order.takerProtocolFee);
    index = ArrayUtils.unsafeWriteAddress(index, order.feeRecipient);
    index = ArrayUtils.unsafeWriteUint8(index, uint8(order.feeMethod));
    index = ArrayUtils.unsafeWriteUint8(index, uint8(order.side));
    index = ArrayUtils.unsafeWriteUint8(index, uint8(order.saleKind));
    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);
    index = ArrayUtils.unsafeWriteUint(index, order.basePrice);
    index = ArrayUtils.unsafeWriteUint(index, order.extra);
    index = ArrayUtils.unsafeWriteUint(index, order.listingTime);
    index = ArrayUtils.unsafeWriteUint(index, order.expirationTime);
    index = ArrayUtils.unsafeWriteUint(index, order.salt);
    assembly {
        hash := keccak256(add(array, 0x20), size)
    }
    return hash;
}

위 코드에서 Order 구조체는 거래에 사용되는 매우 많은 데이터들을 담고 있는데, 이 데이터들은 구매자와 판매자에 의해 keccak256 해시함수를 거쳐 서명된다. 여기서 문제가 되는 부분은 calldata, replacementPattern, staticExtradata와 같은 Bytes 데이터들이다. 위 코드에서 order 구조체를 해싱하는 hashOrder 함수를 살펴보자. 이 함수는 order 구조체에 대해 keccak256(abi.encodePacked(hashOrder))를 수행한 것과 매우 유사하다 (여기서는 stack 사이즈의 제한으로 인해 별도로 ArrayUtils 라이브러리에 abi.encodePacked와 유사한 기능을 구현하였다).

abi.encodePacked()는 Non-standard Packed Mode로서 Figure 1 처럼, string과 bytes와 같은 동적 타입들이 그대로 다른 값의 뒤에 이어진다. 문제는 order는 Bytes 동적 타입 데이터를 여러개 갖고 있기 때문에, 여기서 공격 취약점이 발생한다.

Drawing

Figure 1. abi.encodePacked() 예시

우리는 order 구조체에서 calldata, replacementPattern, staticExtradata 3개의 Bytes 데이터가 존재한다는 것을 보았다. 어떻게 이를 통해 공격이 수행될 수 있을지를 보자. 예시로 변수 string a = “test”, string b = “collision”이라고 해보자. keccak256(abi.encodePacked(a, b)) = keccak256(abi.encodePacked("test", "collision")) = keccak256(abi.encodePacked("te", "stcollision"))와 동일하다. 즉, 동적 타입 a와 b 값이 달라지더라도 인코딩된 값이 동일하기 때문에 같은 해시 값이 나오게 된다.

이는 abi.encodePacked을 서명, 인증 및 데이터 무결성 체크에 확인할 때 주의해야하는 이유이다. keccak256(abi.encodePacked(a, b))를 계산할 때 a와 b가 string, bytes와 같은 동적 타입이라면 a와 b의 값 일부를 서로 간에 이동시켜도 동일한 결과 값을 얻을 수 있다. 따라서 특별한 이유가 없다면 abi.encode 사용을 권고한다. 이러한 경고는 Solidity Docs에 non-standard mode의 주의사항으로 나와있으며 SWC-133에도 이와 같은 해시 충돌에 대한 주의사항이 나와있다.

이를 악용하면 calldata, replacementPattern, staticExtradata의 바이트 값들을 서로 간에 이동 (shifted)시키더라도 같은 해시 값을 얻게 된다. 그럼 공격자는 이 3개의 변수를 조작함으로써 최종적으로 어떤 공격을 수행할 수 있을지를 더 살펴보자. 우선 이 데이터들이 어떻게 활용되는지를 살펴봐야 한다. 아래는 Wyvern 컨트렉트에서 구매자와 판매자의 서명과 Order 구조체를 가지고 실제 거래가 실행되는, ExchangeCore.sol 컨트렉트의 atomicMatch 함수이다. 해당 함수가 정상적으로 실행된다면 구매자는 판매자에게 토큰을 지불하고, 구매자는 판매자에게 nft 전송을 하나의 트랜잭션 안에서 (atomic) 수행하게 된다.

/**
 * @dev Atomically match two orders, ensuring validity of the match, and execute all associated state transitions. Protected against reentrancy by a contract-global lock.
 * @param buy Buy-side order
 * @param buySig Buy-side order signature
 * @param sell Sell-side order
 * @param sellSig Sell-side order signature
 */
function atomicMatch(Order memory buy, Sig memory buySig, Order memory sell, Sig memory sellSig, bytes32 metadata)
    internal
    reentrancyGuard
{
    /* CHECKS */

    /* Ensure buy order validity and calculate hash if necessary. */
    bytes32 buyHash;
    if (buy.maker == msg.sender) {
        require(validateOrderParameters(buy));
    } else {
        buyHash = requireValidOrder(buy, buySig);
    }

    /* Ensure sell order validity and calculate hash if necessary. */
    bytes32 sellHash;
    if (sell.maker == msg.sender) {
        require(validateOrderParameters(sell));
    } else {
        sellHash = requireValidOrder(sell, sellSig);
    }

    /* Must be matchable. */
    require(ordersCanMatch(buy, sell));

    /* Target must exist (prevent malicious selfdestructs just prior to order settlement). */
    uint size;
    address target = sell.target;
    assembly {
        size := extcodesize(target)
    }
    require(size > 0);

    /* Must match calldata after replacement, if specified. */
    if (buy.replacementPattern.length > 0) {
      ArrayUtils.guardedArrayReplace(buy.calldata, sell.calldata, buy.replacementPattern);
    }
    if (sell.replacementPattern.length > 0) {
      ArrayUtils.guardedArrayReplace(sell.calldata, buy.calldata, sell.replacementPattern);
    }
    require(ArrayUtils.arrayEq(buy.calldata, sell.calldata));

    /* Retrieve delegateProxy contract. */
    OwnableDelegateProxy delegateProxy = registry.proxies(sell.maker);

    /* Proxy must exist. */
    require(delegateProxy != address(0));

    /* Assert implementation. */
    require(delegateProxy.implementation() == registry.delegateProxyImplementation());

    /* Access the passthrough AuthenticatedProxy. */
    AuthenticatedProxy proxy = AuthenticatedProxy(delegateProxy);

    /* EFFECTS */

    /* Mark previously signed or approved orders as finalized. */
    if (msg.sender != buy.maker) {
        cancelledOrFinalized[buyHash] = true;
    }
    if (msg.sender != sell.maker) {
        cancelledOrFinalized[sellHash] = true;
    }

    /* INTERACTIONS */

    /* Execute funds transfer and pay fees. */
    uint price = executeFundsTransfer(buy, sell);

    /* Execute specified call through proxy. */
    require(proxy.proxy(sell.target, sell.howToCall, sell.calldata));

    /* Static calls are intentionally done after the effectful call so they can check resulting state. */

    /* Handle buy-side static call if specified. */
    if (buy.staticTarget != address(0)) {
        require(staticCall(buy.staticTarget, sell.calldata, buy.staticExtradata));
    }

    /* Handle sell-side static call if specified. */
    if (sell.staticTarget != address(0)) {
        require(staticCall(sell.staticTarget, sell.calldata, sell.staticExtradata));
    }

    /* Log match event. */
    emit OrdersMatched(buyHash, sellHash, sell.feeRecipient != address(0) ? sell.maker : buy.maker, sell.feeRecipient != address(0) ? buy.maker : sell.maker, price, metadata);
}

정상적인 시나리오에서 atomicMatch 함수는 같은 작업을 수행한다:

  1. order의 유효성을 검사하고 ordersCanMatch를 통해 buyer와 seller의 세부 order 내용(Payment token, target nft 컨트랙트 주소 등)이 일치하는지를 확인한다.
  2. replacementPattern에 값이 존재할 경우, guardedArrayReplace() 함수를 실행한다. 해당 함수를 통해 완성된 calldata를 생성해내며, calldata는 [4 bytes의 함수 선택자][32 bytes의 from 주소][32 bytes의 to 주소][32 bytes의 tokenId]로 구성되어 있다. 여기서 완성된 calldata는 transferfrom의 함수 선택자에 해당하는 0x23b872dd값이 들어가며, from에는 nft 판매자의 주소, to에는 nft 구매자의 주소, tokenId에는 nft 토큰의 id가 들어가야한다.
  3. guardedArrayReplace를 통해 바뀐 buyer와 seller의 calldata가 일치하는지를 비교한다.
  4. executeFundsTransfer 함수를 통해 buyer가 seller에게 토큰을 지불한다.
  5. nft contract 주소 (target)에 대해 call 혹은 delegatecall을 호출함으로써 transferfrom(address from, address to, uint256 tokenId) 함수를 호출한다. 이를 통해 seller는 buyer에게 nft를 전송한다. 이 때 calldata가 call 혹은 delegatecall의 파라미터로 사용됨으로써 transferfrom의 함수 선택자, from 주소, to 주소, tokenId 정보를 전달한다.

우리는 위 과정에서 2번의 replacementPattern에 대해 좀 더 자세히 알아볼 필요가 있다. Wyvern 프로토콜에서 atomicMatch 함수는 replacementPattern을 이용하여 calldata를 변경하는 과정을 거친다. 왜 이런 테크닉을 사용했을까? 위 atomicMatch 시나리오의 5번 과정에서 calldata가 call 혹은 delegatecall의 인자로서, trnasferfrom의 함수 선택자 뿐만 아니라 from 주소, to 주소, tokenId 정보를 알려주는 것을 상기하자. calldata는 이런 주소 정보를 모두 포함해야하지만, 판매자가 nft를 리스팅하는 시점에는 구매자가 정해져있지 않기 때문에, to 주소를 calldata에 넣을 수가 없다. 여기서 약간의 트릭을 사용한다. 아직 확정되지 않은 to 주소는 0 값으로 채워놓고 replacementPattern이라는 비트마스크(BitMask)를 사용하여, 추후 “구매자가 제출하는 calldata”에 의해서 “판매자 calldata의 address to” 부분을 채워지도록한다. 아래 atomicMatch의 코드 일부분이 해당 기능을 수행한다.

if (sell.replacementPattern.length > 0) {
  ArrayUtils.guardedArrayReplace(sell.calldata, buy.calldata, sell.replacementPattern);
}

판매자의 replacementPattern이 존재할 때 guardedArrayReplace 함수를 실행하여 sell.calldata의 [4 bytes function selector][address from][address to][tokenId]에서 [address to] 부분을 구매자의 주소로 변경한다.

아래는 간단한 예시이다.

sell.calldata: [0x23b872dd][32 bytes의 address(판매자)][32 bytes의 address(0)][tokenId]
buy.calldata: [0x23b872dd][32 bytes의 address(0)][32 bytes의 address(구매자)][tokenId]
sell.replacementPattern: [0x00000000][32 bytes의 0][32 bytes의 1][32 bytes의 0]
buy.replacementPattern: [0x00000000][32 bytes의 1][32 bytes의 0][32 bytes의 0]

//do ArrayUtils.guardedArrayReplace(sell.calldata, buy.calldata, sell.replacementPattern);
result: [0x23b872dd][32 bytes의 address(판매자)][32 bytes의 address(구매자)][tokenId]

//do ArrayUtils.guardedArrayReplace(buy.calldata, sell.calldata, buy.replacementPattern);
result: [0x23b872dd][32 bytes의 address(판매자)][32 bytes의 address(구매자)][tokenId]

위 예제에 대해 살펴보자. 판매자가 리스팅을 수행하는 경우, sell.calldata의 첫 4 바이트에는 transferfrom(address,address,uint256)에 해당하는 함수 선택자 (function selector)인 0x23b872dd가 들어가며, 그 다음으로는 판매자의 address, 구매자의 address (이 시점엔 구매자를 모르므로 address(0)), 토큰 id 값이 순서대로 포함된다. 판매자의 order 구조체의 sell.replacementPattern 바이트 변수는 calldata와 동일한 사이즈를 가진 비트마스크이다. 판매자는 sell.calldata의 구매자 주소 부분만 바뀌는 것을 원하기 때문에 해당 부분만 1 값을 갖고 나머지는 0을 갖는다.

반대로 구매자를 살펴보자. 구매자는 판매자의 calldata와는 반대로 to 주소 부분이 자신의 주소로 채워지고, from 주소 부분이 0으로 채워진다. 이는 판매자가 리스팅하기 전에 구매자가 먼저 offer를 수행하는 경우에도 마찬가지이다. nft의 소유주가 바뀌더라도 상관없이 nft 구매자가 제시하는 offer가 유효해야하므로, 현재 판매하고 있는 판매자의 주소를 넣지 않고 0으로 채워넣는다. buy.replacementPattern은 from 주소 부분에만 1 값을 가지고, 나머지는 0을 가지도록 함으로써, 추후 buy.calldata의 from 부분이 sell.calldata를 통해 교체되도록한다.

결과적으로, ArrayUtils.guardedArrayReplace(sell.calldata, buy.calldata, sell.replacementPattern);과 ArrayUtils.guardedArrayReplace(buy.calldata, sell.calldata, buy.replacementPattern);의 결과는 똑같은 calldata를 생성해내게된다. 만약, 같지 않다면 require(ArrayUtils.arrayEq(buy.calldata, sell.calldata));에서 에러가 발생한다.

이렇게 완성된 calldata는 위 5번 과정에서 transferfrom 함수를 실행하기 위한 call 혹은 delegatecall의 파라미터로 활용된다. 즉, 판매자가 구매자에게 target 컨트랙트의 tokenId에 해당하는 nft를 전송한다. calldata의 데이터 값이 조작된다면, 판매자가 구매자에게 nft 전송하는 기능이 정상적으로 동작하지 않을 수 있다. 앞서 살펴 봤듯이, order 구조체는 bytes 변수를 여러개 갖고 있음에도 불구하고 abi.encodePacked()과 유사한 방식으로 데이터를 인코딩하여 서명을 생성하였다. 이는 bytes 값인 calldata, replacementPattern, staticExtradata 사이에 비트 쉬프트 연산을 가능하게하는 취약점을 갖게한다. 아래는 0x0f6005af366bfa60bbdba5966b9209e81567dedb 주소에서 특정 nft에 offer를 제안했을 때, 공격자가 비트 쉬프트를 통해 공격하는 예시이다.

// True payload
...
'calldata': '0x23b872dd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6005af366bfa60bbdba5966b9209e81567dedb00000000000000000000000000000000000000000000000000000000000002b3',
'replacementPattern': '0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
'staticTarget': '0x0000000000000000000000000000000000000000',
'staticExtradata': '0x',
...

// Malicious payload
...
'calldata': '0x23b872dd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6005af36',
'replacementPattern': '0x6bfa60bbdba5966b9209e81567dedb00000000000000000000000000000000000000000000000000000000000002b300000000ffff',
'staticTarget': '0xffffffffffffffffffffffffffffffffffffffff',
'staticExtradata': '0xffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
...

위 공격에서 비트 쉬프트를 통해 calldata의 일부 비트들을 replacementPattern으로 넘기는 것을 볼 수 있는데, 이는 replacementPattern을 통해 calldata의 함수 선택자 부분을 ‘교체’ 하기 위해서이다. 원래의 함수 선택자는 0x23b872dd로, trasnferfrom에 해당하지만 공격자는 단 10 bit만 바꿈으로써 getApproved(uint) 함수의 선택자에 해당하는 0x081812fc로 변경할 수 있다. 0x23b872dd0x081812fc로 변경하기 위해선 xx1x 1x11 1x1x xxxx x11x xxxx xx1x xxx1과 같은 패턴의 replacementPattern이 필요하다 (아래 예시 참고). 위의 True Payload에서 calldata의 0x6bfa60bb 부분부터 replacementPattern으로 옮기는 비트 쉬프트연산을 한다면 공격자는 위의 패턴을 만족하는 replacementPattern을 생성할 수 있다. replacementPattern의 길이는 calldata 길이와 동일해야하기 때문에, 길이를 맞추기 위해 일부 바이트들은 staticTarget과 staticExtradata로 넘어간 점을 참고하자.

        0x23b872dd: 0010 0011 1011 1000 0111 0010 1101 1101
        0x081812fc: 0000 1000 0001 1000 0001 0010 1111 1100
replacementPattern: xx1x 1x11 1x1x xxxx x11x xxxx xx1x xxx1
        0x6bfa60bb: 0110 1011 1111 1010 0110 0000 1011 1011

그렇다면 모든 calldata가 0x6bfa60bb와 같이 위 패턴을 만들 수 있는 것은 아닐텐데, 위 패턴의 replacementPattern을 만들 수 있는 확률을 계산해보자. 우리는 replacementPattern에서 10 bit가 1인 부분이 (1/2^10) 필요하다. 기존 calldata에서 from address는 0으로 채워져있고, tokenId 값 역시 대부분이 0으로 채워져있으므로 해당 부분은 replacemetnPattern의 앞 부분으로 비트 쉬프트를 해봤자 의미가 없다. address to 부분을 옮기는 것만 고려하면 되는데, 32 bytes의 to 주소를 비트 쉬프트 할 때 2바이트 단위로 움직일 수 있으므로, 총 16번의 비트 쉬프트 시도가 가능하다. 따라서 16번의 시도 중 특정 위치의 10 bit가 1일 확률은 16/2^10 = 1/64이다. 즉, 64개의 offer 중 한개 정도는 위 공격 시나리오의 위험이 있으며, offer 구매자는 이더리움을 지불함에도 불구하고 nft는 받을 수 없게 된다.

즉 위의 내용을 정리하자면, Wyvern v2.2의 서명 관련 취약점은 아래와 같다.

  1. Wyvern v2.2 프로토콜은 EIP712이 나오기 전에 개발되었기 때문에 서명에 자체적인 Order 구조체를 사용. Order 구조체는 동적 타입인 Bytes 변수인 Calldata, replacementPattern, staticExtradata를 갖고 있음
  2. 여러 동적 타입 변수들을 갖고있음에도 불구하고, abi.encodePacked와 유사한 방식으로 데이터를 연속적으로 묶어 처리했기 때문에 해시 충돌이 발생할 수 있음. 공격자는 calldata, replacementPattern, staticExtradata 간에 비트 이동을 수행하더라도 같은 서명 값이 나오도록 조작할 수 있음.
  3. atomicMatch 함수에서는 buyer/seller의 calldata와 replacementPattern을 통해 새로운 바이트 배열(calldata)을 생성. 해당 바이트 배열의 첫번째 4 바이트에는 transferFrom(address,address,uint256)에 해당하는 함수 selector가 포함되어 있으며 (0x23b872dd), nft 판매자의 주소와 구매자의 주소 그리고 tokenId 값이 포함되어 있음
  4. 원래는 calldata를 인자로 delegratecall을 호출함으로써 nft를 구매자에게 전송하는 transferfrom 함수가 수행되어야하지만, 공격자는 calldata, replacementPattern, staticExtradata 값을 조작함으로써 transferfrom 함수 대신에 getApproved(uint)가 호출되도록 calldta에 포함된 함수 selector를 변경할 수 있음. 이러한 조작은 64개의 offer 중 한개 꼴로, calldata의 일부 비트를 replacementPattern로 옮김으로써 수행가능할 수 있음.
  5. 따라서 이러한 공격을 통해 nft 전송은 수행하지 않고, 구매자 혹은 offer를 수행한 사람들의 돈만 가져가는 것이 가능함. transferfrom 대신에 호출되는 getApproved(uint256 tokenId) 함수는 특정 토큰 id에 승인된 주소를 반환할 뿐인 함수이기 때문에 어떠한 상태 변화도 없이 정상적으로 처리된 것으로 간주됨.

위 취약점은 발견 후 Wyvern v2.3으로 업그레이드되며 EIP712를 지원하는 것으로 해당 문제점을 해결하였다. EIP712에 대해 자세히 알고 싶다면, 블로그의 다른 포스트인 지갑 데이터 서명(Signature)과 EIP712를 참고하면 좋다. 현재 오픈씨는 Wyvern 프로토콜 대신 Seaport라는 별도 프로토콜을 개발하여 사용중이다. Seaport에 대해선 다음 포스팅에서 다뤄보도록 하겠다.

guardedArrayReplace 메모리 overwrite 취약점

위 취약점과는 별개로 replacementPattern을 사용해 calldata를 변경하는 guardedArrayReplace 함수는 별도의 취약점을 갖고있다. 아래 ArrayUtils.sol의 코드를 살펴보자.

/**
 * Replace bytes in an array with bytes in another array, guarded by a bitmask
 * Efficiency of this function is a bit unpredictable because of the EVM's word-specific model (arrays under 32 bytes will be slower)
 *
 * @dev Mask must be the size of the byte array. A nonzero byte means the byte array can be changed.
 * @param array The original array
 * @param desired The target array
 * @param mask The mask specifying which bits can be changed
 * @return The updated byte array (the parameter will be modified inplace)
 */
function guardedArrayReplace(bytes memory array, bytes memory desired, bytes memory mask)
    internal
    pure
{
    require(array.length == desired.length);
    require(array.length == mask.length);

    uint words = array.length / 0x20;
    uint index = words * 0x20;
    assert(index / 0x20 == words);
    uint i;

    for (i = 0; i < words; i++) {
        /* Conceptually: array[i] = (!mask[i] && array[i]) || (mask[i] && desired[i]), bitwise in word chunks. */
        assembly {
            let commonIndex := mul(0x20, add(1, i))
            let maskValue := mload(add(mask, commonIndex))
            mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))
        }
    }

    /* Deal with the last section of the byte array. */
    if (words > 0) {
        /* This overlaps with bytes already set but is still more efficient than iterating through each of the remaining bytes individually. */
        i = words;
        assembly {
            let commonIndex := mul(0x20, add(1, i))
            let maskValue := mload(add(mask, commonIndex))
            mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))
        }
    } else {
        /* If the byte array is shorter than a word, we must unfortunately do the whole thing bytewise.
           (bounds checks could still probably be optimized away in assembly, but this is a rare case) */
        for (i = index; i < array.length; i++) {
            array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);
        }
    }
}

위 코드에서는 가스비를 최소화하기 위해 인라인 어셈블리(inline assembly)를 사용해 bytes memory array의 특정 부분을 bytes memory target으로 덮어쓴다. memory 관련 opcode는 word 단위 (32 bytes)로 동작하기 때문에, uint words = array.length / 0x20;를 통해 word의 개수를 구하는 것을 확인할 수 있다. 이 words 변수만큼 반복문을 통해 array의 특정 인덱스에 array[i] = (!mask[i] && array[i]) || (mask[i] && desired[i])와 같은 동작을 수행한다.

문제는 words 수만큼 덮어쓰기 작업을 하고 남는 나머지 부분이다. array의 길이가 정확히 words단위로 나누어지지 않는 추가 bytes를 갖고 있을 때 이 부분을 별도로 처리해줘야한다. 예를 들어, array의 길이 80bytes라면, 64bytes를 처리하고 남은 나머지 16 bytes를 별도로 처리해줘야한다.

하지만 위 코드에서는 이런 last section 부분을 처리하기 위해서 if (words > 0){} else {}를 통해 처리하고 있는데, 이 if문 안에서 예기치 못한 메모리 overwrite가 발생할 수 있다. 예를 들어 array의 길이가 정확히 64 bytes라면 words는 2 값을 갖게 되고 if문이 실행된다. let commonIndex := mul(0x20, add(1, i))의 결과로, commonIndex는 0x60의 값을 갖게 되고, mstore() opcode에서 우리는 add(array, commonIndex) 위치에 데이터를 저장하게된다. array는 2개의 slot만을 (64bytes) 차지함에도 불구하고 3번째 slot에 데이터를 저장하는 것이다. 이런 메모리 overwrite 문제는 아래 코드와 같은 상황에서 치명적일 수 있다. 아래 예시는 BlockSec의 미디엄 포스팅글을 참고하였다.

contract PoC {
    mapping(address=>uint) public balances;

    function test() external {
        bytes memory a = abi.encode(keccak256("123")); //slot1
        bytes memory b = abi.encode(keccak256("456")); //slot2
        uint[] memory _rewards = new uint[](1);        //slot3
        bytes memory mask = abi.encode(keccak256("123")); //slot4
        guardedArrayReplace(b, a, mask);
        for(uint i = 0; i < _rewards.length; i++){
            uint256 amt = _rewards[i];
            balances[msg.sender] += amt;
        }
    }
}

위 코드에서 test() 함수의 _rewards 변수는 3번째 slot에 위치한 uint 변수로 msg.sender의 자금 밸런스에 추가되는 변수이다. 정상적인 동작대로라면 _rewards는 아무 값도 할당되어 있지 않으므로 어떠한 값도 balances에 추가되면 안되지만, 위 취약점에 따라 엄청나게 큰 값이 더해질 수 있다. guardedArrayReplace(b, a, mask);에서 b는 정확히 1 words를 가지므로, if (words > 0)안의 로직이 실행되며 이는 변수 b의 메모리 위치의 바로 다음인 _rewards까지 값을 덮어씌우게된다. 이 결과로, _rewards는 매우 큰 값을 갖게되며 msg.sender에게 비정상적인 balances 잔액을 할당하게된다.

따라서 if else 구문이 아래와 같이 변경함으로써 해당 취약점을 해결할 수 있다.

if (array.length % 0x20) {
    for (i = index; i < array.length; i++) {
        array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);
    }
}

다른 방법으로는 해치랩스가 제안하는 것처럼, if (words > 0)는 그대로 놔두고, if 문안에서 commonIndex를 array.length 값으로 할당하는 것이다. 이에 대한 자세한 설명은 Haechi Audit: Wyvern v2.2 1-day Vulnerabilities을 참고하자.

Reference

  1. CTO of Opensa: A critical vulnerability in Wyvern Protocol
  2. Haechi Audit: Wyvern v2.2 1-day Vulnerabilities
  3. EIP712