1월 29, 2022

Qubit Finance Exploit Analysis

TL;DR : Crosschain 탈중앙화 대출 서비스인 Qubit Finance가 해킹되어 약 960억에 달하는 가상자산이 유출되었다. 원인은 Crosschain Bridge의 프로그램과 컨트랙트의 데이터 검증이 상당히 빈약하여, ETH를 입금하지 않고도 BSC로 xETH를 옮길 수 있었다. 해커는 ETH 없이 xETH를 발행하여 그것을 담보로 가상자산을 빌려 이익을 보았다. 이 글은 해당 취약점에 대한 분석을 담고 있다.

Qubit Finance Exploit Analysis

최근에 KlayCity Minting IssueKLEVA Protocol의 Vault Issue로 정신이 없던 날이었다. KLEVA Protocol의 Vault Issue를 살펴보던 중에, Qubit Finance의 해킹 소식이 날아왔다.

DeFi Protocol Qubit Finance Exploited for $80M
The attack is the seventh-largest DeFi exploit by the amount of funds stolen, data shows.

KLEVA Protocol에 비해 피해규모가 크기도 하고, 이미 KLEVA 쪽 사고는 Theori에서 분석이 나와 있는 지라, KLEVA 보다 Qubit Finance 쪽을 다루기로 했다.

대체 무슨 일이 일어난거야?

위의 트위터에 따르면, 0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7에 의해서 BSC에 있는 xETH를 무한히 발행할 수 있게 되었다고 한다.  xETH란, Qubit Finance에서 사용하는 Cross-chain Ethereum 토큰이다. xETH는 원래 Ethereum 네트워크에서  Ethereum을 Bridge Contract에 예치하고, 신뢰할 수 있는 인증자가 그걸 확인해서 BSC의 Bridge Contract에 입금을 처리해서 xETH를 발행해주는 방식이다. Qubit Finance에서는 이 xETH를 담보로 대출을 받을 수 있다.

Qubit Finance의 X-Bridge

해커는 모종의 방법으로 ETH 없이 xETH를 무한히 발행할 수 있게 되었고, 그 xETH를 담보로 BSC에 있는 Qubit Finance의 wETH, BTC-B 등 약 960억원 어치를 대출 받아 갔습니다. 0원 짜리 가짜 xETH로 960억원을 대출받아 간 것이다.

어떻게 이게 가능했던거야?

그러니까 정상적으로 xETH가 발행이 되려면 동일한 양의 ETH를 입금해야 하는 것이다. 그런데, 해커는 어떻게 ETH 없이 xETH를 발행할 수 있었을까?

공격자의 Ethereum 트랙젝션 기록을 보면 QBridge를 향한 토큰 입금 (Deposit) 호출을 14번이나 호출하고 있는 것을 볼 수 있다. 그런데 흥미로운 점은 토큰 입금을 호출 했지만 그 어떤 토큰도 이동하지 않고 있다.

# Name Type Data
0 destinationDomainID uint8 1
1 resourceID byte32 0x00000000000000000000002f422fe9ea622049d6f73f81a906b9b8cff03b7f01
2 data bytes 0x000000000000000000000000000000000000000000000000000000000000006900000000000000000000000000000000000000000000000a4cc799563c380000000000000000000000000000d01ae1a708614948b2b5e0b7ab5be6afa01325c7
공격자의 Transaction Data의 일부
function deposit(uint8 destinationDomainID, bytes32 resourceID, bytes calldata data) external payable notPaused {
    require(msg.value == fee, "QBridge: invalid fee");

    address handler = resourceIDToHandlerAddress[resourceID];
    require(handler != address(0), "QBridge: invalid resourceID");

    uint64 depositNonce = ++_depositCounts[destinationDomainID];

    IQBridgeHandler(handler).deposit(resourceID, msg.sender, data);
    emit Deposit(destinationDomainID, resourceID, depositNonce, msg.sender, data);
}
QBridge의 Deposit Code

위의 코드는 그닥 특별할 것 없이 토큰 입출금을 관리하는 자원 ID를 받아서 토큰 브릿지 handler에게 입금을 처리하도록 하는 함수를 호출한다.

function deposit(bytes32 resourceID, address depositer, bytes calldata data) external override onlyBridge {
    uint option;
    uint amount;
    (option, amount) = abi.decode(data, (uint, uint));

    address tokenAddress = resourceIDToTokenContractAddress[resourceID];
    require(contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");

    if (burnList[tokenAddress]) {
        require(amount >= withdrawalFees[resourceID], "less than withdrawal fee");
        QBridgeToken(tokenAddress).burnFrom(depositer, amount);
    } else {
        require(amount >= minAmounts[resourceID][option], "less than minimum amount");
        tokenAddress.safeTransferFrom(depositer, address(this), amount);
    }
}
QBridgeHandler의 Deposit Code

문제는 QBridgeHandler에서 발생한다. 위의 ResourceID로 resourceIDToTokenContractAddress를 호출하면 0x0000.. 즉 Zero address가 나오게 된다. 0x0000이 Whitelist 되어 있고, (0x0000은 통상적으로 ETH에 관련된 값을 저장시킬 때 사용하기 때문에 Whitelist 해둔 것이다.) 따라서 모든 방어 로직을 뚫어 내고 0x0000 주소를 가진 토큰을 입금자에게 이동 시키게 된다.

만약 저기서 토큰 이동 하기 전, tokenAddress가 컨트랙트인지 확인했더라면 문제가 없었겠지만, 안타깝게도 토큰 이동에 safeTransferFrom을 사용했다. 0x0000..은 EOA, 즉 컨트랙트가 아니기 때문에, low-level call을 이용하는 safeTransferFrom은 무조건 성공했는 값을 반환하게 되어 있다. (메시지를 담은 트랜젝션을 그냥 지갑으로 보내는 게 실패할 리 없다.)

입금은 처리되었고 0x0000... 자산 (ETH)이 지정한 갯수만큼 입금 되었다는 Deposit 이벤트가 네트워크에 전파된다.  중앙화 서버인 QBridge 프로그램은 Ethereum 네트워크에 전파된 Deposit 이벤트를 확인하고, BSC에서 xETH를 발행할 수 있게 처리해 두게 된다. 만약 여기서 DepositETH 함수와 Deposit 함수의 이벤트를 다르게 설정해 두었다면, 봇이 이벤트를 보는 과정에서 "어? 토큰 입금인데 왜 ETH를 옮겼다고 나오지?" 하고 xETH를 발행하지 않았을 것이다.

요약하자면 QBridge의 다음과 같은 실수들로 대량의 무가치한 xETH가 발행된 것이다 :

  • data를 제대로 검증하지 않았다 ( tokenAddress가 0x000..이 아닌지 확인하지 않았다)
  • depositETHdeposit 함수가 분리되어 있음에도, 이벤트를 분리하지 않아 Bridge가 의도치 않은 작동을 하게 했다.
  • safeTransferFrom을 할 때, 대상이 ERC20을 따르는지 확인하지 않았다.
  • Bridge가 이벤트만을 데이터 소스로 사용하고 발행이 올바르게 이루어지는 것인지 검증하는 과정을 구현해 두지 않았다.

정리하며

지금까지 Qubit Finance에서 일어난 Bridge 공격 사건에 대해서 간단하게 원인 분석을 해 보았다. Bridge 프로그램이 만약 더 신중하게 데이터를 검증했거나, 컨트랙트 코드가 조금 더 명확하게 작성되어 있었더라면, 사실 취약점이 존재해도 막을 수 있는 공격 이었다고 생각한다.  

Qubit Finance가 그래도 Bridge를 Ethereum-BSC만 지원해서 망정이지, KLAY-ETH를 지원했다면, 2022년 1월 27은 Klaytn 커뮤니티에게 악몽같은 하루가 되었을 수도 있다. Klaytn.. 참 바람잘날 없다.

2021년 중순 이후부터 MultiChain이나 ChainSwap 사건과 같이 Cross-chain bridge에 대한 공격이 늘어나고 있다. 각각의 공격들이 굉장히 큰 액수의 피해를 입게 되었다. 앞으로 새로운 Cross-chain bridge가 나올때 마다 철저한 검증을 거칠 필요가 있겠다.

참조자료

  1. Qubit Finance Docs
  2. Rekt - Qubit Finance
  3. Qubit Bridge Collapse Exploited to the Tune of $80 Million
  4. Theori Qubit Cross-Chain Security Audit Report