Compound Finance의 거래 중단 사건: 원인과 해결 방안
요약
Compound Finance #117번 거버넌스 제안에서 비롯된 거래 중단 사건을 아시나요? 본 글은 2022년 8월에 발생된 오라클 컨트랙트 통합 오류 사건과 해결책으로 제시되었던 통합 테스트에 관해 다루며, ChainLight은 이 사건의 해결을 위해 제안된 통합 테스트 방법을 개선한 방법의 실험을 공유합니다.
배경
Compound Finance는 기본 자산을 빌리기 위해 암호화폐 자산을 담보로 공급할 수 있는 EVM 호환 프로토콜이자 탈중앙화 금융(DeFi) 서비스입니다. 사용자는 Compound Finance를 통해 보유하고 있는 암호화폐를 이용해 이자 수익을 창출할 수도 있고 보유 암호화폐로 대출을 받아 다른 용도로 사용할 수 있습니다.
Compound Finance는 이 글의 작성 기점으로 DefiLlama에서 TVL 11위를 차지하고 있으며, 대표적으로 규모가 큰 DeFi 서비스 중 하나입니다.
컴파운드는 거버넌스 제안 및 투표를 통해 커뮤니티 주도의 프로토콜 업그레이드를 지원하며, 투표 기간이 종료되면 누구나 거버넌스 컨트랙트를 통해 해당 트랜잭션을 실행할 수 있습니다.
위와 같이 Compound Finance의 거버넌스 스마트 컨트랙트에는 거버넌스 제안 통과 후 프로토콜 업그레이드에 해당하는 트랜잭션을 실행시킬 수 있는 주체에 대한 명시가 되어있지 않습니다.
이는 프로토콜 업그레이드를 할 수 있는 주체가 DAO 일원이든 아니든 상관이 없다는 것을 의미하며, 누구나 해당 트랜잭션을 블록체인에 전송하여 업그레이드 사항을 프로토콜에 적용시킬 수 있습니다.
사건 발단
2022년 8월 31일, #117 거버넌스 제안이 통과되고 프로토콜이 업그레이드될 때, Compound Finance에서는 일주일 동안 Compound 내의 cETH 마켓(예치, 차입과 연관된 시장을 의미)이 동결되는 업그레이드 사고가 발생했습니다.
위 업그레이드 사고의 기점이 되었던 #117 제안은 컴파운드 프로토콜에서 사용하는 가격 오라클 컨트랙트를 업그레이드¹하여 기존에 사용되던 Uniswap V2 마켓 대신 Uniswap V3 마켓의 가격을 사용하도록 하는 GFX Labs 측의 제안이었습니다.
업그레이드에 문제가 생기자, GFX Labs는 업그레이드 전으로 오라클 컨트랙트을 업데이트하는 #119 제안을 상정했습니다. 해당 #119 제안은 #117 제안에 의거한 프로토콜 업그레이드 이후 54분만에 제안되었지만, Compound Finance의 거버넌스 시스템 구조상 거버넌스 제안 상정 이후 7일이라는 기간이 지나야 통과가 될 수 있었기 때문에 7일 동안 cETH 마켓이 동결되었습니다.
[1] 새로 사용될 Oracle Contract는 Dedaub, ABDK, OpenZeppelin의 보안 감사를 받았습니다.
문제 발생 원인 분석
Compound Finance의 스마트 컨트랙트 감사 이후 오라클 컨트랙트 자체에서는 취약점이 발견되지 않았지만, #117 제안에 의해 오라클 컨트랙트가 프로토콜에 결합되자 누락된 메서드를 호출하는 문제가 발생했습니다.
Compound Finance에서는 사용자가 네이티브 토큰(Ethereum) 또는 ERC20 토큰을 프로토콜에 예치할 수 있는데, 이때 프로토콜은 예치량에 따라 이자를 지급받기 위한 보증 토큰인 ‘cToken’을 발행합니다. 둘은 전송 방식이 서로 다르기 때문에 내부적으로는 각각 CEther, CErc20이라는 스마트 컨트랙트로 구현됩니다.
여기서 ERC-20을 Wrapping(감싸는)하는 CErc20에는 underlying()
함수가 해당 cToken이 감싸는 원본 ERC-20 토큰을 UAV(UniswapAnchoredView) Contract에 알려주지만, Ethereum을 감싸는 CEther에는 이 함수가 없었습니다.
UAV Contract — 자산의 가격 피드를 프로토콜에 업데이트 하는 컨트랙트
새 UAV Contract는 이를 무시하고 가격을 조회하려는 cToken 컨트랙트의 underlying()
함수를 무조건적으로 호출하며, 따라서 Ethereum 마켓의 가격을 불러올 때 오류가 발생했습니다. 이는 Compound Finance 내의 모든 Ethereum 마켓 관련 트랜잭션이 실패하는 결과로 이어집니다.
이에 이어, Defi 프로토콜들은 서로 간의 결합이 쉽다는 특성상² 상호작용하고 있는 경우가 많기 때문에(참조: Defi: Money Legos and Composability in the Blockchain Ecosystem) 한 컨트랙트의 동작 변경은 해당 Defi 프로토콜을 넘어서 해당 컨트랙트를 사용하는 다른 DeFi 프로토콜에도 영향을 미칠 수 있습니다. 따라서 Compound Finance를 사용하는 다른 프로토콜도 제대로 동작을 하지 않는 문제가 생기기 시작했습니다.
[2] 결합이 쉬울 뿐, 안전성은 담보되지 않습니다.
문제 해결 방법: 통합 테스트
Compound Finance 측의 사후 분석 보고서에서는 통합 테스트(Integration Test)를 통해 이를 방지할 수 있었다고 언급합니다. 통합 테스트란 바뀐 컴포넌트를 시스템과 결합 후 기능이 의도대로 동작하는지 검사하는 기법이며, 이 경우 바뀐 오라클 컨트랙트의 개별 기능뿐만 아니라 오라클 컨트랙트가 Compound 프로토콜에 결합된 후 여러 기능이 동작하는지 검사하면 됩니다.
통합 테스트(Integration Test): 일반적으로 개별 컨트랙트 단위를 각기의 컴포넌트에 테스트 하는 것을 단위 테스트(unit test)라고 합니다. 여러 컨트랙트 또는 프로토콜 전체의 환경을 구성하고 테스트를 하는 것을 통합 테스트라 합니다. 즉, 통합 테스트란 특정 시점과 환경에 가장 가까운 현실적이고 실제적인 환경 구성을 통해 환경을 정확히 재현한 후 테스트하는 것을 의미합니다.
Compound Finance는 ‘Scenario’라는 자체 통합 테스트 프레임워크로 각 거버넌스 제안에 대한 테스트를 수행해왔습니다. 테스트 코드의 생김새는 아래와 같습니다.
diff --git a/pre_deploy.scen b/post_deploy.scen
index 076591c..1db68d3 100755
--- a/pre_deploy.scen
+++ b/post_deploy.scen
@@ -4,11 +4,12 @@ PrintTransactionLogs
...
-- Vote for, queue, and execute the proposal
MineBlock
AdvanceBlocks 13140
@@ -38,7 +39,7 @@ GovernorBravo GovernorBravoDelegator Proposal LastProposal Execute
Assert Equal (6977000000000000000) (Erc20 Comp TokenBalance (Address Ratan)) -- Testing a proposer 10K < x < 65K
-From CompProposer (GovernorBravo GovernorBravoDelegator Propose "Test Proposal" [(Address GovernorBravoDelegator)] [0]
- ["_setProposalThreshold(uint256)"] [[10000000000000000000000]])
+From CompProposer (GovernorBravo GovernorBravoDelegator Propose "Test Proposal" [(Address GovernorBravoDelegator)] [0]
+ ["_setProposalThreshold(uint256)"] [[25000000000000000000000]])
-- Vote for, queue, and execute the proposal
MineBlock
AdvanceBlocks 13140
#117번 제안의 경우 Scenario 파일을 이용해 오라클 컨트랙트의 주소를 바꾸고 ERC20(DAI 등) 마켓과 Ethereum 마켓의 대출을 실행해보는 방법이 있을 것입니다. 기존에 있던 단위 테스트 (Borrow.scen
, BorrowEth.scen
, …)들을 통합 테스트에 호환되도록 만들고 모든 테스트 케이스를 구동해보는 방법이 있을 수 있습니다.
통합 테스트(Integration Test): Fork testing
해당 오라클 컨트랙트는 오프체인으로부터 가격 피드를 보고해야 하기 때문에, 해당 오라클 컨트랙트를 시뮬레이션하는 Scenario 파일이 있어야 합니다. 하지만 로컬에서 UAV Contract를 시뮬레이션하기 위해서는 UniswapV3 pool들의 가격 정보들을 전부 테스트 코드에 기록해줘야 하기 때문에 적지 않은 코드가 추가될 수 있습니다. 하지만 Ethereum의 특성상 네트워크의 특정 시점을 로컬로 가져와서 테스트하는 방법이 더욱 쉬울 수 있습니다. 이를 네트워크를 포크(Fork)하여 테스트한다고 해서 Fork testing이라 부르며, Foundry & Hardhat 등의 도구에서 지원합니다.
// Run with `forge test --fork-url="https://rpc.ankr.com/eth" -vv`
import "forge-std/Test.sol";
interface Oracle {
function getUnderlyingPrice(address cToken) external returns (uint256);
}contract TestOracle is Test {
function testUnderlyingPrice() public {
// Get underlying price of DAI token from ethereum mainnet
Oracle oracle = Oracle(0x50ce56A3239671Ab62f185704Caedf626352741e);
console.log(oracle.getUnderlyingPrice(
0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643)); // prints $0.999
}
}
Foundry와 Hardhat은 스마트 컨트랙트를 개발하고 테스트할 수 있는 개발자를 위한 오픈소스 개발 환경입니다. 개발자들은 두 개발 환경을 이용해 Ethereum 상에서 스마트 컨트랙트 및 서비스를 개발하기 위한 여러 도구들을 사용할 수 있으며, 로컬 환경에서도 Solidity로 작성된 코드를 테스트해볼 수 있게 합니다.
통합 테스트의 단점과 개선 방안 제안
Compound Finance가 제안했던 통합 테스트에는 단점이 있는데, 기존 테스트 코드들은 프로토콜에 필요한 각 구성요소와 프로토콜을 이용하는 가상의 사용자들을 처음부터 생성하여 테스트하기 때문에, 가상의 사용자용으로 사용되었던 코드를 그 당시 온체인에 있는 사용자로 바꿔서 테스트하도록 바꾸는 것은 쉽지 않습니다.
따라서 ChainLight은 기존의 테스트를 통합 테스트로 변환하는 대신 약간 다른 방식으로 접근해보기로 했습니다. 저희 팀은 과거의 트랜잭션들을 테스트셋으로 사용하면 좋을 것이라 판단했고, 사건 발생 한 달 전의 트랜잭션 데이터(2022년 7월 28일 — 8월 31일)를 이용해 실험을 진행했습니다.
이상적으로는 프로토콜 내의 테스트셋이 가능한 모든 시나리오에 적용되고, 통합 테스트 또한 모든 테스트셋에서 구동되어야 합니다. 과거의 트랜잭션들을 테스트셋으로 써보는 것은, 사용자들이 일상적으로 프로토콜을 사용하는 시나리오를 바탕으로 현실적인 테스트 케이스들을 가져다 줄 것으로 예상했습니다.
ChainLight는 본 테스트를 ‘시간여행 통합 테스트(Time-traveling Integration Test)’라고 부르기로 했습니다.
시간여행 통합 테스트 계획
먼저 (1) 거버넌스 제안이 실행되었을 때 변경되는 프로토콜의 변경 내용을 추출하고 (2) 해당 파라미터에 영향받는 트랜잭션들을 추출한 뒤 (3) 해당 트랜잭션에 가상으로 거버넌스 제안을 적용한 시뮬레이션을 구동해보는 것입니다. 이를 통해 과거 트랜잭션 기준으로 거버넌스 제안이 프로토콜에 어떤 영향을 줄 수 있을지 파악해볼 수 있습니다.
1. 거버넌스 제안 시뮬레이션 & 변경 내용 추출하기
거버넌스 제안을 어떻게 시뮬레이션할 수 있을까요? Foundry의 fork testing을 활용해봅시다. 로컬 환경에서 해당 시점의 메인넷을 포크한 후 실제 제안을 실행하여 스토리지 변경과 같은 상태 변화를 추적했습니다:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";contract ExtractChanges is Test {
function testExtraction() public {
address target = 0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B;
address sender = 0x6d903f6003cca6255D85CcA4D3B5E5146dC33925; bytes[1] memory calldatas = [bytes(hex"55ee1fe1000000000000000000000000ad47d5a59b6d1ca4dc3ebd53693fda7d7449f165")];
address[1] memory targets = [0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B]; for(uint i = 0; i < calldatas.length; i++) {
vm.prank(sender);
vm.record();
(bool success,) = targets[i].call(calldatas[i]);
require(success); (, bytes32[] memory writes) = vm.accesses(targets[i]);
for(uint j = 0; i < writes.length; i++) {
for(uint k = 0; k < targets.length; k++)
console.log(targets[k], uint256(writes[j]), uint256(vm.load(targets[k], writes[j])));
}
}
}
}
위와 같이 앞으로 나올 Solidity 코드들은 해당 제안의 메타데이터 등을 통해 자동으로 생성한 코드입니다.
아래와 같이 거버넌스 제안이 바꾸는 스토리지 값들이 출력됩니다. 0x3d98…
의 4번 스토리지 슬롯 (priceOracle
) 값을 새 오라클 컨트랙트 주소 (9892…
= 0xad47…
)로 바꾸는 것을 알 수 있습니다.
Logs:
0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B 4 989257367531710799496057489928977151189150462309
2. 영향을 받는 트랜잭션 검색
먼저 과거 트랜잭션들 중 해당 거버넌스 제안이 영향을 끼치는 트랜잭션들을 찾습니다. 지난 Audius의 스토리지 충돌 리서치 방법과 비슷하게 Google BigQuery를 사용해서 아래와 같이 조회할 수 있습니다.(기호에 따라 Dune Analytics를 사용하셔도 됩니다.)
SELECT DISTINCT transaction_hash FROM crypto_ethereum.traces
WHERE to_address='0x3d98192...'
굉장히 많은 트랜잭션들이 추출되는데(215만 건), 모든 트랜잭션들을 테스트하기에는 시간이 오래 걸릴 수 있으니 시험 삼아 해당 트랜잭션 중 0.1%를 랜덤으로 추출합니다.
SELECT DISTINCT transaction_hash FROM crypto_ethereum.traces
TABLESAMPLE (0.1 PERCENT)
WHERE to_address='0x3d98192...'
또한 테스트의 편의성을 위해 새 오라클 컨트랙트가 배포된 시점 이후의 트랜잭션만 불러옵니다. 최종 쿼리는 다음과 같습니다.
SELECT DISTINCT transaction_hash FROM crypto_ethereum.traces
TABLESAMPLE SYSTEM (0.1 PERCENT)
WHERE to_address='0x3d98192...' AND
DATE(block_timestamp) >= '2022-07-28'
이를 통해 쿼리당 대략 240개의 트랜잭션 샘플을 얻을 수 있었습니다.
3. 과거 트랜잭션 시뮬레이션
이후, 영향을 받는 트랜잭션에 스토리지 변경 사항을 적용한 다음 실행했습니다. 결과로 아래와 같은 Solidity 코드가 생성되었습니다:
pragma solidity ^0.8.7;
import "forge-std/Test.sol";contract CheckTransaction is Test {
function testCheckTransaction() public {
// Apply state changes extracted from governance proposal
uint256[1] memory changeIndexes = [uint256(4)];
uint256[1] memory changeValues = [uint256(989257367531710799496057489928977151189150462309)];
address[1] memory changed = [0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B]; for(uint i = 0; i < changeIndexes.length; i++) {
vm.store(changed[0], bytes32(changeIndexes[i]), bytes32(changeValues[i]));
} // Setup environments to simulate the transaction
// 1. Select a block & transaction to fork
uint forkId = vm.createFork("http://127.0.0.1:8545", bytes32(0x79a054531b2d442d5900d0cf993d71a69f0080b1514927003b24b983bfae6ee0));
vm.selectFork(forkId);
vm.roll(15354878); // 2. Setup msg.sender, tx.origin, tx.origin.balance
address target = 0x8888882f8f843896699869179fB6E4f7e3B58888;
address sender = 0x310B44B03eaCF984BdB3eb90e6777496d09F82F9;
vm.prank(sender, sender); // 3. Execute a transaction and check if it succeeds
(bool success,) = target.call{value: 0}(hex"4b8a3529000000000000000000000000ccf4429db6322d5c611ee964527d42e5d685dd6a0000000000000000000000000000000000000000000000000000000001c48cec");
require(success);
}
}
위의 진행 과정과 트랜잭션 Revert(실패) 발생 방식에 대해 살펴보겠습니다:
[FAIL. Reason: EvmError: Revert] testCheckTransaction() (gas: 8004512848309167860)
Traces:
[..] CheckTransaction::testCheckTransaction()
...
├─ [..] MorphoProxy_0x8888::borrow(cWBTC2: [0xccF4..], 29658348)
│ ├─ [..] Morpho_0xf29c::borrow(cWBTC2: [..], 29658348) [delegatecall]
│ │ ├─ [..] PositionsManager_0x7821::borrowLogic(0xccF4429DB6322D5C611ee964527D42E5d685DD6a, 29658348, 100000) [delegatecall]
│ │ │ ├─ [94987] InterestRatesManager_0xe355::updateP2PIndexes(0xccF4429DB6322D5C611ee964527D42E5d685DD6a) [delegatecall]
...
│ │ │ ├─ [..] cWBTC2::borrow(29658348)
│ │ │ │ ├─ [..] CErc20Delegate::borrow(29658348) [delegatecall]
│ │ │ │ │ ├─ [..] Unitroller::borrowAllowed(cWBTC2: [..], Morpho_0x8888: [..], 29658348)
│ │ │ │ │ │ ├─ [..] Comptroller::borrowAllowed(cWBTC2: [..], Morpho_0x8888: [..], 29658348) [delegatecall]
│ │ │ │ │ │ │ ├─ [3441] NewPriceOracle::getUnderlyingPrice(cWBTC2: [..]) [staticcall]
...
│ │ │ │ │ │ │ ├─ [5155] NewPriceOracle::getUnderlyingPrice(cDAI: [..]) [staticcall]
...
│ │ │ │ │ │ │ ├─ [5947] NewPriceOracle::getUnderlyingPrice(cCOMP: [0x70e36f6BF80a52b3B46b3aF8e106CC0ed743E8e4]) [staticcall]
...
│ │ │ │ │ │ │ ├─ [..] NewPriceOracle::getUnderlyingPrice(cETH: [..]) [staticcall]
│ │ │ │ │ │ │ │ ├─ [2400] cETH::underlying() [staticcall]
│ │ │ │ │ │ │ │ │ └─ ← "EvmError: NotActivated"
│ │ │ │ │ │ │ │ └─ ← "EvmError: Revert"
...
└─ ← "EvmError: Revert"
Test result: FAILED. 0 passed; 1 failed; finished in 130.22msFailing tests:
Encountered 1 failing test in test/Counter.t.sol:CheckTransaction
[FAIL. Reason: EvmError: Revert] testCheckTransaction() (gas: 8004512848309167860)Encountered a total of 1 failing tests, 0 tests succeeded
위 트랜잭션은 Morpho(대출 프로토콜)의 borrow()
를 실행하여 Compound 프로토콜의 cWBTC2를 통해 29,658,348 WBTC 토큰을 빌립니다. cToken의 한 종류인 cWBTC2는 Morpho가 Compound에 빌려준 담보가 29,658,348 WBTC를 빌릴 수 있는 충분한지 확인합니다. 트랜잭션 기록에 따르면 Morpho가 빌려준 담보는 DAI, COMP, ETH입니다. 각 자산의 Ethereum 기초 가격을 구하기 위해 새 오라클 컨트랙트의 getUnderlyingPrice()
를 사용합니다. 그러나 앞서 언급한 오류로 인해 cETH의 기초 가격을 가져오는 데 실패합니다. 이 오류로 인해 전체 트랜잭션 생성이 실패합니다.
결론
Audius 스토리지 충돌 리서치에 이어서, 저희는 과거 트랜잭션 데이터를 활용한 온체인 통합 테스트를 진행해보았습니다. #117 제안의 경우에는 성공적으로 테스트를 수행할 수 있었으며, 다른 프로토콜의 대출 요청에 의해 트리거된 트랜잭션 실패를 성공적으로 확인할 수 있었습니다.
이 실험은 동일한 사건이 발생해도 통합 테스트를 통해 해결할 수 있는 것을 발견한 데에는 큰 의미가 있지만, 일반적으로 적용하기에는 여러 가지 한계점이나 극복해야 할 사항이 있었습니다.
의도적인 트랜잭션의 실패: 거버넌스 제안은 프로토콜의 많은 로직을 변경할 수 있습니다.(예: 서킷 브레이커, 리워드 토큰량 등) 이런 로직의 변경에 의해 과거에는 동작하던 트랜잭션이 더 이상 동작하지 않는 것은 의도된 것일 수 있습니다. 이는 테스트의 통과/실패 기준을 변경함으로써 해결할 수 있습니다.
오프체인 동작의 필요성: 현재 Compound protocol의 오라클 컨트랙트는 특정 EOA(Externally Owned Account)에 의해 업데이트됩니다. 즉, 일부 영향을 받는 트랜잭션의 시점에서는 해당 EOA가 업데이트 값을 전송하지 않아 종속값이 유효하지 않을 수 있습니다. 저희는 1개월 간의 트랜잭션이 포함된 오라클 컨트랙트 배포 이후의 트랜잭션만 사용함으로써 이 문제를 우회했습니다. 1번 단계의 스크립트를 조정하여 배포 이전의 환경을 만들어줄 수 있겠지만, 추가 작업이 필요합니다.
후속 연구를 통한 개선점
시간여행 통합 테스트는 앞서 언급한 바와 같이 한계점이 분명하지만, 통상적으로 제안되는 통합 테스트보다 더 나은 지점이 있습니다. 저희가 제안한 테스트 방식을 컨트랙트의 동작 여부 뿐만 아니라 시장 효과, 보상 변화와 같은 효과를 시각화에 사용할 수 있다면 또 다른 개선점이 될 수 있을 것입니다. 또한, 과거 트랜잭션들을 이용해 현실 케이스에 가까운 로컬 단위 테스트와 통합 테스트를 생성하는 데에도 유의미한 사용성을 가질 수 있을 것으로 예상합니다.
참고 자료
https://www.paradigm.xyz/2021/12/introducing-the-foundry-ethereum-development-toolbox
https://twitter.com/compoundfinance/status/1564695154430296067
https://etherscan.io/address/0xef3b6e9e13706a8f01fe98fdcf66335dc5cfdeed#code
✨ We are ChainLight!
ChainLight 팀은 풍부한 실전 경험과 깊은 기술 이해를 바탕으로 새롭고 효과적인 블록체인 보안 기술을 연구합니다. 연구 결과를 바탕으로 Web3 생태계의 각종 보안 위험 요소와 취약점을 사전 파악하여 제거하는 혁신적인 보안 감사 서비스를 제공합니다. 보안 감사 이후에도 온체인 데이터 모니터링 및 취약점 탐지 자동화 서비스를 이용한 지속적인 디지털 자산 위험 관리 솔루션을 제공합니다.
ChainLight 팀은 사용자들이 탈중앙화 서비스를 안전하게 활용할 수 있도록 Web3 생태계 위협으로부터의 보호에 힘쓰고 있습니다.
ChainLight의 더 다양한 정보를 보고 싶으시다면? 👉 Twitter 계정도 방문해 주세요.
🌐 Website: chainlight.io | 📩 TG: @chainlight | 📧 chainlight@theori.io
Originally published at https://blog.theori.io on May 18, 2023.