콘텐츠로 이동

QBridge 익스플로잇 기술 분석

이것은 온체인 증거, 컨트랙트 소스 코드, 그리고 공격 당시 캡처된 스크린샷 증거를 기반으로 한 익스플로잇의 완전한 기술 분석입니다. 피해자 커뮤니티의 중국어 원문 분석을 번역하고, 추출된 모든 데이터와 함께 확장하였습니다.


역할주소
공격자0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7
QBridge 컨트랙트 (Ethereum)0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
QBridge 핸들러 (BSC)0x4d8ae68fcae98bf93299548545933c0d273ba23a
ETH resourceID0x0000000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01

1단계 — BSC는 최초 범행 현장이 아니었다

섹션 제목: “1단계 — BSC는 최초 범행 현장이 아니었다”

조사관들이 BSC에서 공격자의 주소를 처음 확인했을 때, 즉시 무언가 이상했습니다:

BSCScan에서 QubitFin Exploiter의 borrow 트랜잭션 표시 — 준비 단계 없음

공격자는 곧바로 borrow()를 실행했습니다 — 준비도, 플래시 론도, 컨트랙트 배포도 없었습니다. 이는 BSC가 공격이 시작된 곳이 아니라는 것을 의미했습니다. 공격자는 렌딩 프로토콜에 접근하기 전에 이미 qXETH 토큰을 보유하고 있었습니다.

해당 qXETH 토큰을 역추적한 결과, 브릿지 릴레이어에 의해 발행되었음이 밝혀졌으며 — 이는 출처가 Ethereum이었음을 의미했습니다.


2단계 — BSC에서의 voteProposal 트랜잭션

섹션 제목: “2단계 — BSC에서의 voteProposal 트랜잭션”

qXETH 발행 트랜잭션 중 하나:

트랜잭션: 0x8c5877d1b618f29f6a3622cb610ace08ca96e04d8218f587072a3f91e8545bdc

BSCScan voteProposal 트랜잭션 — null 주소에서 공격자에게 xETH 발행 표시

이 트랜잭션에서의 주요 관찰 사항:

  • QBridge BSC 컨트랙트에서 voteProposal(uint8 chainID, uint64 depositNonce, bytes32 resourceID, bytes data) 호출
  • 3건의 토큰 전송 발생:
    • Null 주소 → 0xa6b1bb... — 59,900 Cross-Chain xETH (발행)
    • 0xa6b1bb...0xfd7a55... — 59,900 xETH (브릿지됨)
    • Null 주소 → QubitFin Exploiter — 59,900 qXETH (담보용 발행)
  • 값: 0 BNB — 실제 ETH는 어디에도 입금되지 않음

voteProposal 함수는 릴레이어만 호출할 수 있었습니다. 릴레이어가 이를 호출한 이유는 Ethereum에서 Deposit 이벤트를 감지했기 때문입니다. 그 이벤트는 위조된 것이었습니다.


3단계 — Ethereum에서의 공격자의 입금 내역

섹션 제목: “3단계 — Ethereum에서의 공격자의 입금 내역”

Etherscan에서 공격자가 QBridge로 보낸 다수의 제로 값 Deposit 호출 표시

공격자는 Ethereum QBridge 컨트랙트(0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6)에 deposit()여러 차례 호출했습니다:

트랜잭션 해시블록
0x3dfa33b5...140902340 ETH
0x58020654...140902300 ETH
0x501f8541...140902300 ETH
0xa6282e60...140902230 ETH
0x94031569...140902160 ETH
0xbdedb13d...140902100 ETH
0xeb9f622e...140902010 ETH

모든 트랜잭션의 ETH 값은 0이었습니다. 메서드: deposit. 각 트랜잭션은 릴레이어가 충실히 처리하는 Deposit 이벤트를 발생시켰습니다.

이 트랜잭션 중 하나의 상세 내역:

Etherscan에서 deposit() 호출 상세 — 0 Ether, ETH resourceID로 deposit() 호출

  • 블록: 14090216
  • 타임스탬프: 2022년 1월 27일 오후 9:45:32 UTC
  • 발신: 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7 (공격자)
  • 수신: 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge Ethereum)
  • 값: 0 Ether ($0.00)
  • 호출된 함수: deposit(uint8 destinationDomainID, bytes32 resourceID, bytes data)
  • 공격 당시 ETH 가격: $2,425.83

4단계 — 두 개의 함수, 하나의 이벤트

섹션 제목: “4단계 — 두 개의 함수, 하나의 이벤트”

QBridge 컨트랙트에는 두 개의 입금 함수가 있었습니다:

QBridge 컨트랙트 코드 — deposit()과 depositETH() 모두 동일한 Deposit 이벤트를 발생시킴

  • depositETH() — ETH의 올바른 경로, msg.value > 0 필수
  • deposit() — ERC-20 토큰용으로 설계됨, ETH 불필요

두 함수 모두 정확히 동일한 Deposit 이벤트 타입을 발생시켰습니다. 릴레이어는 Deposit 이벤트를 감지했지만 어떤 함수가 이를 발생시켰는지 구분할 수 없었습니다.


5단계 — 핸들러 코드와 화이트리스트

섹션 제목: “5단계 — 핸들러 코드와 화이트리스트”

QBridgeHandler 코드 — 128번 줄의 화이트리스트 검사와 135번 줄의 safeTransferFrom이 포함된 deposit() 함수

QBridgeHandler.deposit() 함수:

function deposit(
bytes32 resourceID,
address depositer,
bytes calldata data
) external override {
address tokenAddress = _resourceIDToTokenContractAddress[resourceID]; // get token address
require(_contractWhitelist[tokenAddress], "not whitelisted"); // line 128: whitelist check
// ...
ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount); // line 135
}

ETH의 resourceID에 대해 컨트랙트는 tokenAddress = 0x0000000000000000000000000000000000000000 (제로 주소)을 반환했습니다.


6단계 — 제로 주소가 화이트리스트에 등록되어 있었다

섹션 제목: “6단계 — 제로 주소가 화이트리스트에 등록되어 있었다”

컨트랙트 조회 결과 제로 주소가 화이트리스트에 포함되어 있음을 표시

조사관들이 컨트랙트를 조회했습니다: 제로 주소가 화이트리스트에 등록되어 있었는가?

그렇습니다. 등록되어 있었습니다.

이것은 depositETH()도 ETH의 플레이스홀더로서 제로 주소와 함께 동일한 화이트리스트 메커니즘을 사용했기 때문에 필요했습니다. 그러나 이는 치명적인 부작용을 만들어냈습니다: 제로 주소가 deposit()의 화이트리스트 검사도 통과한 것입니다.

컨트랙트에서 제로 주소가 ETH resourceID에 매핑되어 있음을 표시

ETH resourceID0x0000000000000000000000000000000000000000 — 제로 주소 — 에 매핑되어 있었으며, 이는 resourceIDToTokenContractAddress 조회를 통해 확인되었습니다.


7단계 — EOA 호출은 조용히 성공한다

섹션 제목: “7단계 — EOA 호출은 조용히 성공한다”

tokenAddress = 0x0000...0000인 상태에서, 핸들러는 다음을 실행했습니다:

ERC20Interface(tokenAddress).safeTransferFrom(depositer, address(this), amount);

제로 주소는 EOA — 컨트랙트 코드가 없는 외부 소유 계정입니다.

EVM에서: 컨트랙트 코드가 없는 주소에 대한 함수 호출은 조용히 성공합니다 — 리버트도, 에러도, 실제 실행도 없습니다.

safeTransferFrom 호출은 “성공”했습니다. 아무것도 이동하지 않았습니다. ETH도, 토큰도 없었습니다. 하지만 코드는 모든 것이 정상인 것처럼 계속 진행되었고 — 정상적인 Deposit 이벤트를 발생시켰습니다.

2019년에 문서화된 0x Protocol EOA 트릭

이 정확한 트릭은 2019년 0x Protocol 보안 업데이트에서 공개적으로 문서화되었습니다. Mound Inc.는 이를 고려하지 않고 2022년에 브릿지를 배포했습니다.


8단계 — Borrow 함수는 정확했지만, 이미 늦었다

섹션 제목: “8단계 — Borrow 함수는 정확했지만, 이미 늦었다”

Qore borrow() 함수 — Compound 스타일, 올바른 borrowAllowed 검사

function borrow(address qToken, uint amount) external override onlyListedMarket(qToken) nonReentrant {
_enterMarket(qToken, msg.sender);
require(IQValidator(qValidator).borrowAllowed(qToken, msg.sender, amount), "Qore: cannot borrow");
IQToken(payable(qToken)).borrow(msg.sender, amount);
qDistributor.notifyBorrowUpdated(qToken, msg.sender);
}

렌딩 컨트랙트는 정확했습니다 — borrowAllowed()는 담보 가치를 적절히 검사했습니다. 하지만 qXETH 토큰은 온체인에서 정상적으로 보였습니다. 사기는 이미 브릿지에서 발생한 것이었습니다. borrow()가 실행될 때쯤, 공격자는 아무것도 뒷받침하지 않는 정상처럼 보이는 담보를 보유하고 있었습니다.


9단계 — 결정적 증거: 공격 전 매개변수 변경

섹션 제목: “9단계 — 결정적 증거: 공격 전 매개변수 변경”

이것은 한 번도 설명된 적 없는 세부 사항입니다.

Ethereum 트랜잭션 — 2021년 12월 1일 deposit() 함수를 통한 WETH 입금 표시 — resourceID 변경 전

공격 전, 누군가가 deposit() 함수의 이력을 조사했습니다. 그들이 발견한 것:

2021년 12월 1일 — 정상적인 트랜잭션 (0xc85df3fbfee7a8541e9fd354e98df78dca21ac6be40b929ff5da52ea8eab80de):

  • 블록: 13719888
  • 발신: 0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
  • 수신: QBridge 컨트랙트 0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6
  • 전송된 토큰: 0.1 WETH (당시 $241.81)
  • 함수: 동일한 resourceIDdeposit() 호출

2021년 12월에는 해당 resourceIDdeposit()을 호출하면 실제 WETH가 전송되었습니다 — resourceID가 실제 WETH 컨트랙트 주소에 매핑되어 있었기 때문입니다.

2021년 12월과 2022년 1월 28일 사이 어느 시점에 — 소유자 전용 함수가 호출되어 resourceID 매핑을 WETH 컨트랙트 주소에서 제로 주소로 재할당했습니다.

코드에서 resourceID 매개변수가 소유자 함수에 의해 변경되었음을 확인

컨트랙트 소유자만이 이 변경을 할 수 있었습니다. 타임락이 필요하지 않았습니다. 공지도 없었습니다. 이 단 하나의 보이지 않는 매개변수 변경이 작동하는 브릿지를 익스플로잇 가능한 브릿지로 만들었습니다.


실패 요인영향
deposit()depositETH()가 동일한 이벤트를 발생시킴릴레이어가 실제 입금과 가짜 입금을 구분할 수 없음
EOA 조용한 성공이 고려되지 않음제로 주소에 대한 safeTransferFrom이 전송 없이 통과함
제로 주소가 화이트리스트에 등록됨EOA 호출이 화이트리스트 검사를 통과함
감사 없는 프로덕션 배포외부 감사인이 위의 문제를 발견하지 못함
소유자 함수에 타임락 없음resourceID가 조용히, 즉시, 보이지 않게 재매핑됨
공격 전 resourceID 재매핑익스플로잇을 가능하게 만든 변경
릴레이어가 이벤트를 맹목적으로 신뢰오프체인 서비스가 온체인 가치를 검증하지 않고 토큰을 발행함


결정적 증거 트랜잭션 — 2021년 12월 13일 setResource() 호출

섹션 제목: “결정적 증거 트랜잭션 — 2021년 12월 13일 setResource() 호출”

이것이 가장 핵심적인 증거입니다.

640_7.jpg — 2021년 12월 13일 ETH resourceID를 제로 주소로 재매핑하는 setResource() 소유자 호출

트랜잭션: 0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

필드
상태✅ 성공
블록13797391
타임스탬프2021년 12월 13일 오후 2:31:21 UTC
발신0xbee3971293374d0b4db7bf1654936951e5bdfe5a6
수신0x20e5e35ba29dc3b540a1aee781d0814d5c77bce6 (QBridge, Ethereum)
0 ETH
함수setResource(address handlerAddress, bytes32 resourceID, address tokenAddress)

디코딩된 입력 인수:

#매개변수
0handlerAddress0x17B7163cf1Dbb6286262ddc68b553D899B893f526
1resourceID0x0000000000000000000000002f422fe9ea622049d6e73f81a906b9b8cff03b7f01
2tokenAddress0x0000000000000000000000000000000000000000

setResource()onlyOwner 함수입니다. Mound Inc. 팀만이 이를 호출할 수 있었습니다.

2021년 12월 13일 — 해킹 45일 전 — 컨트랙트 소유자가 ETH resourceIDtokenAddress 매핑을 의도적으로 제로 주소로 변경했습니다. 이 호출 전에는 동일한 resourceID가 WETH 토큰 컨트랙트를 가리키고 있었습니다. 이 호출 이후에는 아무것도 가리키지 않게 되었으며 — EOA 조용한 성공 익스플로잇이 가능해졌습니다.

릴레이어만 호출할 수 있음을 보여주는 이전 voteProposal 코드:

640_1.jpg — voteProposal 함수: onlyRelayers 수정자 강조 표시

function voteProposal(uint8 originDomainID, uint64 depositNonce, bytes32 resourceID, bytes calldata data)
external onlyRelayers notPaused {
address handlerAddress = resourceIDToHandlerAddress[resourceID];
require(handlerAddress != address(0), "QBridge: invalid handler");
// ...
if (proposal._status == ProposalStatus.Passed) {
executeProposal(originDomainID, depositNonce, resourceID, data, true);
return;
}
// ...
if (proposal._status == ProposalStatus.Inactive) {
proposal = Proposal({
status: ProposalStatus.Active,
_yesVotes: 0,
_yesVotesTotal: 0,
_proposedBlock: uint40(block.number)
});

그리고 모든 것을 바꾼 setResource() 코드:

640_7.jpg — setResource() onlyOwner 함수

function setResource(address handlerAddress, bytes32 resourceID, address tokenAddress) external onlyOwner {
resourceIDToHandlerAddress[resourceID] = handlerAddress;
IQBridgeHandler(handlerAddress).setResource(resourceID, tokenAddress);
}

640_8.jpg — QubitFin Exploiter가 여러 라운드에 걸쳐 null 주소로부터 발행된 qXETH를 수령

공격자는 null 주소로부터 직접 발행된 qXETH를 여러 라운드에 걸쳐 수령했습니다:

트랜잭션 해시수량토큰출처
0xd8bba15555...999qXETHNull 주소 → 공격자
0xf6008ab482...499qXETHNull 주소 → 공격자
0xcfa4379af6...140ETH (BEP-20)0xb4b778… → 공격자
0x61ca8bc28f...190qXETHNull 주소 → 공격자
0x881a68c9c9...0.1qXETHNull 주소 → 공격자
0x8c5877d1b6...0.1qXETHNull 주소 → 공격자

각 “Null 주소 → 공격자” qXETH 발행은 Ethereum에서의 위조된 deposit() 호출에 대응합니다.