시리즈의 마지막 편입니다. 1~3부에서 다룬 취약점은 대부분 온체인에 원인이 있었습니다. 4부는 그 경계를 완전히 넘어갑니다. 하나는 서명이라는 암호학적 증거가 맥락을 벗어나 재사용되는 리플레이 공격이고, 다른 하나는 아예 블록체인 바깥의 키 관리와 운영이 뚫린 경우입니다. 흥미롭게도, 역대 최대 규모 손실의 상당수가 바로 이 “경계 지점”에서 발생했습니다.
서명은 “이 메시지를 이 키의 소유자가 승인했다”는 암호학적 증거입니다. 그런데 서명이 어떤 맥락에서 유효한지를 명시적으로 제한하지 않으면, 같은 서명을 전혀 다른 맥락에서 그대로 재사용할 수 있게 됩니다. 실무에서 흔히 발견되는 누락이 세 가지 있습니다.
// 취약: nonce 도 도메인 구분도 없다
function claim(uint amount, bytes memory sig) public {
bytes32 hash = keccak256(abi.encodePacked(msg.sender, amount));
address signer = recover(hash, sig);
require(signer == authorizer, "invalid sig");
payable(msg.sender).transfer(amount); // 같은 sig 로 무한 반복 가능
} 한 번 발급받은 유효한 sig를 반복해서 claim에 제출하면, 서명이 소진되었는지를 추적하는 nonce가 없으므로 매번 검증을 통과합니다. 승인은 분명 한 번뿐이었는데, 출금은 서명이 유효한 한 몇 번이고 일어나는 것입니다.
핵심은 서명 대상 데이터에 nonce + chainId + 컨트랙트 주소를 반드시 함께 묶어, 서명이 “이 체인에서, 이 컨트랙트에 대해, 이 한 번의 실행”에만 유효하도록 만드는 것입니다. EIP-712는 바로 이를 표준화한 서명 스킴입니다.
// 안전: EIP-712 도메인 구분자 + nonce + deadline
bytes32 public immutable DOMAIN_SEPARATOR;
mapping(address => uint256) public nonces;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256(bytes("ChainLabVault")),
block.chainid, // ← 체인 고정
address(this) // ← 컨트랙트 고정
));
}
function claim(uint amount, uint deadline, bytes memory sig) public {
require(block.timestamp <= deadline, "expired"); // ← 만료시각
uint256 nonce = nonces[msg.sender]++; // ← 1회용 nonce
bytes32 structHash = keccak256(abi.encode(
keccak256("Claim(address to,uint256 amount,uint256 nonce,uint256 deadline)"),
msg.sender, amount, nonce, deadline
));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
require(recover(digest, sig) == authorizer, "invalid sig");
payable(msg.sender).transfer(amount);
} 이렇게 하면 같은 서명을 다시 제출해도, nonce가 이미 소진되어 다음번에는 다른 structHash가 계산되므로 검증에 실패합니다. 또한 다른 체인이나 다른 컨트랙트에서는 DOMAIN_SEPARATOR 값 자체가 달라지므로, 그 서명은 애초에 무효가 됩니다. 서명이 특정 맥락에 단단히 묶이는 것입니다.
지금까지의 모든 방어를 완벽하게 구현하더라도, 키를 도둑맞으면 그 모든 것이 한순간에 무너집니다. 컨트랙트 입장에서, 올바른 서명이 담긴 트랜잭션은 정당한 것으로 취급될 수밖에 없으며, 그 키가 사실은 탈취된 것인지를 알아낼 방법이 없습니다. 특히 여러 체인을 잇는 브리지는 막대한 자금을 소수의 검증자 키로 지키는 구조이기 때문에, 공격자들에게 최우선 표적이 됩니다.
Axie Infinity의 Ronin 브리지는 9개 검증자 중 5개의 서명이 모이면 출금을 승인하는 멀티시그 구조였습니다. 표면적으로는 상당히 견고해 보입니다. 그러나 진짜 문제는 온체인이 아니라 오프체인의 키 관리와 사회공학에 있었습니다.
여기서 반드시 주목해야 할 점은, 스마트 컨트랙트에는 아무런 버그가 없었다는 사실입니다. 5-of-9 로직은 처음부터 끝까지 명세대로 정확하게 작동했습니다. 무너진 것은 코드가 아니라 키의 물리적 보관과, 한 번 부여한 위임 권한의 수명을 관리하는 운영 체계였습니다.
브리지가 이렇게 반복적으로 최대 피해처가 되는 이유는 명확합니다. 한곳에 막대한 자금이 집중되는데, 그 방어는 소수의 키에 의존하기 때문입니다. 자금 집중과 방어 집중이 같은 지점에 겹쳐 있는 것입니다.
| # | 문제 | 무너진 지점 | 대표 사례 | 핵심 방어 |
|---|---|---|---|---|
| 9 | 서명 리플레이 | 서명의 맥락 제한 부재 | (일반 패턴) | EIP-712 · nonce · deadline |
| 10 | 키 유출 / 브리지 해킹 | 오프체인 키·위임 관리 | Ronin, Wormhole | 임계값 · HSM · 위임 만료 |
10가지 사례를 관통하는 통찰을 정리하면 이렇습니다.
결국 블록체인 보안은 “완벽한 스마트 컨트랙트 하나”를 짜는 문제가 아닙니다. 코드부터 경제, 합의, 운영에 이르는 모든 층위의 신뢰 경계를 함께 지켜내는 시스템 설계의 문제입니다.
「블록체인 보안의 10가지 얼굴」 시리즈 완결. ChainLab — 세상을 잇는 체인.
Tags: 블록체인보안, EIP712, 서명리플레이, 브리지해킹, 키관리
중고거래 안전결제, 다들 써보셨죠? AI 결제에도 이게 필요합니다. 문제는, 생각보다 훨씬 까다롭다는 거예요. 중고거래를 떠올려…
앞 편의 x402가 자판기였다면, 이번 두 방식은 '용돈 한도'와 '술집 외상장부'에 가깝습니다. 앞 편 복습:…