Back to Blog

검증되지 않은 매핑이 노마드 브리지에 2억 달러 손실을 초래한 방법 | 공격 분석

Code Auditing
August 2, 2022
6 min read

요약

2022년 8월 2일, Nomad Bridge라는 크로스체인 브리지가 공격을 받아 약 2억 달러의 손실이 발생했습니다. 근본 원인은 업그레이드된 온체인 스마트 컨트랙트의 잘못된 검증 로직에 있습니다.

배경

Nomad Bridge는 사기 증명(fraud-proof) 기반 설계를 사용하는 신흥 크로스체인 자산 브리지입니다. 작동 방식은 다음과 같습니다:

  1. Nomad는 크로스체인 메시지의 메일박스 역할을 하는 Replica라는 핵심 컨트랙트를 지원되는 각 블록체인에 배포합니다.
  2. 오프체인 에이전트가 크로스체인 메시지를 중계하고 머클 트리에 정리한 뒤, 서명된 새 트리 루트 해시를 이 컨트랙트에 게시하여 트리 루트를 업데이트합니다.
  3. 온체인에서 확인이 필요한 새 메시지는 prove()process() 절차를 모두 거쳐야 합니다.
    1. prove() 절차는 메시지와 머클 트리의 증명을 검증한 뒤, 해당 메시지를 증명된 것으로 표시합니다.
    2. 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을 반환합니다. 그러면 acceptableRootconfirmAt 매핑에서 0 해시 항목에 접근하고 true를 반환하게 됩니다.

공격

위의 코드 분석에서 알 수 있듯이, 이전에 본 적 없는 메시지가 검증 로직을 그냥 통과하여 실행될 수 있습니다. 따라서 메시지를 위조하고 process()를 호출하기만 하면 됩니다.

흥미롭게도 이 컨트랙트에서 process() 함수에 대한 첫 번째 호출은 불과 이틀 전(블록 15249565)에 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0에서 발생했습니다.

아래 그림에서 이 메시지에 대한 messages 상태 변수의 스토리지 슬롯이 원래 0이었음을 확인할 수 있으며, 이는 Replica 컨트랙트가 이 메시지를 이전에 알지 못했음을 의미합니다.

그런 다음 이 슬롯이 2로 설정되었습니다(즉, 이 메시지가 처리되었음을 의미하는 LEGACY_STATUS_PROCESSED 상태). 이는 유효하지 않은 메시지가 prove() 로직을 우회하고 직접 처리되었음을 나타냅니다.

결론

이것은 매핑에서 조회된 반환 값을 확인하지 않아 발생하는 또 다른 전형적인 공격 사례입니다. Solidity 개발자들은 예상치 못한 결과를 방지하기 위해 매핑을 다룰 때 특별한 주의를 기울여야 합니다.

Sign up for the latest updates
~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리
Security Insights

~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리

2026년 8월 3~9일, BNB 체인에서 2건의 보안 사고가 발생해 총 약 160만 달러의 손실이 발생했으며, 모두 가격 조작이 원인이었습니다. LpdFi(약 69.7만 달러)는 PancakeSwap 유동성 풀을 주문 평가와 이자 정산에 동시 사용해 공격자가 원금을 부풀리고 과도한 이자를 탈취했습니다. Moke Token(약 90.6만 달러)은 조작 가능한 현물 가격과 중복 LP 배당 회계를 결합해 MOKE를 부풀려 BNB 배당을 반복 수령했습니다.

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때
Security Insights

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때

COLDCARD 펌웨어의 빌드 통합 버그로 비트코인 시드 생성이 취약한 소프트웨어 RNG로 라우팅되어 지갑 시드가 오프라인 복구 가능해졌습니다. 시드 자체의 취약점이므로 펌웨어 업데이트로 해결 불가하며, 2026년 8월 7일 기준 확인된 피해는 1,405 BTC(~9,100만 달러), 비공개 추정치는 최대 2,055 BTC입니다.

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리
Security Insights

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리

2026년 7월 27일~8월 2일, 비트코인과 BNB 체인에서 약 8,800만 달러 손실을 유발한 두 건의 보안 사고가 발생했습니다. COLDCARD 사고는 하드웨어 지갑 펌웨어의 엔트로피 오류로, RNG 매크로 활성화 여부 미확인으로 결정론적 폴백이 실행되어 약 1,370 BTC(~8,800만 달러)가 탈취됐습니다. BNB 체인의 LULA 토큰은 비즈니스 로직 취약점으로 `recycle()` 함수가 악용되어 PancakeSwap V2 유동성에서 약 57만 8천 달러가 유출됐습니다.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit
검증되지 않은 매핑이 노마드 브리지에 2억 달러 손실을 초래한 방법 | 공격 분석