스마트 컨트랙트 공격 - 리플레이 공격
⛔️ 리플레이 공격
리플레이 공격은 블록체인에서 발생하는 보안 위협 중 하나로, 유효한 데이터 전송을 악의적이거나 부정하게 반복 또는 지연시켜 발생하는 공격입니다. 이러한 공격은 특히 블록체인에서 중요한 거래나 계약을 수행하는 경우에 매우 치명적일 수 있습니다.
리플레이 공격은 주로 블록체인 트랜잭션을 그대로 복사하여 다른 블록체인 또는 같은 블록체인 상의 다른 계정에 다시 제출하는 것으로 이루어집니다. 이로 인해 같은 거래가 두 번 이상 처리되는 결과를 초래할 수 있습니다.
멀티시그 지갑을 통해 리플레이 공격에 대해 설명하겠습니다. 잔액이 2 ETH이고 두 명의 관리자인 Bill과 Elon이 있는 멀티시그 지갑이 있습니다. Bill이 지갑에서 1 ETH를 인출하려면 아래와 같은 절차를 거칩니다.
1️⃣ Bill은 승인을 위해 컨트랙트에 트랜잭션을 보냅니다.
2️⃣ Elon 또한 승인을 위해 컨트랙트에 트랜잭션을 보냅니다.
3️⃣ 마지막으로 1 ETH를 실제로 인출하기 위해 또 다른 트랜잭션을 보냅니다.
이렇게 총 3번의 트랜잭션을 보내야 하기 때문에 매우 비효율적이고 비용이 많이 드는 방법입니다.
반면 Elon이 메시지를 서명하여 Bill이 지갑에서 1 ETH를 인출할 수 있도록 허용한다는 메시지를 Bill에게 보낸다면, 한 번의 트랜잭션으로 인출할 수 있습니다.
Bill은 Elon의 서명과 자신의 서명을 추가하고, 1 ETH 인출을 위해 한번의 트랜잭션을 컨트랙트에 보낼 수 있습니다.
위 같은 상황을 통해 왜 오프체인(off-chain)에서 메시지에 서명하는 것이 필요한지 알 수 있습니다. 하지만 이러한 오프체인 서명을 기반으로 한 멀티시그 지갑의 경우, 리플레이 어택이 발생할 수 있습니다.
❓ 리플레이 공격 시나리오
1️⃣ 오프체인에서 서명된 메시지를 가져와서 같은 컨트랙트에서 두 번째 액션의 인가를 요구하는 경우
이 시나리오에서 공격자는 먼저 유효한 트랜잭션을 오프체인에서 만들어 냅니다. 그리고 해당 트랜잭션은 컨트랙트에서 특정 액션에 대한 인가를 포함하고 있습니다.
이후 공격자는 이 서명된 메시지를 다시 가져와서 똑같은 컨트랙트에서 다른 액션의 인가를 요구하는 트랜잭션으로 사용합니다.
따라서 오프체인에서 이미 유효성이 확인된 메시지를 그대로 사용하여 두 번째 트랜잭션을 제출함으로써 두 번째 액션이 무조건 수행되도록 만드는 공격입니다.
2️⃣ 다른 주소에 동일한 컨트랙트 코드를 사용하여 리플레이 공격을 시도하는 경우
이 시나리오에서는 공격자가 첫 번째 트랜잭션과 같은 컨트랙트 코드를 사용하는 새로운 주소를 만듭니다.
이렇게 만든 새로운 주소에는 이전과 동일한 컨트랙트 코드가 배포됩니다. 그리고 공격자는 이전에 사용한 트랜잭션을 그대로 가져와서 새로운 주소의 컨트랙트에 제출합니다.
이로 인해 새로운 주소의 컨트랙트에서도 똑같은 트랜잭션이 실행되어 첫 번째와 동일한 결과가 발생합니다.
3️⃣ CREATE2와 self-destruck의 조합을 사용하여 컨트랙트를 자가 소멸한 후 CREATE2를 다시 사용하여 동일한 주소에 새로운 컨트랙트를 재생성하고 이전 메시지를 재사용하여 리플레이 공격을 시도하는 경우
이 시나리오에서는 공격자가 원래의 컨트랙트를 self-destruct를 통해 자가 소멸시킵니다.
그러면 해당 주소에 다시 새로운 컨트랙트를 생성하기 위해 CREATE2를 사용하여 새로운 컨트랙트를 배포합니다.
이렇게 생성한 새로운 컨트랙트는 이전에 사용했던 메시지를 그대로 재사용하여 두 번째 트랜잭션을 제출합니다.
블록체인은 이 트랜잭션을 먼저 처리하고 이전과 같은 주소에 다시 새로운 컨트랙트를 생성하므로, 이전 메시지를 재사용한 두 번째 트랜잭션이 또다시 처리됩니다.
💡 CREATE2
CREATE2는 스마트 컨트랙트를 생성하는 EVM 오프코드입니다. 기본적으로 CREATE 오프코드는 새로운 컨트랙트를 생성할 때마다 랜덤한 주소를 할당합니다. 하지만 CREATE2는 특정 조건에 따라 항상 동일한 주소를 생성하는 데 사용됩니다.
💡 self-destruct
self-destruct는 EVM 오프코드로, 스마트 컨트랙트를 자기 자신으로부터 제거하는 데 사용됩니다. 이는 불필요한 컨트랙트를 해제하고 스마트 컨트랙트에서 사용한 자원을 반환하는 데에 사용됩니다. 이는 이더리움의 상태를 더욱 가볍고 효율적으로 유지하는 데에 기여합니다.
self-destruct를 사용하여 컨트랙트를 제거하면 해당 주소에 대한 모든 정보와 잔액이 사라지며, 컨트랙트는 더 이상 실행되지 않습니다. 그러나 이전에 발생한 트랜잭션 기록은 블록체인에 남아 있습니다.
😭 리플레이 공격을 위한 멀티시그 컨트랙트
//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@openzeppelin/contracts/utils/Address.sol";
contract MultiSig {
using Address for address payable;
address[2] public owners;
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}
constructor(address[2] memory _owners) {
owners = _owners;
}
function transfer(address to, uint256 amount, Signature[2] memory signatures) external {
require(verifySignature(to, amount, signatures[0]) == owners[0]);
require(verifySignature(to, amount, signatures[1]) == owners[1]);
payable(to).sendValue(amount);
}
function verifySignature(address to, uint256 amount, Signature memory signature) public pure returns (address signer) {
// 메시지 길이 = 52
string memory header = "\x19Ethereum Signed Message:\n52";
// 타원 곡선 복구 작업
bytes32 messageHash = keccak256(abi.encodePacked(header, to, amount));
return ecrecover(messageHash, signature.v, signature.r, signature.s);
}
receive() external payable {}
}
위의 스마트 컨트랙트에서 transfer 함수는 주어진 서명이 소유자와 일치하는지 확인하고 일치하면 to 주소로 지정된 금액을 전송합니다.
하지만 위 스마트 컨트랙트는 리플레이 공격에 취약합니다. transfer 함수는 같은 입력값인 to, amount, signatures를 계속해서 호출할 수 있기 때문입니다.
🤔 해결책
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@openzeppelin/contracts/utils/Address.sol";
contract MultiSig {
using Address for address payable;
address[2] public owners;
mapping(bytes32 => bool) executed;
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}
constructor(address[2] memory _owners) {
owners = _owners;
}
function transfer(address to, uint256 amount, uint256 nonce, Signature[2] memory signatures) external {
address sign1;
address sign2;
bytes32 txhash1;
bytes32 txhash2;
(txhash1, sign1) = verifySignature(to, amount, nonce, signatures[0]);
(txhash2, sign2) = verifySignature(to, amount, nonce, signatures[1]);
require(!executed[txhash1] && !executed[txhash2], "Signature expired");
executed[txhash1] = true;
executed[txhash2] = true;
payable(to).sendValue(amount);
}
function verifySignature(address to, uint256 amount, uint256 nonce, Signature memory signature) public view returns (bytes32 msghash, address signer) {
// 메시지 길이 = 52
string memory header = "\x19Ethereum Signed Message:\n52";
// 타원 곡선 복구 작업
bytes32 messageHash = keccak256(abi.encodePacked(address(this), header, to, amount, nonce));
return (messageHash, ecrecover(messageHash, signature.v, signature.r, signature.s));
}
receive() external payable {}
}
1️⃣ Nonce 사용
transfer 함수에 Nonce를 입력으로 전달합니다. Nonce는 각 트랜잭션마다 고유한 값을 가져야 합니다. Nonce를 사용하면 매번 다른 값이 포함된 메시지 해시를 생성하여 유일한 서명을 만들 수 있습니다.
이를 통해 동일한 트랜잭션이 반복해서 실행하는 리플레이 공격을 방지할 수 있습니다.
2️⃣ 주소 포함
keccak256(abi.encodePacked()) 함수에서 address(this)를 메시지 해시 계산에 포함합니다. 이렇게 하면 스마트 컨트랙트의 주소가 메시지 해시에 포함되므로, 같은 컨트랙트 코드를 다른 주소에 배포하여 리플레이 공격을 시도하는 것을 막을 수 있습니다.