시그니쳐 리플레이는 전자서명 과정에서 일어나는 해킹 공격이다.
다음과 같이 Multi Signature Wallet 을 구현한 컨트랙트와 함께 설명하면,
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet {
using ECDSA for bytes32;
address[2] public owners;
constructor(address[2] memory _owners) payable {
owners = _owners;
}
function deposit() external payable {}
function transfer(address _to, uint _amount, bytes[2] memory _sigs) external {
bytes32 txHash = getTxHash(_to, _amount);
require(_checkSigs(_sigs, txHash), "invalid sig");
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
function getTxHash(address _to, uint _amount) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_to, _amount));
}
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) private view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid = signer == owners[i];
if (!valid) {
return false;
}
}
return true;
}
}
A, B 라는 두명의 오너가 MultiSigWallet 컨트랙트를 관리한다.
컨트랙트에는 2 ETH 가 예치되어 있다. A 가 transfer 함수를 통해 컨트랙트에서 자신의 지갑으로 1 ETH 를 전송하고 싶다면 B 의 서명을 받아야 한다.
서명은 getTxHash 함수를 통해 ETH를 받을 주소, 전송할 ETH 갯수 로 해시하고 이 해시는 _checkSigs 함수를 통해 서명한 주소와 서명이 일치하는지 검증한 후 ETH 를 전송하게 된다.
이 과정에서 A 가 B 의 서명을 받았을 때, 이 서명을 계속 사용하여 transfer 함수를 여러번 호출할 수 있다. 이것을 Signature Replay 공격이라고 부른다.
Signature Replay 공격을 방어하는 방법은 두 가지가 있다.
첫 번째 방법은 execute 매핑을 추가하여 해당 서명이 실행되었는지 확인하고, nonce 값을 인자로 받아 서명을 한번만 사용 가능하도록 만드는 것이다.
contract MultiSigWallet {
using ECDSA for bytes32;
address[2] public owners;
mapping(bytes32 => bool) public executed;
constructor(address[2] memory _owners) payable {
owners = _owners;
}
function deposit() external payable {}
function transfer(
address _to,
uint _amount,
uint _nonce,
bytes[2] memory _sigs
) external {
bytes32 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");
}
function getTxHash(
address _to,
uint _amount,
uint _nonce
) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_to, _amount, _nonce));
}
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) private view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid = signer == owners[i];
if (!valid) {
return false;
}
}
return true;
}
}
이렇게 컨트랙트를 만들 경우, 해당 컨트랙트에 한해 Signature Replay 공격 방어가 가능하다.
하지만 tx 를 실행 후, selfdestruct 를 이용해 다시 컨트랙트를 배포하여 논스 값을 초기화 시킬 수 있다.
두 번째 방법으로, 이러한 공격을 막기 위해 서명 생성 과정에서 컨트랙트의 주소도 포함시킨다면 컨트랙트를 다시 배포하더라도 같은 서명을 생성할 수 없기에 공격을 방어할 수 있다.
function getTxHash(
address _to,
uint _amount,
uint _nonce
) public view returns (bytes32) {
return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce));
}
일부 사이트는 처음 지갑을 연결할 때 아래와 같이 서명을 요청하는 경우가 있다.
아래 사진의 opensea 는 트랜잭션이나 가스비를 소모하는 서명은 아니라고 친절히 알려주고 있다..
만약 피싱사이트에 잘못 접속하게 된다면 이 서명을 통해 지갑에 보유하고 있는 자산을 털릴 수 있다.
자산을 제어할 수 있는 서명은 위 사진처럼 빨간 글씨로 경고 문구를 보여주므로 주의하면 된다.
개발자도 사용자가 생성한 서명이 의도대로 사용되는 지와 서명을 이용한 공격을 주의하며 컨트랙트를 작성해야 한다.
참조 : https://solidity-by-example.org/hacks/signature-replay/