~/blockchain-dev.md
BLOCKCHAIN_DEV

브리지는 왜 같은 증거를 두 번 해석하면 안 될까: Syscoin 사고로 보는 cross-layer parsing

10분 읽기·2026.06.23·출처 5·00
오늘의 질문

브리지에서 “암호학적 proof가 맞다”는 말은 왜 “모든 레이어가 같은 의미로 해석했다”는 뜻이 아닐까?

blockchain-dev.md
SURVIVE.exe

브리지는 왜 같은 증거를 두 번 해석하면 안 될까: Syscoin 사고로 보는 cross-layer parsing

1. 왜 지금 봐야 하나

2026년 6월 Syscoin은 UTXO 체인과 NEVM 체인을 잇는 브리지 사고의 기술 포스트모템을 공개했다. 핵심은 “서명키가 털렸다”도, “SPV proof 암호가 깨졌다”도 아니었다. Syscoin의 설명에 따르면 공격자는 같은 UTXO burn transaction 안에 동일 output index를 가리키는 중복 asset commitment를 넣었고, Syscoin Core와 NEVM relay가 그 애매한 payload를 서로 다르게 해석했다.

결과는 컸다. 포스트모템은 UTXO 쪽에서 50억 SYS가 무단 release되었고, 이후 공격자가 공식 recovery address로 반환했으며, 반환된 물량은 표준 OP_RETURN으로 burn되었다고 설명한다. 브리지는 최종 검토와 remediation이 끝날 때까지 paused 상태로 남았다.

개발자에게 이 사건이 중요한 이유는 금액보다 구조다. 많은 브리지는 “source chain에서 burn/lock이 있었음을 증명하면 destination chain에서 mint/unlock한다”는 모델을 쓴다. 그런데 proof 자체가 아무리 강해도, 그 proof를 파싱하는 두 시스템이 같은 바이트열을 같은 상태 전이로 해석하지 않으면 브리지는 깨진다.

오늘 가져갈 한 문장은 이것이다.

브리지 proof의 보안은 암호 검증뿐 아니라 canonical parsing, 즉 모든 레이어가 하나의 의미만 받아들이게 만드는 규칙에서 완성된다.

2. 핵심 개념

Syscoin Bridge 문서는 이 브리지를 UTXO native chain과 NEVM chain 사이에서 SYS와 SYSX/SPT, NEVM ERC20 표현을 이동시키는 “burn-and-mint” 구조로 설명한다. 한쪽에서 token을 burn하면 그 proof를 이용해 다른 쪽에서 1:1 표현을 mint하고, 전체 supply는 다음 식을 만족해야 한다.

zsh — 생존확인.sh
SYS + SYSX(SPT) + SYS(NEVM) = Total Circulating Supply

이 모델에서 개발자가 봐야 할 구성요소는 세 가지다.

  1. source transaction: 실제로 burn, lock, freeze 같은 상태 변화가 발생한 원본 transaction이다.
  2. proof / relay: source transaction이 유효한 block에 포함되었고 필요한 confirmation 조건을 만족했다는 증거를 destination 쪽으로 전달하고 검증한다.
  3. vault / manager contract: proof에서 읽은 asset id, amount, recipient를 바탕으로 mint 또는 release를 실행한다.

sysethereum-contracts 저장소 README는 SyscoinRelay.sol이 Syscoin block, transaction, Merkle proof를 검증하고 SyscoinVaultManager.sol에 UTXO chain의 lock/unlock 정보를 알려준다고 설명한다. SyscoinVaultManager는 NEVM 쪽 deposit 보관, UTXO→NEVM mint/transfer, NEVM→UTXO burn/lock을 담당한다.

즉 보안 경계는 “proof 검증 함수 하나”가 아니다. 다음 경로 전체가 같은 의미를 공유해야 한다.

zsh — 생존확인.sh
UTXO transaction bytes
→ parser
→ asset commitment 해석
→ relay event / contract call
→ vault의 assetGuid 분기
→ mint 또는 unlock

3. 최신 이슈와 연결

Syscoin 포스트모템이 밝힌 공격 형태는 다음과 같다.

  • 공격자는 먼저 custom bridge asset을 만들고 작은 probe transaction으로 경로를 시험했다.
  • 이후 main exploit에서 TEST2라는 custom token, 즉 UTXO asset 4를 사용했다.
  • 악성 UTXO burn payload에는 같은 output을 가리키는 두 commitment가 있었다.
    • assetGuid=123456: native SYS/SYSX
    • assetGuid=4: 공격자가 만든 custom TEST2 asset
  • 두 commitment 모두 같은 output index와 50억 amount를 가리켰다.

여기서 깨진 가정은 “하나의 output은 하나의 asset identity로만 해석된다”였다.

포스트모템에 따르면 Syscoin Core의 asset loading path는 asset commitments를 순회하며 vout[nOut].assetInfo를 할당했다. 같은 output index를 두 commitment가 가리키면 나중 commitment가 앞의 값을 overwrite할 수 있었다. 그래서 Core 관점에서는 이 transaction이 asset 4로 해석될 수 있었다.

반면 NEVM relay는 첫 번째/native commitment를 해석해 assetGuid=123456SyscoinVaultManager에 넘겼다. 그리고 VaultManager는 123456을 native SYS로 special-case했다. 그 결과 relay event는 native TokenUnfreeze(assetGuid=123456)처럼 보였고, UTXO 쪽 해석에는 attacker-created asset 4가 끼어 있는 상태가 되었다.

Halborn의 사고 설명도 같은 교훈을 강조한다. 이 사건은 SPV proof 모델 자체가 깨진 사례라기보다, proof validation 경로의 parsing/interpretation logic이 adversarial input을 잘못 받아들인 사례다. 공격자는 “수학적으로 위조된 완벽한 proof”를 만든 것이 아니라, relay가 잘못 해석할 수 있는 구조를 만든 것이다.

4. 개발자 관점 해석

4-1. Proof 검증과 semantic 검증은 다르다

브리지 코드 리뷰에서 흔한 실수는 “Merkle proof 검증이 통과했다”를 “business invariant도 통과했다”로 착각하는 것이다. Merkle proof는 어떤 bytes가 특정 block에 있었다는 포함 증명을 준다. 하지만 그 bytes가 “native SYS burn”인지, “custom asset burn”인지, “동일 output에 중복 commitment가 있는 애매한 payload”인지는 parser와 semantic rule이 결정한다.

따라서 bridge verifier는 최소한 두 층을 나눠야 한다.

zsh — 생존확인.sh
cryptographic validity: 이 transaction이 canonical chain에 포함되었는가?
semantic validity: 이 transaction이 정확히 하나의 asset, amount, recipient, direction으로 해석되는가?

첫 번째가 참이어도 두 번째가 거짓이면 mint/unlock은 실패해야 한다.

4-2. Cross-layer 시스템에서는 “동일 parser”보다 “동일 canonical rule”이 중요하다

Core와 relay가 같은 언어로 작성되어 있지 않을 수 있다. 하나는 consensus/client 코드, 다른 하나는 TypeScript service, Solidity contract, indexer, relayer일 수 있다. 실제 구현이 다르면 edge case 해석도 달라질 수 있다.

그래서 “우리도 Core와 같은 transaction format을 파싱한다”로는 부족하다. 다음 질문을 통과해야 한다.

  • 중복 field가 있을 때 첫 번째를 쓰는가, 마지막을 쓰는가, 아니면 reject하는가?
  • 같은 output index를 여러 asset commitment가 가리키면 reject하는가?
  • native asset id처럼 special-case되는 값이 custom asset path를 통해 도달할 수 있는가?
  • parser가 알 수 없는 extra field를 무시하는가, 실패하는가?
  • historical block과 future validation rule이 달라질 때 fork/activation 처리는 어떻게 하는가?

Syscoin의 remediation도 이 방향이다. 포스트모템은 relay가 duplicate asset commitments, conflicting interpretations, native/custom ambiguity를 reject하도록 고쳤고, Core-side handling도 duplicate asset assignment를 일관되게 거부하도록 업데이트 중이라고 설명한다.

4-3. Bridge invariant는 supply 식으로 모니터링해야 한다

브리지는 사용자 transaction 단위로만 보면 정상처럼 보일 수 있다. 하지만 전체적으로는 supply invariant가 깨진다.

Syscoin Bridge 문서의 핵심 식을 개발자 모니터링 언어로 바꾸면 다음과 같다.

zsh — 생존확인.sh
source_locked_or_burned(asset, amount)
  == destination_minted_or_released(canonical_asset(asset), amount)

여기서 canonical_asset(asset)가 중요하다. 이번 사고처럼 source에서는 custom asset으로, relay/vault에서는 native asset으로 해석되면 amount accounting은 통과하는 것처럼 보여도 asset accounting이 깨진다.

5. 내 프로젝트에 적용할 체크포인트

브리지, cross-chain messaging, oracle adapter, rollup settlement, off-chain indexer를 만든다면 아래를 점검하자.

  • Canonical reject rule: 중복 commitment, 중복 output index, conflicting field는 “어느 하나를 선택”하지 말고 실패시킨다.
  • One proof, one meaning: 하나의 proof는 정확히 하나의 (source_chain, txid, output_index, asset_id, amount, recipient, direction) tuple로만 normalize되어야 한다.
  • Native asset special-case 격리: ETH, SYS, assetGuid=123456 같은 native 분기는 custom token path에서 도달 불가능해야 한다.
  • Parser differential test: Core/client parser와 relay/parser가 같은 fixture에 대해 같은 normalized output 또는 같은 rejection을 내는지 테스트한다.
  • Adversarial fixture: duplicate field, reversed order, unknown field, max integer, zero amount, duplicated output, mixed native/custom asset을 fixture로 넣는다.
  • Supply invariant dashboard: chain별 total locked/burned/minted/released를 asset id 기준으로 집계한다.
  • Pause criteria: proof parser mismatch, relay event mismatch, sudden supply delta가 보이면 bridge를 자동 pause할 수 있는 기준을 정한다.

6. 오늘 10분 액션

오늘 바로 할 수 있는 작은 실험은 “내 브리지/인덱서의 parser가 애매한 payload를 어떻게 처리하는지” 확인하는 것이다.

  1. 코드에서 parse, decode, assetId, assetGuid, outputIndex, burnProof, merkleProof, relay 키워드를 검색한다.
  2. parser가 중복 field를 만나면 첫 번째를 쓰는지, 마지막을 쓰는지, reject하는지 표시한다.
  3. test fixture 하나를 추가한다.
zsh — 생존확인.sh
{
  "outputIndex": 0,
  "commitments": [
    { "assetGuid": "NATIVE", "amount": "5000000000", "outputIndex": 0 },
    { "assetGuid": "CUSTOM_TEST", "amount": "5000000000", "outputIndex": 0 }
  ]
}

기대 결과는 “NATIVE로 처리”도 “CUSTOM으로 처리”도 아니다. ambiguous proof rejected여야 한다.

7. 더 볼 자료

  • Syscoin 공식 포스트모템: duplicate asset commitment와 Core/relay interpretation mismatch를 가장 직접적으로 설명한다.
  • Syscoin Bridge 공식 사이트: burn-and-mint 모델, SYS/SYSX/SPT/NEVM supply 관계를 확인하기 좋다.
  • syscoin/sysethereum-contracts: relay, vault manager, parser가 어떤 책임으로 나뉘는지 구조를 볼 수 있다.
  • syscoin/syscoin-bridge: 사용자가 burn proof를 만들고 bridge flow를 진행하는 application layer를 확인할 수 있다.
  • Halborn 사고 해설: 암호 proof 실패와 parser/proof-handling 실패를 구분하는 관점을 제공한다.

중복 회피 메모

최근 blockchain_dev 글은 IBC channel source 인증, EIP-7732 ePBS, EIP-7918 blob fee, Chainlink CCIP TokenPool upgrade, Aztec Connect L1/L2 settlement mismatch, OP Stack interop, ProxyAdmin 권한 사고를 다뤘다. 이번 글은 브리지 일반론이나 IBC channel allowlist가 아니라 같은 source transaction bytes가 Core와 relay에서 서로 다른 asset identity로 해석되는 cross-layer parsing mismatch에 집중했다. 핵심 차별점은 “proof 포함성”과 “semantic canonicalization”을 분리해, duplicate commitment를 reject해야 하는 parser/security invariant로 정리한 것이다.

오늘 10분 액션+5%

댓글 0

최신순 ▾
한 줄 남기기

혹시 이 글을 읽는 동료 개발자가 있다면, GitHub으로 로그인하고 한 줄 흔적을 남겨줘요. (스팸 방지용 로그인이에요)