[The Butterfly Effect] The Compound Security Incident Caused by a Bugfix
By BlockSec Team (@BlockSecTeam)
In the last week, the Compound protocol has a bug that will accidentally
send a large number of COMP tokens to users. The cause of this bug (bug 2 in this blog) is due to the incorrect fix of another bug (Bug 1 in this blog) that was previously discovered.
In this blog, we will elaborate the root cause of the first bug and the reason why the fix to the first bug causes the second bug.
Background
The Compound protocol is based on the Compound Whitepaper. Through the cToken contracts, accounts on the blockchain supply capital (Ether or ERC-20 tokens) to receive cTokens or borrow assets from the protocol (holding other assets as collateral). The Compound cToken contracts track these balances and algorithmically set interest rates for borrowers.
To incentive users, users who provide liquidity to Compound (supplying capital) can receive the interest. Specifically, users provide assets (e.g., Ether or other ERC20 tokens) to Compound and receive the corresponding cTokens. When the cToken is returned back to Compound, the underlying assets (Ether or ERC20 tokens) and the interests will be returned to the user, if the user does not have any debt in Compound. For instance, if a user has 1000 Ether, then he/she can put the asset into Compound through cEth.mint(1000)
to obtain the cToken.
The cToken represents the underlying assets that have been locked in Compound. The user can further use the cToken as the collateral to borrow other assets. For instance, a user can deposit 1000 Ether through ceth.mint(1000)
and then use the obtained cTokens to borrow x Dai worth 75 Ether (over-collateralization -- this number depends on the collateral factor) through cDai.borrow(x)
.
The core logic is implemented in the Comptroller
contract. It maintains the states of a user, e.g., how many tokens have been deposited into Compound by the user, how many tokens have been borrowed by the user, and whether the user can borrow more tokens. The functions invoked in this process include getHypotheticalAccountLiquidityInternal()
, borrowAllowed()
, mintAllowed()
and etc.
The Compound also has the governance token called COMP. The COMP token can be used to vote for proposals. Besides, the COMP token can be traded in exchanges. Currently, the price of the COMP is around $300.
Bug 1
On September 31, 2021, there was a new proposal (Proposals 62) in the Compound DAO, which aims to fix a bug in the Comptroller.
The bug is related to CompSpeed
, which represents the number of COMP tokens that can be distributed to users in each block.
The flow of the mint
function
In the following, we will use the mint
function to describe the cause of this bug. The invocation chain of the mint
function is: mint
→ mintInternal
→ mintFresh.
In the function mintFresh
, it invokes the mintAllowed
and then updates the user's balance of the cToken.
In the function mintAllowed
, it first invokes updateCompSupplyIndex
and then distributeSupplierComp
to 1) update the compSupplyState
of the market and 2) distribute the COMP tokens to users.
updateCompSupplyIndex
The function updateCompSupplyIndex
will update the status of each market, mainly the compSupplyState[cToken]
.
In the CompMarketState
structure, it records the block number (block
) of this update, and the bonus index (index
) that will affect the number of COMP tokens that should be distributed to the users (who hold the cToken.)
What is the bonus index (index
) for each token? This is the value accumulated over time (shown in the following formula).
This shows the number of COMP that should be distributed to users (for each cToken the user holds).
distributeSupplierComp
The another function distributeSupplierComp
is responsible for recording the number of COMP tokens that should be distributed to the user (supplier
) in compAccrued[supplier]
Specifically, it updates the global bonus index in compSupplyState (in updateCompSupplyIndex
function). Then in the distributeSupplierComp
function, the supplyIndex
records the current bonus index, and the supplierIndex
shows the last bonus index for the user (supplier
). The delta value (supplyIndex - supplierIndex
) * the user's cToken balance
shows the number of COMP tokens that should be distributed to the user.
the cause of the Bug 1
There is another function setCompSpeed
to adjust the supplySpeed
of the market (compSpeeds[address[cToken]]
).
That’s because if we set the CompSpeed
for a market to zero, that means the COMP token will not be distributed to users in that market. So if we want to first disable the distribution of COMP for a market and then re-enable it, we can
- Step I: set the
CompSpeed[cToken]
as zero to disable the distribution of COMP tokens - Step II: invoke the
setCompSpeed
function to setCompSpeed[cToken]
as a non-zero value
Step I: For markets that have been disabled the distribution of COMP tokens in step I (supplySpeed == 0
), the block
is not zero, since the block
is continuously updated in updateCompSupplyIndex
(else if (deltaBlocks >0)
).
Step II: When executing the operation in Step II, the function setCompSpeedInternal
will go through the else if (compSpeed != 0)
statement (line 1083). Then in line 1088 to 1093, there is a check if (compSupplyState[address(cToken)].index == 0 && compSupplyState[address(cToken)].block == 0)
to initialize the index
and block
for a new market. However, since we are reenabling the distribution of COMP tokens in an existing market (not a new market), then the statements in 1090 an 1091 will not be executed to initialize the the index
and block
(since the compSupplyState[address(cToken)].block is not zero
).
In summary, for a currently disabled market, the index is zero
. However, the block
is not zero. This means, when we re-enable the disabled market by invoking setCompSpeed
to set CompSpeed[cToken]
as a non-zero value, the index
value will NOT be reinitialized to CompInitialIndex
(1e36) (line 1090 and 1091 is not executed.)
the impact of the bug 1
We further dig into the ``distributeSupplierComp` function that’s responsible for distributing the COMP tokens.
The supplierIndex
is compInitialIndex
. However, the supplyIndex
is still zero due to the bug, it will cause an underflow for Double memory deltaIndex = sub_(supplyIndex=0, supplierIndex=1e36)
.
Bug 2: Introduced by the fix to the bug 1
To fix the bug, the project owner changes the code logic. Specifically, it immediately initializes the index
to compInitialIndex
when initializing a new market.
Since the global bonus index (index
) has been initialized to compInitialIndex
, the user bonus index should also be initialized to this value. Let's see the distributeSupplierComp
function.
The if condition on line 1234 cannot be satisfied even if supplierIndex == 0 since the supplyIndex
is equal to (not bigger than) compInitialIndex (1e36)
. This causes that the supplierIndex
is NOT properly initialized to compInitialIndex
(its value is 0). Then the deltaIndex (supplyIndex - supplierIndex) will be compInitialIndex
, instead of zero. The supplierTokens
will become a large value if the user's balance of the cToken is not zero.
In summary, if a user happens to perform the mint operation before the fix of the bug 1, then he/she has cTokens and the supplierIndex will become zero (since the COMP token has been distributed). Then after the fix of the bug 1 (which introduces the bug 2), when the user invokes the mint
function again, he/she can get a large number of COMP token (1e36*ctoken.balanceOf(user)).
Real World
We show the affected markets in the following:
0xF5DCe57282A584D2746FaF1593d3121Fcac444dC: cSAI
0x12392F67bdf24faE0AF363c24aC620a2f67DAd86: cTUSD
0x95b4eF2869eBD94BEb4eEE400a99824BF5DC325b: cMKR
0x4B0181102A0112A2ef11AbEE5563bb4a3176c9d7: cSUSHI
0xe65cdB6479BaC1e22340E4E755fAE7E509EcD06c: cAAVE
0x80a2AE356fc9ef4305676f7a3E2Ed04e12C33946: cYFI
For the user (0xa7b95d2a2d10028cc4450e453151181cbcac74fc), the user gets 4,466.542459954989867175
COMP tokens in this transaction (0x6416ed016c39ffa23694a70d8a386c613f005be18aa0048ded8094f6165e7308).
The further debugging of the transaction shows that, due to the bug 2, the deltaIndex
is 1e36 and the user happens to have the cToken at that time.
the fix to the bug 2
The fix to the bug 2 is simple. It changes the if condition in the distributeSupplierComp
function.
Lessons
- This is a bug caused by the fix of another bug. How to thoroughly review the code changes for high-profile projects is still an open question.
- The DAO can eliminate the risk of centralization. However, it also makes the response to security incidents a slow process.
- The high profile DeFi projects can take good security practices in traditional programs, e.g., deploying an efficient fuzzing system with a continuous testing process.