BlockSec 팀 작성 (@BlockSecTeam)

지난 주, Compound 프로토콜에서 다수의 COMP 토큰을 사용자에게 실수로 전송하는 버그가 발생했습니다. 이 버그(본 블로그의 버그 2)의 원인은 이전에 발견된 다른 버그(본 블로그의 버그 1)를 잘못 수정한 것에서 비롯되었습니다.
이 블로그에서는 첫 번째 버그의 근본 원인과 첫 번째 버그에 대한 수정이 두 번째 버그를 유발한 이유에 대해 자세히 설명하겠습니다.
배경
Compound 프로토콜은 Compound 백서를 기반으로 합니다. cToken 컨트랙트를 통해 블록체인의 계정들은 cToken을 받기 위해 자본(이더 또는 ERC-20 토큰)을 공급하거나, 다른 자산을 담보로 보유하면서 프로토콜에서 자산을 빌립니다. Compound cToken 컨트랙트는 이러한 잔액을 추적하고 차입자를 위한 이자율을 알고리즘적으로 설정합니다.
사용자 인센티브를 위해, Compound에 유동성을 제공(자본 공급)하는 사용자는 이자를 받을 수 있습니다. 구체적으로, 사용자는 Compound에 자산(예: 이더 또는 기타 ERC20 토큰)을 제공하고 해당하는 cToken을 받습니다. cToken이 Compound에 반환되면, Compound에 부채가 없는 경우 기초 자산(이더 또는 ERC20 토큰)과 이자가 사용자에게 반환됩니다. 예를 들어, 사용자가 1000 이더를 보유하고 있다면, cEth.mint(1000)을 통해 해당 자산을 Compound에 넣어 cToken을 얻을 수 있습니다.

cToken은 Compound에 잠긴 기초 자산을 나타냅니다. 사용자는 cToken을 담보로 사용하여 다른 자산을 빌릴 수 있습니다. 예를 들어, 사용자는 ceth.mint(1000)을 통해 1000 이더를 예치하고, 획득한 cToken을 사용하여 cDai.borrow(x)를 통해 75 이더 상당의 x Dai를 빌릴 수 있습니다(초과 담보화 -- 이 수치는 담보 비율에 따라 달라집니다).
핵심 로직은 Comptroller 컨트랙트에 구현되어 있습니다. 이 컨트랙트는 사용자가 Compound에 예치한 토큰 수, 사용자가 빌린 토큰 수, 사용자가 더 많은 토큰을 빌릴 수 있는지 여부와 같은 사용자 상태를 유지합니다. 이 과정에서 호출되는 함수에는 getHypotheticalAccountLiquidityInternal(), borrowAllowed(), mintAllowed() 등이 포함됩니다.
Compound에는 또한 COMP라는 거버넌스 토큰이 있습니다. COMP 토큰은 제안에 투표하는 데 사용할 수 있습니다. 또한, COMP 토큰은 거래소에서 거래될 수 있습니다. 현재 COMP의 가격은 약 $300입니다.
버그 1
2021년 9월 31일, Compound DAO에 새로운 제안(제안 62)이 등장했으며, 이는 Comptroller의 버그를 수정하는 것을 목표로 했습니다.

이 버그는 각 블록에서 사용자에게 배포될 수 있는 COMP 토큰 수를 나타내는 CompSpeed와 관련이 있습니다.
mint 함수의 흐름
다음에서는 mint 함수를 사용하여 이 버그의 원인을 설명하겠습니다. mint 함수의 호출 체인은 mint → mintInternal → mintFresh입니다.

mintFresh 함수에서는 mintAllowed를 호출한 다음 사용자의 cToken 잔액을 업데이트합니다.

mintAllowed 함수에서는 먼저 updateCompSupplyIndex를 호출한 다음 distributeSupplierComp를 호출하여 1) 마켓의 compSupplyState를 업데이트하고 2) 사용자에게 COMP 토큰을 배포합니다.
updateCompSupplyIndex

updateCompSupplyIndex 함수는 주로 compSupplyState[cToken]인 각 마켓의 상태를 업데이트합니다.

CompMarketState 구조체에는 이 업데이트의 블록 번호(block)와 사용자(cToken 보유자)에게 배포되어야 할 COMP 토큰 수에 영향을 미치는 보너스 인덱스(index)가 기록됩니다.
각 토큰의 보너스 인덱스(index)란 무엇인가요? 이는 시간이 지남에 따라 누적된 값입니다(아래 공식 참조).

이는 사용자에게 배포되어야 할 COMP 수량을 나타냅니다(사용자가 보유한 각 cToken에 대해).
distributeSupplierComp
또 다른 함수 distributeSupplierComp는 사용자(공급자)에게 배포되어야 할 COMP 토큰 수를 compAccrued[supplier]에 기록하는 역할을 합니다.

구체적으로, compSupplyState의 전역 보너스 인덱스를 업데이트합니다(updateCompSupplyIndex 함수에서). 그런 다음 distributeSupplierComp 함수에서 supplyIndex는 현재 보너스 인덱스를 기록하고, supplierIndex는 사용자(공급자)의 마지막 보너스 인덱스를 나타냅니다. 델타값 (supplyIndex - supplierIndex) * 사용자의 cToken 잔액은 사용자에게 배포되어야 할 COMP 토큰 수를 나타냅니다.
버그 1의 원인
마켓의 supplySpeed(compSpeeds[address[cToken]])를 조정하는 또 다른 함수 setCompSpeed가 있습니다.

마켓의 CompSpeed를 0으로 설정하면 해당 마켓에서 사용자에게 COMP 토큰이 배포되지 않는다는 것을 의미합니다. 따라서 마켓에 대한 COMP 배포를 먼저 비활성화했다가 다시 활성화하려면 다음 단계를 따를 수 있습니다:
- 1단계: COMP 토큰 배포를 비활성화하기 위해
CompSpeed[cToken]을 0으로 설정합니다. - 2단계:
setCompSpeed함수를 호출하여CompSpeed[cToken]을 0이 아닌 값으로 설정합니다.

1단계: 1단계에서 COMP 토큰 배포가 비활성화된 마켓(supplySpeed == 0)의 경우, block은 updateCompSupplyIndex에서 지속적으로 업데이트되기 때문에(else if (deltaBlocks > 0)) 0이 아닙니다.

2단계: 2단계의 작업을 실행할 때, setCompSpeedInternal 함수는 else if (compSpeed != 0) 구문(1083번 줄)을 통과합니다. 그런 다음 1088번에서 1093번 줄에는 새로운 마켓의 index와 block을 초기화하기 위한 if (compSupplyState[address(cToken)].index == 0 && compSupplyState[address(cToken)].block == 0) 검사가 있습니다. 그러나 기존 마켓에서 COMP 토큰 배포를 다시 활성화하는 것이므로(새로운 마켓이 아닌), compSupplyState[address(cToken)].block이 0이 아니기 때문에 1090번과 1091번 줄의 구문이 실행되어 index와 block을 초기화하지 않습니다.
요약하면, 현재 비활성화된 마켓의 경우 index는 0입니다. 그러나 block은 0이 아닙니다. 이는 setCompSpeed를 호출하여 CompSpeed[cToken]을 0이 아닌 값으로 설정함으로써 비활성화된 마켓을 다시 활성화할 때, index 값이 CompInitialIndex(1e36)으로 재초기화되지 않는다는 것을 의미합니다(1090번과 1091번 줄이 실행되지 않습니다).
버그 1의 영향
COMP 토큰 배포를 담당하는 distributeSupplierComp 함수를 더 자세히 살펴보겠습니다.

supplierIndex는 compInitialIndex입니다. 그러나 버그로 인해 supplyIndex는 여전히 0이며, 이는 Double memory deltaIndex = sub_(supplyIndex=0, supplierIndex=1e36)에서 언더플로우를 유발합니다.

버그 2: 버그 1 수정으로 인해 도입된 버그
버그를 수정하기 위해 프로젝트 소유자는 코드 로직을 변경했습니다. 구체적으로, 새로운 마켓을 초기화할 때 즉시 index를 compInitialIndex로 초기화합니다.

전역 보너스 인덱스(index)가 compInitialIndex로 초기화되었으므로, 사용자 보너스 인덱스도 이 값으로 초기화되어야 합니다. distributeSupplierComp 함수를 살펴보겠습니다.

1234번 줄의 if 조건은 supplyIndex가 compInitialIndex(1e36)보다 크지 않고 같기 때문에 supplierIndex == 0이더라도 만족될 수 없습니다. 이로 인해 supplierIndex가 compInitialIndex로 적절히 초기화되지 않습니다(값은 0). 그러면 deltaIndex(supplyIndex - supplierIndex)가 0 대신 compInitialIndex가 됩니다. 사용자의 cToken 잔액이 0이 아닌 경우 supplierTokens는 큰 값이 될 것입니다.
요약하면, 사용자가 버그 1의 수정 전에 mint 작업을 수행한 경우, 그는 cToken을 보유하고 있으며 supplierIndex는 0이 됩니다(COMP 토큰이 배포되었으므로). 그런 다음 버그 1의 수정(버그 2를 도입하는) 이후에 사용자가 다시 mint 함수를 호출하면, 많은 수의 COMP 토큰(1e36*ctoken.balanceOf(user))을 얻을 수 있습니다.
실제 사례
다음에서 영향을 받은 마켓을 보여줍니다:
0xF5DCe57282A584D2746FaF1593d3121Fcac444dC: cSAI
0x12392F67bdf24faE0AF363c24aC620a2f67DAd86: cTUSD
0x95b4eF2869eBD94BEb4eEE400a99824BF5DC325b: cMKR
0x4B0181102A0112A2ef11AbEE5563bb4a3176c9d7: cSUSHI
0xe65cdB6479BaC1e22340E4E755fAE7E509EcD06c: cAAVE
0x80a2AE356fc9ef4305676f7a3E2Ed04e12C33946: cYFI
사용자(0xa7b95d2a2d10028cc4450e453151181cbcac74fc)는 이 트랜잭션(0x6416ed016c39ffa23694a70d8a386c613f005be18aa0048ded8094f6165e7308)에서 4,466.542459954989867175개의 COMP 토큰을 얻었습니다.

트랜잭션에 대한 추가 디버깅 결과, 버그 2로 인해 deltaIndex가 1e36이 되었으며, 그 시점에 사용자가 cToken을 보유하고 있었음을 보여줍니다.



버그 2의 수정
버그 2의 수정은 간단합니다. distributeSupplierComp 함수의 if 조건을 변경합니다.

교훈
- 이것은 다른 버그의 수정으로 인해 발생한 버그입니다. 주목도 높은 프로젝트의 코드 변경 사항을 철저히 검토하는 방법은 여전히 해결되지 않은 과제입니다.
- DAO는 중앙화의 위험을 제거할 수 있습니다. 그러나 보안 사고에 대한 대응을 느리게 만들기도 합니다.
- 주목도 높은 DeFi 프로젝트는 지속적인 테스트 프로세스와 함께 효율적인 퍼징 시스템을 배포하는 등 전통적인 프로그램의 좋은 보안 관행을 채택할 수 있습니다.
BlockSec 소개
BlockSec은 전 세계적으로 저명한 보안 전문가 그룹이 2021년에 설립한 선도적인 블록체인 보안 회사입니다. 이 회사는 대규모 채택을 촉진하기 위해 새롭게 부상하는 Web3 세계의 보안성과 사용성을 향상시키는 데 전념하고 있습니다. 이를 위해 BlockSec은 스마트 컨트랙트 및 EVM 체인 보안 감사 서비스, 보안 개발 및 위협 사전 차단을 위한 Phalcon 플랫폼, 자금 추적 및 조사를 위한 MetaSleuth 플랫폼, 그리고 암호화폐 세계를 효율적으로 탐색하는 Web3 빌더를 위한 MetaSuites 확장 프로그램을 제공합니다.
현재까지 이 회사는 MetaMask, Uniswap Foundation, Compound, Forta, PancakeSwap 등 300개 이상의 저명한 고객사에 서비스를 제공했으며, Matrix Partners, Vitalbridge Capital, Fenbushi Capital을 포함한 저명한 투자자들로부터 두 차례의 자금 조달 라운드에서 수천만 달러를 유치했습니다.
공식 웹사이트: https://blocksec.com/
공식 트위터 계정: https://twitter.com/BlockSecTeam



