Cobo 보안 팀: ETH 하드포크에 숨겨진 리스크와 차익거래 기회
이 글은 Cobo 블록체인 보안 팀에서 제공한 기고문입니다. 팀원들은 유명한 블록체인 보안 업체 출신으로, 스마트 계약 감사에 풍부한 경험을 보유하고 있으며, 여러 DeFi 프로젝트에서 고위험 취약점을 발견한 바 있습니다. 현재 이 팀은 스마트 계약 보안 및 DeFi 보안 등 분야를 중심으로 최신 블록체인 보안 기술을 연구하고 공유하고 있습니다.
또한 암호화폐 분야에 대해 탐구 정신과 과학적 방법론을 갖춘 평생 학습자들의 동참을 기대하며, 업계에 깊이 있는 통찰과 연구 성과를 공유할 수 있기를 바랍니다!
이 글은 Cobo Global의 16번째 기사입니다.
서론
ETH가 PoS 합의 시스템으로 업그레이드됨에 따라, 일부 커뮤니티의 지지를 받은 기존 PoW 방식의 ETH 체인이 성공적으로 하드포크되었습니다(아래에서는 ETHW라고 약칭함). 그러나 일부 블록체인 프로토콜들이 설계 초기에 가능한 하드포크 상황을 고려하지 않아, 해당 프로토콜들이 ETHW 포크 체인에서 일정한 보안 위험에 직면하게 되었으며, 그 중 가장 심각한 위험은 재생공격(replay attack)입니다.
하드포크 완료 후, ETHW 메인넷에서는 적어도 두 건의 재생공격이 발생하였는데, 각각 OmniBridge와 Polygon Bridge에서 발생한 사례입니다. 본문에서는 이 두 사건을 사례로 삼아, 재생공격이 포크 체인에 미치는 영향과 프로토콜이 이러한 공격을 어떻게 방지해야 하는지 분석합니다.
재생공격의 유형
분석에 앞서 먼저 재생공격의 유형에 대한 기본적인 이해가 필요합니다. 일반적으로 재생공격은 두 가지로 나뉘며, 바로 '거래 재생(transaction replay)'과 '서명 메시지 재생(signature message replay)'입니다. 아래에서 두 유형의 차이점을 설명하겠습니다.
거래 재생
거래 재생이란 원래 체인의 거래를 그대로 목표 체인으로 옮기는 작업을 의미하며, 거래 수준에서의 재생으로, 재생된 거래도 정상적으로 실행되고 검증될 수 있습니다. 대표적인 사례로 Wintermute가 Optimism에서 당한 공격이 있으며, 이로 인해 2,000만 개 이상의 OP 토큰이 손실되었습니다. 하지만 EIP-155 도입 이후에는 거래 서명 자체에 chainId(체인을 구별하는 식별자)가 포함되기 때문에, 목적 체인의 chainId가 다를 경우 거래 재생이 불가능합니다.
서명 메시지 재생
서명 메시지 재생은 거래 재생과 달리, 개인키로 서명된 메시지(e.g., "Cobo is the best")를 재생하는 것을 말합니다. 이 경우 공격자는 전체 거래를 재생할 필요 없이, 서명된 메시지만 재생하면 됩니다. 예를 들어 "Cobo is the best"라는 메시지는 체인 관련 특수 매개변수를 포함하지 않기 때문에, 서명 후에는 이론상 모든 포크 체인에서 유효하며 검증이 가능합니다. 이러한 메시지의 포크 체인 재생을 막기 위해선 메시지 내용에 chainId를 추가하여, 예를 들어 "Cobo is the best + chainId()" 형태로 만들어야 합니다. 특정 체인 식별자를 포함하면 서로 다른 포크 체인에서 메시지 내용이 달라지고, 따라서 서명도 달라져 직접적인 재생이 불가능해집니다.
OmniBridge와 Polygon Bridge의 공격 원리
다음으로 OmniBridge와 Polygon Bridge의 공격 원리를 분석해보겠습니다. 결론부터 말하자면, 두 공격 모두 거래 재생 공격이 아닙니다. 이유는 ETHW가 ETH 메인넷과 다른 chainId를 사용하기 때문에, 거래를 그대로 재생하면 검증이 불가능하기 때문입니다. 따라서 남는 선택지는 메시지 재생뿐이며, 이제 각각이 어떻게 ETHW 포크 체인에서 메시지 재생 공격을 당했는지 살펴보겠습니다.
OmniBridge
OmniBridge는 xDAI와 ETH 메인넷 간 자산 이동을 위한 브릿지로, 주로 브릿지에 지정된 validator가 크로스체인 메시지를 제출하여 자산 이동을 완료합니다. OmniBridge에서 validator가 제출하는 검증 메시지의 로직은 다음과 같습니다.
function executeSignatures(bytes _data, bytes _signatures) public { _allowMessageExecution(_data, _signatures); bytes32 msgId; address sender; address executor; uint32 gasLimit; uint8 dataType; uint256[2] memory chainIds; bytes memory data; (msgId, sender, executor, gasLimit, dataType, chainIds, data) = ArbitraryMessage.unpackData(_data); _executeMessage(msgId, sender, executor, gasLimit, dataType, chainIds, data); }
이 함수에서 #L2 행의 서명 검사를 통해 제출된 서명이 지정된 validator에 의해 서명되었는지 확인하고, #L11 행에서 data 메시지를 디코딩합니다. 디코딩 결과를 보면 chainId 필드가 포함되어 있는데, 그러면 서명 메시지 재생이 불가능한 것일까요? 계속 분석해보겠습니다.
function _executeMessage( bytes32 msgId, address sender, address executor, uint32 gasLimit, uint8 dataType, uint256[2] memory chainIds, bytes memory data ) internal { require(_isMessageVersionValid(msgId)); require(_isDestinationChainIdValid(chainIds[1])); require(!relayedMessages(msgId)); setRelayedMessages(msgId, true); processMessage(sender, executor, msgId, gasLimit, dataType, chainIds[0], data); }
_executeMessage 함수를 추적하면, #L11 행에서 chainId의 유효성을 검사하는 것을 알 수 있습니다.
function _isDestinationChainIdValid(uint256 _chainId) internal returns (bool res) { return _chainId == sourceChainId(); } function sourceChainId() public view returns (uint256) { return uintStorage[SOURCE_CHAIN_ID]; }
후속 함수 로직을 분석하면, chainId 검사에서 EVM의 네이티브 chainId 오퍼코드를 사용하지 않고, uintStorage 변수에 저장된 값을 직접 사용한다는 점을 알 수 있습니다. 이 값은 관리자가 설정한 것이므로 메시지 자체에 체인 식별자가 없다고 볼 수 있으며, 이론상 서명 메시지 재생이 가능합니다.
하드포크 과정에서 포크 이전의 모든 상태는 두 체인에서 그대로 유지되며, 이후 xDAI 팀이 추가 조치를 하지 않는 한, 포크 후 ETHW와 ETH 메인넷의 OmniBridge 계약 상태는 동일합니다. 즉, validator도 동일합니다. 따라서 validator의 메인넷 서명은 ETHW에서도 검증이 가능합니다. 서명 메시지에 chainId가 포함되지 않았기 때문에 공격자는 이 서명 재생을 이용해 ETHW에서 동일한 계약의 자산을 인출할 수 있습니다.
Polygon Bridge
OmniBridge와 마찬가지로 Polygon Bridge는 Polygon과 ETH 메인넷 간 자산 이동을 위한 브릿지입니다. 다만 Polygon Bridge는 블록 증명을 기반으로 인출을 처리하며, 로직은 다음과 같습니다.
function exit(bytes calldata inputData) external override { //... 생략 // 영수증 포함성 검증 require( MerklePatriciaProof.verify( receipt.toBytes(), branchMaskBytes, payload.getReceiptProof(), payload.getReceiptRoot() ), "RootChainManager: INVALID_PROOF" ); // 체크포인트 포함성 검증 _checkBlockMembershipInCheckpoint( payload.getBlockNumber(), payload.getBlockTime(), payload.getTxRoot(), payload.getReceiptRoot(), payload.getHeaderNumber(), payload.getBlockProof() ); ITokenPredicate(predicateAddress).exitTokens( _msgSender(), rootToken, log.toRlpBytes() ); }
함수 로직을 보면 계약이 두 가지 검사를 통해 메시지의 유효성을 판단합니다. 하나는 transactionRoot, 다른 하나는 BlockNumber를 검사하여 거래가 실제로 서브체인(Polygon Chain)에서 발생했음을 확인합니다. 첫 번째 검사는 우회 가능합니다. 누구나 거래 데이터를 통해 자신만의 transactionRoot를 생성할 수 있기 때문입니다. 그러나 두 번째 검사는 우회가 어렵습니다. _checkBlockMembershipInCheckpoint 로직을 보면:
function _checkBlockMembershipInCheckpoint( uint256 blockNumber, uint256 blockTime, bytes32 txRoot, bytes32 receiptRoot, uint256 headerNumber, bytes memory blockProof ) private view returns (uint256) { ( bytes32 headerRoot, uint256 startBlock, , uint256 createdAt, ) = _checkpointManager.headerBlocks(headerNumber); require( keccak256( abi.encodePacked(blockNumber, blockTime, txRoot, receiptRoot) ) .checkMembership( blockNumber.sub(startBlock), headerRoot, blockProof ), "RootChainManager: INVALID_HEADER" ); return createdAt; }
해당 headerRoot는 _checkpointManager 계약에서 추출됩니다. 이 로직을 따라 headerRoot가 설정되는 위치를 확인해보면:
function submitCheckpoint(bytes calldata data, uint[3][] calldata sigs) external { (address proposer, uint256 start, uint256 end, bytes32 rootHash, bytes32 accountHash, uint256 _borChainID) = abi .decode(data, (address, uint256, uint256, bytes32, bytes32, uint256)); require(CHAINID == _borChainID, "Invalid bor chain id"); require(_buildHeaderBlock(proposer, start, end, rootHash), "INCORRECT_HEADER_DATA"); // check if it is better to keep it in local storage instead IStakeManager stakeManager = IStakeManager(registry.getStakeManagerAddress()); uint256 _reward = stakeManager.checkSignatures( end.sub(start).add(1), /** prefix 01 to data 01 represents positive vote on data and 00 is negative vote malicious validator can try to send 2/3 on negative vote so 01 is appended */ keccak256(abi.encodePacked(bytes(hex"01"), data)), accountHash, proposer, sigs ); //.... 나머지 로직 생략
#L2 코드에서 서명 데이터는 borChainId만 검사하고 체인의 chainId는 검사하지 않습니다. 이 메시지는 계약에서 지정한 proposer가 서명한 것이므로, 공격자는 포크 체인에서도 proposer의 메시지 서명을 재생하여 유효한 headerRoot를 제출할 수 있습니다. 이후 Polygon Bridge에서 ETHW 체인의 exit 함수를 호출하고 merkle proof를 제출하면 인출이 성공하며 headerRoot 검사를 통과할 수 있습니다.
예를 들어 주소 0x7dbf18f679fa07d943613193e347ca72ef4642b9는 다음 단계를 통해 ETHW 체인에서 차익 실현에 성공했습니다.
-
먼저 거액을 활용해 메인넷 거래소에서 출금.
-
Polygon 체인에서 Polygon Bridge의 depositFor 함수를 통해 입금;
-
ETH 메인넷에서 Polygon Bridge의 exit 함수를 호출하여 출금;
-
ETH 메인넷의 proposer가 제출한 headerRoot를 복사;
-
ETHW에서 위 단계의 proposer 서명 메시지를 재생;
-
ETHW의 Polygon Bridge에서 exit 함수를 호출하여 출금
왜 이런 일이 발생했는가?
위 두 사례를 분석해보면, 두 프로토콜이 ETHW에서 재생공격을 당한 이유는 자체적으로 재생방지 보호 장치가 부족했기 때문이며, 이로 인해 해당 자산이 포크 체인에서 소진되었습니다. 하지만 이 두 브릿지는 본래 ETHW 포크 체인을 지원하지 않기 때문에 사용자는 실제 손실을 입지 않았습니다. 그런데 왜 설계 초기에 재생 보호 조치가 없었는지 생각해볼 필요가 있습니다. 그 이유는 간단합니다. OmniBridge든 Polygon Bridge든 설계된 응용 시나리오는 매우 단순하며, 지정된 대응 체인으로의 자산 이동에 국한되어 있고 다중 체인 배포 계획이 없기 때문에 재생 보호가 없더라도 프로토콜 자체에는 보안상 문제가 되지 않았습니다.
반면 ETHW 사용자의 경우, 이러한 브릿지가 다중 체인 시나리오를 지원하지 않기 때문에, 사용자가 ETHW 포크 체인에서 작업을 수행할 경우 오히려 ETH 메인넷에서 메시지 재생공격을 당할 수 있습니다.
예를 들어 UniswapV2의 경우, 현재 UniswapV2의 pool 계약에는 permit 함수가 있으며, 이 함수는 PERMIT_TYPEHASH 변수를 포함하고 있고, 여기에 DOMAIN_SEPARATOR가 포함되어 있습니다.
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external { require(deadline >= block.timestamp, 'UniswapV2: EXPIRED'); bytes32 digest = keccak256( abi.encodePacked( '\x19\x01', DOMAIN_SEPARATOR, keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) ) ); address recoveredAddress = ecrecover(digest, v, r, s); require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE'); _approve(owner, spender, value); }
이 변수는 EIP712에서 처음 정의되었으며, chainId를 포함하고 있어 다중 체인 시나리오의 재생 방지를 고려했습니다. 그러나 UniswapV2 pool 계약의 로직을 보면:
constructor() public { uint chainId; assembly { chainId := chainid } DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), keccak256(bytes(name)), keccak256(bytes('1')), chainId, address(this) ) ); }
DOMAIN_SEPARATOR는 생성자에서 이미 정의되므로, 하드포크 후 체인의 chainId가 변경되더라도 pool 계약은 새로운 chainId를 가져와 DOMAIN_SEPARATOR를 갱신할 수 없습니다. 따라서 미래에 사용자가 ETHW에서 권한 부여를 수행하면, ETHW의 permit 서명 권한이 ETH 메인넷으로 재생될 수 있습니다. Uniswap 외에도 Yearn Vault 계약의 특정 버전처럼 고정된 DOMAIN_SEPARATOR를 사용하는 유사한 프로토콜들이 많습니다. 사용자는 ETHW에서 상호작용할 때 이러한 프로토콜의 재생 위험에 주의해야 합니다.
프로토콜 설계 시 예방 조치
개발자 입장에서 프로토콜의 메시지 서명 메커니즘을 설계할 때 향후 다중 체인 시나리오를 고려해야 하며, 로드맵에 다중 체인 배포 가능성이 있다면 chainId를 서명 메시지의 변수로 포함시켜야 합니다. 또한 검증 시에는 하드포크가 포크 이전 상태를 변경하지 않기 때문에, 서명 검증에 사용되는 chainId는 계약 변수로 설정하지 말고, 매번 검증 전에 새로 가져와 서명을 검증함으로써 보안을 확보해야 합니다.
영향
사용자에게 미치는 영향
프로토콜이 포크 체인을 지원하지 않을 경우, 사용자는 포크 체인에서 어떠한 작업도 하지 않는 것이 좋으며, 그렇지 않으면 해당 서명 메시지가 메인넷으로 재생되어 자산 손실이 발생할 수 있습니다.
거래소 및 자산 운용기관에 미치는 영향
많은 거래소가 이미 ETHW 토큰을 지원하고 있기 때문에, 이러한 공격으로 인출된 토큰들이 거래소에 입금되어 매도될 가능성이 있습니다. 하지만 이러한 공격은 체인 합의 자체의 문제로 인한 악의적 증발이 아니므로, 거래소 입장에서는 추가적인 방어 조치가 필요하지 않습니다.
결론
다중 체인 환경의 발전과 함께 재생공격은 이론적 수준에서 점차 주요 공격 수단으로 자리잡고 있습니다. 개발자들은 프로토콜 설계 시 신중히 고려하고, 메시지 서명 메커니즘 설계 시 chainId 등의 요소를 반드시 포함시키며 관련 모범 사례를 따르는 등 사용자 자산 손실을 방지해야 합니다.
Cobo는 아태 지역 최대의 암호화폐 자산 운용기관으로, 설립 이래 500개 이상의 선도 기관 및 고자산층 고객에게 뛰어난 서비스를 제공해왔으며, 암호화 자산의 안전한 보관은 물론 안정적인 수익 창출까지 실현하며 전 세계 사용자들로부터 신뢰를 얻고 있습니다. Cobo는 확장 가능한 인프라 구축에 집중하여 기관이 다양한 자산을 안전하게 보관하고, 자산 증식, 체인 상호작용, 크로스체인·크로스레이어 등을 위한 종합 솔루션을 제공하며, 기관의 Web 3.0 전환을 위한 강력한 기술적 지원과 역량을 제공합니다. Cobo는 Cobo Custody, Cobo DaaS, Cobo MaaS, Cobo StaaS, Cobo Ventures, Cobo DeFi Yield Fund 등 다양한 사업 부문을 운영하며 고객의 다양한 요구를 충족시킵니다.
TechFlow 공식 커뮤니티에 오신 것을 환영합니다
Telegram 구독 그룹:https://t.me/TechFlowDaily
트위터 공식 계정:https://x.com/TechFlowPost
트위터 영어 계정:https://x.com/BlockFlow_News














