요약
2022년 8월 2일, Nomad Bridge라는 크로스체인 브리지가 공격을 받아 약 2억 달러의 손실이 발생했습니다. 근본 원인은 업그레이드된 온체인 스마트 컨트랙트의 잘못된 검증 로직에 있습니다.
배경
Nomad Bridge는 사기 증명(fraud-proof) 기반 설계를 사용하는 신흥 크로스체인 자산 브리지입니다. 작동 방식은 다음과 같습니다:
- Nomad는 크로스체인 메시지의 메일박스 역할을 하는 Replica라는 핵심 컨트랙트를 지원되는 각 블록체인에 배포합니다.
- 오프체인 에이전트가 크로스체인 메시지를 중계하고 머클 트리에 정리한 뒤, 서명된 새 트리 루트 해시를 이 컨트랙트에 게시하여 트리 루트를 업데이트합니다.
- 온체인에서 확인이 필요한 새 메시지는
prove()와process()절차를 모두 거쳐야 합니다.prove()절차는 메시지와 머클 트리의 증명을 검증한 뒤, 해당 메시지를 증명된 것으로 표시합니다.process()절차는 메시지가 이전에 증명되었고 연관된 트리 루트가 확인된 경우, 해당 메시지를 검사하고 실행합니다.
코드
이더리움에서 Replica는 0x5d94309e5a0090b165fa4181519701637b6daeba에 배포된 Beacon 프록시입니다. 논리 컨트랙트에는 두 가지 버전이 있으며, 첫 번째 버전은 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b에, 두 번째 버전은 0xb92336759618f55bd0f8313bd843604592e27bd8에 배포되어 있습니다.
먼저 논리 컨트랙트의 이전 버전, 특히 process() 함수를 살펴보겠습니다:
function process(bytes memory _message) public returns (bool _success) {
bytes29 _m = _message.ref(0);
// ensure message was meant for this domain
require(_m.destination() == localDomain, "!destination");
// ensure message has been proven
bytes32 _messageHash = _m.keccak();
require(messages[_messageHash] == MessageStatus.Proven, "!proven");
// check re-entrancy guard
require(entered == 1, "!reentrant");
entered = 0;
// update message status as processed
messages[_messageHash] = MessageStatus.Processed;
이 함수의 일부만 보여드립니다. 이 코드 세그먼트에서는 메시지 해시가 계산되고, 해당 해시가 messages 매핑과 대조되어 메시지가 이전에 증명되었는지 확인한 뒤, 재진입 검사를 수행하고 메시지 상태를 업데이트합니다.
이전 prove() 함수도 간략히 살펴보겠습니다:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// ensure that message has not been proven or processed
require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
// calculate the expected root based on the proof
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// if the root is valid, change status to Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = MessageStatus.Proven;
return true;
}
return false;
}
특별한 점은 없습니다: 중복 검사, 트리 루트 계산, 허용 가능한 경우 증명 표시. 따라서 이전 버전의 Replica 컨트랙트에서는 증명된 모든 메시지에 특별한 표시(MessageStatus.Proven = 1)가 있습니다.
이제 두 번째 버전의 논리 컨트랙트를 살펴보겠습니다. 새 버전에서는 먼저 prove() 함수를 확인합니다:
function prove(
bytes32 _leaf,
bytes32[32] calldata _proof,
uint256 _index
) public returns (bool) {
// ensure that message has not been processed
// Note that this allows re-proving under a new root.
require(
messages[_leaf] != LEGACY_STATUS_PROCESSED,
"already processed"
);
// calculate the expected root based on the proof
bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
// if the root is valid, change status to Proven
if (acceptableRoot(_calculatedRoot)) {
messages[_leaf] = _calculatedRoot;
return true;
}
return false;
}
여기서 주요 변경 사항을 즉시 발견할 수 있습니다: 어떤 이유에서인지 개발자들이 특별한 표시 대신 계산된 루트를 증명 상태로 기록하기로 결정했습니다. 이 함수에서는 머클 트리 루트 해시가 0이 아님이 보장되므로 문제가 없습니다. 또한 트리 루트가 확인되는 즉시 해당 트리 루트로 증명된 새 메시지들이 실행될 준비가 되므로 합리적이기도 합니다.
이제 새 버전의 process() 함수를 확인하겠습니다:
function process(bytes memory _message) public returns (bool _success) {
// ensure message was meant for this domain
bytes29 _m = _message.ref(0);
require(_m.destination() == localDomain, "!destination");
// ensure message has been proven
bytes32 _messageHash = _m.keccak();
require(acceptableRoot(messages[_messageHash]), "!proven");
// check re-entrancy guard
require(entered == 1, "!reentrant");
entered = 0;
// update message status as processed
messages[_messageHash] = LEGACY_STATUS_PROCESSED;
messages[_messageHash] 부분에 주목하세요. 존재하지 않는 매핑 항목을 조회하면 0이 반환된다는 것은 흔한 함정입니다. 이 맥락에서는 해당 메시지 해시와 연관된 머클 트리 루트가 0임을 의미합니다. 이 0값의 결과를 추가로 확인해야 합니다. 따라서 새로운 acceptableRoot() 함수를 면밀히 살펴봐야 합니다.
function acceptableRoot(bytes32 _root) public view returns (bool) {
// this is backwards-compatibility for messages proven/processed
// under previous versions
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;
uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;
}
기본적으로 이 함수는 confirmAt 매핑을 확인하여 머클 트리 루트가 확인되었는지 검사합니다.
안타깝게도 Replica 컨트랙트의 두 버전 모두 초기화 함수에서 0 해시에 1이 설정됩니다:
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot, // this is zero at initialization
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// set storage variables
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// pre-approve the committed root.
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}
이전 버전의 Replica 컨트랙트에서는 이것이 완전히 문제없습니다: prove()에서 트리 루트 해시가 0이 될 수 없으므로 confirmAt 매핑에서 0 해시 항목을 1로 설정하는 것이 안전합니다.
그러나 새 버전에서는 새 메시지에 대해 messages[_messageHash]가 0을 반환합니다. 그러면 acceptableRoot가 confirmAt 매핑에서 0 해시 항목에 접근하고 true를 반환하게 됩니다.
공격
위의 코드 분석에서 알 수 있듯이, 이전에 본 적 없는 메시지가 검증 로직을 그냥 통과하여 실행될 수 있습니다. 따라서 메시지를 위조하고 process()를 호출하기만 하면 됩니다.
흥미롭게도 이 컨트랙트에서 process() 함수에 대한 첫 번째 호출은 불과 이틀 전(블록 15249565)에 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0에서 발생했습니다.
아래 그림에서 이 메시지에 대한 messages 상태 변수의 스토리지 슬롯이 원래 0이었음을 확인할 수 있으며, 이는 Replica 컨트랙트가 이 메시지를 이전에 알지 못했음을 의미합니다.

그런 다음 이 슬롯이 2로 설정되었습니다(즉, 이 메시지가 처리되었음을 의미하는 LEGACY_STATUS_PROCESSED 상태). 이는 유효하지 않은 메시지가 prove() 로직을 우회하고 직접 처리되었음을 나타냅니다.
결론
이것은 매핑에서 조회된 반환 값을 확인하지 않아 발생하는 또 다른 전형적인 공격 사례입니다. Solidity 개발자들은 예상치 못한 결과를 방지하기 위해 매핑을 다룰 때 특별한 주의를 기울여야 합니다.



