지금까지는 컨트랙트 레벨(1부)과 경제 레벨(2부)을 다뤘습니다. 3부는 방향이 둘로 나뉩니다. 하나는 블록체인의 토대인 합의 알고리즘 그 자체를 겨냥하는 51% 공격이고, 다른 하나는 컨트랙트가 “누가 이 함수를 부를 수 있는가”를 제대로 정하지 못해 발생하는 접근 제어 실패입니다. 층위는 서로 다르지만, 두 문제 모두 “권한의 경계”가 무너진다는 공통점을 가집니다.
작업증명(PoW) 블록체인의 보안은 “정직한 참여자가 전체 해시파워의 과반을 차지한다”는 단 하나의 가정에 기대고 있습니다. 만약 한 주체가 해시파워의 과반을 확보하면, 그는 다른 누구보다 빠르게 블록을 생성할 수 있고, 따라서 자신만의 더 긴 체인을 남몰래 만들 수 있습니다. 네트워크는 가장 긴 체인을 유효한 것으로 채택하는 최장 체인 규칙(longest chain rule)을 따르므로, 이 공격자는 어느 순간 기존 체인을 자신의 체인으로 교체(reorg) 해버릴 수 있습니다.
여기서 핵심은 컨트랙트 버그가 전혀 없다는 점입니다. 프로토콜은 규칙대로 정확히 작동했습니다. 다만 그 규칙의 안전을 떠받치던 가정, 즉 “과반은 정직하다”는 전제가 깨졌을 뿐입니다.
두 사례의 공통점은 해시파워가 작은 체인이라는 것입니다. 네트워크 전체 해시레이트가 낮을수록 과반을 임대하거나 확보하는 비용이 낮아지고, 그만큼 공격의 경제성이 성립합니다. 반대로 비트코인이나 이더리움 같은 대형 체인은 과반을 확보하는 데 드는 비용이 천문학적이어서 사실상 방어됩니다.
컨트랙트의 민감한 함수 — 자금 인출, 소유권 변경, 자기파괴 등 — 는 반드시 “누가 호출할 수 있는가”를 명시적으로 제한해야 합니다. onlyOwner 같은 제어자나 초기화 가드가 빠지는 순간, 누구나 그 함수를 호출할 수 있게 되어 컨트랙트의 통제권이 통째로 노출됩니다.
// 취약: 초기화 함수에 가드가 없어 누구나 재초기화 가능
contract Wallet {
address public owner;
// initializer 가드가 없다!
function initWallet(address _owner) public {
owner = _owner; // 공격자가 자신을 owner 로 설정 가능
}
function withdraw(uint amount) public {
require(msg.sender == owner);
payable(msg.sender).transfer(amount);
}
} 이미 정상적으로 초기화된 지갑이라도, 공격자가 initWallet(attacker)를 다시 호출하면 소유권이 그대로 공격자에게 넘어갑니다. 재초기화를 막는 가드가 없기 때문입니다. 소유자가 된 공격자는 그 뒤 withdraw를 호출해 자금을 모조리 빼냅니다.
1차 (2017년 7월): Parity 멀티시그 지갑의 초기화 함수가 위 예시와 유사하게 보호되지 않아, 공격자가 여러 지갑의 소유권을 재설정하고 약 3천만 달러를 탈취했습니다.
2차 (2017년 11월): 이쪽이 훨씬 더 인상적입니다. Parity 지갑들은 로직을 하나의 공용 라이브러리 컨트랙트에 위임(delegatecall)하는 구조였는데, 이 라이브러리 자체가 초기화되지 않은 상태로 방치되어 있었고, 게다가 kill()(self-destruct) 함수가 아무런 보호 없이 노출되어 있었습니다. devops199라는 사용자가 실수로 이 라이브러리를 자신의 소유로 초기화한 뒤 self-destruct를 호출했습니다. 그러자 이 라이브러리에 로직을 의존하던 모든 멀티시그 지갑이 동시에 벽돌이 되어, 약 5억 달러 이상의 자금이 영구히 동결되었습니다. 누군가 훔쳐간 것이 아니라 아무도 영원히 접근할 수 없게 되었다는 점이 특히 뼈아팠습니다.
이 2차 사건은 사실 1부에서 다룬 delegatecall/스토리지 취약점과 접근 제어 실패가 결합된 사례입니다. 초기화되지 않은 라이브러리 + 보호되지 않은 파괴 함수 = 재앙이라는 공식이 그대로 성립했습니다.
onlyOwner나 역할 기반 접근 제어(OpenZeppelin AccessControl)를 적용해, 호출 권한을 코드에 못박아 둡니다.initializer 제어자를 사용해 초기화 함수가 단 한 번만 실행되도록 강제합니다.selfdestruct 사용을 권장하지 않는 방향으로 가고 있습니다.)// 안전: 초기화는 단 한 번, 파괴 함수는 소유자 전용
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Wallet is Initializable {
address public owner;
function initialize(address _owner) public initializer { // 1회만 실행
owner = _owner;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
} | # | 문제 | 무너진 경계 | 대표 사례 | 핵심 방어 |
|---|---|---|---|---|
| 7 | 51% 공격 | 합의의 정직 과반 가정 | ETC, Bitcoin Gold | 컨펌 상향 · PoS |
| 8 | 접근 제어 실패 | 함수 호출 권한 | Parity (2건) | initializer · onlyOwner |
51% 공격은 프로토콜의 안전 가정이 무너진 경우이고, 접근 제어 실패는 컨트랙트의 권한 경계가 무너진 경우입니다. 서로 다른 층위처럼 보이지만, 둘 다 “누가 무엇을 할 수 있는가”라는 질문에 대한 답이 흐트러졌을 때 발생한다는 점에서 본질이 같습니다.
마지막 4부에서는 온체인 코드를 완전히 벗어납니다. 서명이라는 암호학적 증거가 맥락을 벗어나 재사용되는 리플레이 공격, 그리고 역대 최대 규모의 해킹인 Ronin 브리지의 오프체인 키 유출 사건을 다룹니다.
이 글은 ChainLab 기술 시리즈의 일부입니다.
Tags: 블록체인보안, 51퍼센트공격, 이중지불, 접근제어, Parity
중고거래 안전결제, 다들 써보셨죠? AI 결제에도 이게 필요합니다. 문제는, 생각보다 훨씬 까다롭다는 거예요. 중고거래를 떠올려…
앞 편의 x402가 자판기였다면, 이번 두 방식은 '용돈 한도'와 '술집 외상장부'에 가깝습니다. 앞 편 복습:…