[Hacking Series] #04 Signature Replay

gganbukim
Decipher Media |디사이퍼 미디어
19 min readJan 19, 2023
Smart Contract Hacking [source]

본 게시글은 이더리움 스마트 컨트랙트 해킹 유형을 분석한 시리즈 중 4편입니다. 이번 편에서는 사용자의 서명을 여러 번 사용하는 해킹 유형인 Signature Replay Attack에 대하여 분석합니다.

Author: gganbukim
Reviewer : Yohan Lim

서울대학교 블록체인 학회 디사이퍼에서 스마트 컨트랙트 해킹에 대한 글을 시리즈로 연재합니다. 본 글은 해킹 시리즈의 4편으로, 다른 편을 읽고 싶으시다면 아래의 리스트를 확인해주십시오.

[Hacking Series]

1편: Intro
2편: Unsafe Delegatecall
3편: Front Running
4편: Signature Replay
5편: Denial of Service
6편: Arithmetic Overflow / Underflow & Conclusion

[목차]

  1. Intro
  2. Signature Replay Attack 개요
  3. Opensea 피싱 공격
  4. 실제 코드와 예방 방안
  5. Outro

블록체인에서의 디지털 서명은 (1) 인증, (2) 무결성 보장이라는 특징을 가지고 있습니다. 여기서의 인증이란 공개키를 통해 서명자를 식별할 수 있다는 것을 의미하고, 무결성 보장이란 서명 이후 데이터가 수정되지 않았음을 증명할 수 있음을 의미합니다. 이를 통해서 서면에서의 서명보다 높은 보안성을 가질 수 있게 되는 것이죠. 이는 메시지나 문서에 코드로 첨부되어 서명의 역할을 하게 됩니다.

디지털 서명의 개요

예를 들어, Alice가 Bob에게 메시지를 작성하는 경우를 가정해봅시다. 메시지는 일관된 길이의 해시 값으로 해싱되고, Alice는 해당 메시지의 해시 값을 개인 키와 결합하여 디지털 서명을 생성합니다. Bob이 메시지를 받으면 Alice가 제공한 공개 키를 통해 전자 서명의 유효성을 확인할 수 있습니다. 이를 통해 Bob은 Alice만이 해당 공개 키에 해당하는 개인 키를 가지고 있기 때문에 서명이 Alice에 의해 생성되었음을 알 수 있습니다.

그러나 디지털 서명의 인증과 무결성 보장이라는 특징만으로는 (1)메시지의 고유성을 확인하기 어렵고, (2)메시지의 발신자가 메시지의 서명자와 동일함을 의미하지 않습니다. 이러한 문제로 인해 발생할 수 있는 공격 포인트 중 하나가 바로 Signature Replay Attack입니다. 이번 글에서는 Signature Replay Attack의 개요와 실제 사례 그리고 코드 분석에 대해 다룰 것입니다.

2. Signature Replay Attack 개요

Signature Replay Attack의 기본 아이디어는 동일한 서명을 사용하여 트랜잭션을 여러 번 실행하는 것을 의미합니다. 쉬운 설명을 위해 Alice와 Bob에 의해 소유된 다중 서명 지갑이 있다고 가정하겠습니다.

지갑에는 2 ETH가 있고 Alice는 컨트랙트에서 1 ETH를 인출하고 싶어합니다. 이를 위해서 Alice는 1 ETH를 인출할 수 있도록 승인(approve)하는 트랜잭션을 보내야 합니다. Alice와 Bob이 다중 서명 지갑을 이용하고 있으므로 Bob도 Alice가 1 ETH를 인출할 수 있도록 승인하는 트랜잭션을 보내야 합니다. 두 명의 승인이 이루어지면 Alice는 다중 서명 지갑에서 1 ETH를 인출하는 트랜잭션을 통해 1 ETH를 인출 할 수 있게 되는 것입니다. 따라서 이를 위해 필요한 트랜잭션은 Alice가 Alice에게 1 ETH를 승인하는 트랜잭션, Bob이 Alice에게 1 ETH를 승인하는 트랜잭션, Alice에게 1 ETH를 전송하는 트랜잭션 이렇게 총 3개의 트랜잭션이 필요합니다.

하지만 서명을 이용하면 우리는 이 트랜잭션의 개수를 하나로 줄일 수 있습니다.

먼저 Bob이 Alice가 1 ETH를 인출할 수 있도록 승인하는 메시지에 서명합니다. 그리고 Bob은 서명을 Alice에게 전달하고 Alice는 그녀의 서명을 Bob의 서명과 함께 1 ETH를 인출하는 트랜잭션을 보냅니다. 그러면 컨트랙트는 Alice의 서명과 Bob의 서명을 검증하고 Alice에게 1 ETH를 보내줍니다. 따라서 서명을 이용하면 오프체인에서 이를 교환함으로써 트랜잭션의 수를 줄일 수 있습니다. 이 때, Bob이 1 ETH만을 승인했음에도 불구하고 Alice가 Bob의 서명을 다시 이용하여 다중 서명 지갑의 1 ETH를 반복하여 인출하는 것을 Signature Replay Attack이라고 합니다. 이러한 공격은 실제로 어떻게 진행되는 것이고 왜 가능한 것일까요? 실제 Opensea 피싱 공격의 사례를 통해 이를 살펴보도록 하겠습니다.

3. Opensea 피싱 공격

2022년 초, Opensea에서 피싱 공격을 받아 일부 사용자가 NFT를 도난당하는 사건이 발생하였습니다. 피싱 공격은 다음과 같은 순서로 이루어졌습니다.

  1. 사용자가 서명할 올바른 트랜잭션 구성
  2. 사용자가 서명을 클릭하도록 유인
  3. 사용자의 서명을 얻은 후 사용자의 NFT를 훔치는 공격 계약 구성

예를 들어 공격자가 사용자에게 피싱 메일 또는 다른 방법으로 사용자의 서명을 유도한 이후 공격 컨트랙트를 구성하고 공격 컨트랙트에 사용자의 서명을 다시 사용하는 것입니다. 즉, 동일한 서명을 사용하여 트랜잭션을 여러 번 사용하는 Signature Replay Attack이라고 할 수 있습니다.

function hsashToSign(Order memory order)
internal
pure
returns (bytes32)
{
/* Calculate signature */
return keccak256("\\x19Ethereum Signed Message:\\n32", hashOrder(order));
}

위 코드의 “\x19Ethereum Signed Message”\n32”는 계산된 서명을 이더리움의 서명으로 인식하기 위해 존재합니다. 이는 서명 변경이 이더리움 외부에서 사용될 수 없도록 합니다. 그리고 32는 메시지의 길이를 의미하며 해싱을 한 메시지는 32 bytes의 길이를 가집니다. 그리고 이를 메시지에 해당하는 hashOrder 값과 함께 keccak256으로 해싱하여 최종적으로 개인 키로 서명됩니다. 그러나 이 코드는 Signature Replay Attack을 방지할 수 없습니다. 앞서 언급하였던 메시지의 고유성을 확인하기 어렵고, 메시지의 발신자가 메시지의 서명자와 동일함을 의미하지 않기 때문입니다. 따라서 해당 메시지에 대한 사용자의 서명을 다른 사용자가 다른 컨트랙트에 사용하는 Signature Replay Attack이 발생할 수 있습니다.

function validateOrder(bytes32 hash, Order memory order, Sig memory sig) 
internal
view
returns (bool)
{
/* Not done in an if-conditional to prevent unnecessary ecrecover evaluation, which seems to happen even though it should short-circuit. */

/* Order must have valid parameters. */
if (!validateOrderParameters(order)) {
return false;
}

/* Order must have not been canceled or already filled. */
if (cancelledOrFinalized[hash]) {
return false;
}

/* Order authentication. Order must be either:
/* (a) previously approved */
if (approvedOrders[hash]) {
return true;
}

/* or (b) ECDSA-signed by maker. */
if (ecrecover(hash, sig.v, sig.r, sig.s) == order.maker) {
return true;
}

return false;
}

피싱 발생 당시 오픈씨는 Wyvern Exchange V1 코드로 EIP-191 서명을 통해 거래가 이루어졌으며 사용자는 서명에 대한 정보를 보지 못하는 단점이 존재하였습니다. 그리고 위의 코드에서는 order의 파라미터들이 있는지와 유효성을 판단한 뒤 approvedOrders를 통해 온체인에서 유효성을 검증받았는지 확인합니다. 바로 이 지점에서 사용자가 이전에 서명하였으므로 유효성을 검증받았던 것으로 인식되어 공격자는 해당 서명을 계속해서 이용할 수 있게 되는 것입니다. 또한, 서명에 대한 정확한 정보를 모른 채 사용자가 서명을 하게 되는 문제도 발생할 수 있었습니다. 이는 Wyvern Exchange V2에서 EIP-712 서명을 통한 거래와 데이터 투명성 확보로 발전하였고 추후 Seaport V1로의 업데이트가 이루어진 상태입니다.

4. 실제 코드와 예방 방안

Signature Replay Attack에서 발생할 수 있는 공격을 설명하기 위해 Intro 부분에서 예시로 들었던 Alice와 Bob의 다중 서명 지갑에 대한 상황을 Signature Replay Attack에 취약 코드와 함께 살펴보도록 하겠습니다. 그리고 가능한 공격을 세가지로 나누고 이에 따른 해결 방안을 알아보겠습니다.

contract MultiSigWallet {
using ECDSA for bytes32;

address[2] public owners;

contstructor(address[2] memory _owners) public payable {
owners = _owners;
}

function deposit() external payable {}

/*Transfer ether with valid signature*/
function transfer(address _to, uint _amount, bytes[2] memory _sigs)
external
{
byte32 txHash = getTxHash(_to, _amount);
require(_checkSigs(_sigs, txHash), "invalid sig");

(bool sent, ) = _to.call{value: amount}("");
require(sent, "Failed to send Ether");


}

/*Hashing the transaction*/
function getTxHash(address _to, uint _amount) public view returns (bytes32) {
return keccak256(abi.encodePacked(_to,_amount));
}

/*Check if the signer matches the owner*/
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) prvate view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

for (uint i = 0; i < _sig.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid - signer == owners[i];

if (!valid) {
return false;
}
}
return true;
}
}

위의 코드에서 다중 서명 지갑의 소유자를 지정하고 해당 컨트랙트에 있는 ETH를 다른 사람에게 보내기 위해서는 transfer 함수를 호출해야 합니다. 현재 취약한 코드는 transfer 함수를 호출하기 위해서 ETH를 보내기 위한 주소인 _to, 보낼 ETH의 양인 _amount, 서명 _sigs 세 개의 입력 값이 필요합니다. 그리고 서명은 소유자의 수만큼 필요합니다. transfer 에서는 _to와 _amount를 getTxHash 함수를 이용하여 해싱을 합니다. 그리고 checksigs 함수를 이용하여 각 서명에 대해 서명자와 소유자가 일치하는지 확인합니다. 일치할 경우 해당 트랜잭션은 유효하다고 판단하여 ETH를 _to 주소로 보내게 됩니다.

이 컨트랙트가 Signature Reply Attack에 취약한 이유는 Alice가 Bob의 서명을 한 번 갖게 되면 Alice는 transfer 함수를 원하는 만큼 호출할 수 있고 이는 발신자가 아닌 서명자와 소유자를 확인하기 때문에 서명은 유효하게 여겨집니다. 이를 방지하기 위한 방법을 다루기 위해 Signature replay attack이 실행될 수 있는 상황을 크게 세가지로 나눌 수 있습니다.

1)같은 컨트랙트를 대상으로 한 Signature Replay Attack

이는 컨트랙트에서 트랜잭션을 구분할 수 있도록 트랜잭션마다 고유한 서명을 가지도록 함으로써 예방할 수 있습니다. 트랜잭션이 실행되면 실행된 트랜잭션에 표시를 하여 공격을 방지하는 것입니다. 따라서 이를 해 서명에 논스 값을 포함하는 방법을 사용합니다. 이를 통해 만약 Alice가 같은 서명을 이용하여 트랜잭션을 두 번 실행하려고 한다면 컨트랙트에서는 이미 해당 논스가 사용되었음을 알 수 있기 때문에 트랜잭션은 실패하게 됩니다.

contract MultiSigWallet {
using ECDSA for bytes32;

address[2] public owners;
mapping(bytes32 => bool) public executed;

contstructor(address[2] memory _owners) public payable {
owners = _owners;
}

function deposit() external payable {}

/*Transfer ether with valid signature*/
function transfer(address _to, uint _amount, uint _nonce, bytes[2] memory _sigs)
external
{
byte32 txHash = getTxHash(_to, _amount, _nonce);
require(!executed[txHash], "tx executed");
require(_checkSigs(_sigs, txHash), "invalid sig");

executed[txHash] = true;

(bool sent, ) = _to.call{value: amount}("");
require(sent, "Failed to send Ether");

}

/*Hashing the transaction with nonce*/
function getTxHash(address _to, uint _amount, uint _nonce) public view returns (bytes32) {
return keccak256(abi.encodePacked(_to,_amount,_nonce));
}

/*Check if the signer matches the owner*/
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) prvate view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

for (uint i = 0; i < _sig.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid - signer == owners[i];

if (!valid) {
return false;
}
}
return true;
}
}

위의 코드에서 _nonce를 getTxhash 함수의 입력 값으로 사용하여 같이 해싱합니다. 그리고 _nonce는 transfer 함수의 입력 값으로도 사용합니다. 이를 통해 논스를 추가함으로써 고유한 트랜잭션 해시를 생성할 수 있습니다. 그리고 executed 매핑을 추가하고 require 구문을 통해 transfer 함수가 사용된 트랜잭션 해시를 사용되었다고 표시해주어 같은 컨트랙트에서의 Signature Replay Attack을 방지할 수 있게 됩니다.

앞에서 설명하였던 오픈씨의 Wyvern Exchange V1을 개선한 Wyvern Exchange V2에서도 유사한 방식으로 해당 거래를 특정할 수 있는 매개변수들을 추가하여 Signature Replay Attack을 방지하도록 하였습니다.

function _deriveDomainSeparator() private view returns (bytes32) {

/*Prevent Signature Replay Attack by adding parameters that can specify the transaction*/
return keccak256(
abi.encode(
_EIP_712_DOMAIN_TYPEHASH, // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
_NAME_HASH, // keccak256("Wyvern Exchange Contract")
_VERSION_HASH, // keccak256(bytes("2.3"))
_CHAIN_ID, // NOTE: this is fixed, need to use solidity 0.5+ or make external call to support!
address(this)
)
);
}

2)컨트랙트를 지우고 다시 배포하는 방식의 Signature Replay Attack

컨트랙트의 selfdestruct() 함수를 이용하면 컨트랙트를 지울 수 있고 같은 컨트랙트를 다시 배포하면 논스 또한 초기화되기 때문에 Signature Replay Attack이 가능해집니다. 따라서 이를 방지하기 위해 getTxHash 함수에서 컨트랙트의 주소인 address(this)를 서명에 포함함으로써 공격을 예방할 수 있습니다.

contract MultiSigWallet {
using ECDSA for bytes32;

address[2] public owners;
mapping(bytes32 => bool) public executed;

contstructor(address[2] memory _owners) public payable {
owners = _owners;
}

function deposit() external payable {}

/*Transfer ether with valid signature*/
function transfer(address _to, uint _amount, uint _nonce, bytes[2] memory _sigs)
external
{
byte32 txHash = getTxHash(_to, _amount, _nonce);
require(!executed[txHash], "tx executed");
require(_checkSigs(_sigs, txHash), "invalid sig");

executed[txHash] = true;

(bool sent, ) = _to.call{value: amount}("");
require(sent, "Failed to send Ether");

}
/*Added address(this) to prevent nonce initialization*/
function getTxHash(address _to, uint _amount, uint _nonce) public view returns (bytes32) {
return keccak256(abi.encodePacked(address(this),_to,_amount,_nonce));
}

/*Check if the signer matches the owner*/
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) prvate view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();

for (uint i = 0; i < _sig.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid - signer == owners[i];

if (!valid) {
return false;
}
}
return true;
}
}

5. Outro

지금까지 Signature Replay Attack에 대해서 알아보았습니다. Signature Replay Attack은 사용자의 서명이 다른 곳에서도 사용될 수 있다는 점에서 치명적인 공격으로 작용할 수 있습니다. 그리고 Signature Replay Attack의 실제 사례를 살펴보면서 사용자의 지갑에서 서명을 하는 행위에 대해 주의가 필요하며 안전한 컨트랙트 구현을 위해 노력해야 할 것입니다. 그리고 Signature Replay Attack을 방지하기 위한 방법인 논스와 컨트랙트 주소 추가를 실제 코드와 함께 살펴보았습니다. 이번 시리즈를 통해 해킹의 사례를 바탕으로 해킹과 관련한 개념들과 공격 요인에 대해 깊이 이해하고 안전한 블록체인 사용을 기대합니다. 다음 글에서는 Arithmetic Overflow / Underflow에 대하여 다루도록 하겠습니다.

Reference

--

--