Cover Protocol Hack
In December 2020, some addresses hacked Cover protocol. The hack allowed the attackers to mint an infinite amount of COVER tokens.
Cover Protocol (formerly Safe) was an insurance provider that allowed anyone to buy insurance coverage. The protocol gave out rewards in the form of COVER tokens to users using a contract called Blacksmith. This contract did an infinite mint of COVER tokens to the attacker.
If you are reading this on a mobile device you’ll have to zoom in to see the code images. I promise to find a solution before the next issue. Sorry for the inconvenience.
Vulnerability
The vulnerability exploited here was a logic error involving storage/memory misuse. If you are familiar with the Solidity programming language, you know that we can save gas by saving storage variables reused in a function in memory. Anytime we use the variable, we are charged the cheaper MLOAD
opcode gas for accessing memory instead of the costly SLOAD
opcode gas for storage.
But what happens when we make a change to that storage variable? Does it affect the variable in memory? No, it doesn’t. The variable in memory is a copy of the value in storage. That was what happened here. The code modified the storage variable correctly but still used its stale value in memory.
Here’s the deposit function of Blacksmith. In line 118, Blacksmith creates a memory pool
variable from a storage mapping called pools
. A pool is the supply of a token deposited to earn a reward in Blacksmith. Blacksmith has multiple pools. In line 121, the updatePool
function is called. Let’s see what it does.
The updatePool
function creates a storage pool variable from the same mapping. This pool and the other pool in the deposit function are created from the same value in the mapping. The pools accRewardsPerToken
and lastUpdatedAt
properties are updated in lines 84 and 85. The pool here is a storage variable hence the pool’s value in storage is updated when it is modified. But the pool
variable in the deposit
function remains the same. Let’s see how this affects the contract.
Now we are back in the deposit
function. In line 130, you will notice that Blacksmith uses the accRewardsPerToken property of the pool to calculate the rewardWriteoff
of a miner
variable. The miner
is the msg.sender
in this context. The alarms in your head should be going off by now. If they aren’t, don’t worry, we will explain in the next section.
Vulnerability Side-Effect
So we’ve seen the vulnerability, but how does it affect Blacksmith? First of all, we know Blacksmith uses a stale accRewardsPerToken
to calculate the rewardWriteoff
of a miner. The rewardWriteoff
of a miner determines how much reward the miner is no longer eligible for. But using a stale accRewardsPerToken
to calculate the rewardsWriteoff
makes it inaccurate. That is why your alarms should have gone off in the last section.
Now that we know rewardWriteoff
can be wrong, how do we exploit this vulnerability?
Exploit Strategy
As we mentioned earlier, Blacksmith gives out rewards in COVER tokens. These rewards are claimed by calling the claimRewards
function. This function uses the _claimCoverRewards
function to calculate the COVER reward and send it to the caller. We’ll start our strategy building from here since this is where money leaves the contract.
The _claimCoverRewards
function above calculates the reward in line 316. If you look at the values used, we can get a high reward if the product of miner.amount
and pool.accRewardsPerToken
are large and miner.rewardWriteoff
is low. CAL_MULITPLIER
is a constant. We can’t change its value. The number of tokens we have deposited in that pool is miner.amount
. So we can deposit a large number of tokens to increase its value. Let’s see how we can vary the other two values in our favour.
pool.accRewardsPerToken
determines the number of COVER tokens that each unit of an accepted token deposited in the pool has earned since the owner of Blacksmith added it. It is calculated in line 84 of the updatePool function above. For it to be large, coverRewards.div(lpTotal)
or/and the old pool.accRewardsPerToken
value has to be high. To make coverRewards.div(lpTotal)
large, lpTotal
has to be small. lpTotal
is the supply of a lpToken
staked in the pool to earn COVER rewards. We will need to reduce it. A lpToken
refers to any token accepted as a deposit in Blacksmith. Its supply makes up a pool. lpTotal
can’t be zero because of line 78. We don’t have any control over coverRewards
hence we’ll ensure lpTotal is really low — you can check _calculateCoverRewardsForPeriod
for its calculation. We will revisit the old pool.accRewardsPerToken
value since it also has to be added — pool.accRewardsPerToken
is cumulative.
Line 130 in the deposit function calculates the miner.rewardWriteoff
value. To get a low miner.rewardWriteoff
, miner.amount.mul(pool.accRewardsPerToken)
has to be small. We have a problem. miner.amount
also has to be high to get high rewards. We mentioned this earlier. That leaves us with pool.accRewardsPerToken
, we need to make it small. But pool.accRewardsPerToken
also has to be high. We established this earlier in _claimCoverRewards
. Normally, I’d give up here because this is quite a dilemma. But wait, we saw a vulnerability earlier. How do we leverage it?
At this point, the original hacker's thoughts transcended all human understanding in my opinion. He really took his time to figure this out. He might have even sat down and said, “Blacksmith, I will deal with you” 😂. Alright, we need pool.accRewardsPerToken
to be high in the _claimCoverRewards
function, but the vulnerability allows us to use an old pool.accRewardsPerToken
value to calculate miner.rewardWriteoff
when we deposit. We’ll need to get the old pool.accRewardsPerToken
to be small so that we get a small miner.rewardWriteoff
. But we can only increase pool.accRewardsPerToken
since it is a cumulative value. Therefore we’ll focus on making the new pool.accRewardsPerToken
larger than the old one.
This means that we need the new pool.accRewardsPerToken
to be larger than the old pool.accRewardsPerToken
in line 84 above of the updatePool function. We’ve already established that to get coverRewards.div(lpTotal)
to be large, lpTotal
has to be small. We also said we do not have control over coverRewards
for the pool. If we get lpTotal
to equal 1, we can get the highest value for coverRewards.div(lpTotal)
, assuming coverRewards
is constant. But the old pool.accRewardsPerToken
also has to be small. This value is cumulative, which means it will be small for a new Blacksmith pool. So how do we get it to equal 1? There’s one last piece of this strategy we need to think about.
The deposit function calls updatePool
in line 121 when a user deposits. At this point, the lpTotal
value used is the number of lp tokens in the pool before the user calls the deposit
function. We’ll need to deposit 1 lpToken or somehow get 1 lpToken to be the only amount in the pool before we call the deposit
function. After we call updatePool
, the new, high and correct value of pool.accRewardsPerToken
is stored in storage. But its old, low and incorrect value in memory is still used in line 130 to calculate our rewardWriteoff because of the vulnerability. In line 130, the vulnerability has helped us solve our dilemma of needing pool.accRewardsPerToken
to be high and low in the same call context. But we cannot do anything about miner.amount
as it is updated correctly and we need it to be high. The small value of pool.accRewardsPerToken
is sufficient enough for us to have a miner.rewardWriteoff
value far lower than the correct value. We’ve got satisfied all the conditions we need. Let’s go back to the _claimCoverRewards
function and summarise our findings.
We now know how to get miner.amount
and pool.accRewardsPerToken
to be high, and miner.rewardWriteoff
to be low. That is what we originally wanted. Now to execute our attack, we’ll need to:
Get a newly deployed pool so we can ensure it’s
pool.accRewardsPerToken
is low.Deposit 1 lpToken if it’s empty or somehow get 1 lpToken to be in the pool. You’ll soon see the miracle that allowed the original hacker to pull this off.
Wait for a few blocks, so
updatePool
can update the pool when we call deposit. The pool won’t update twice in the same block. Check line 76 of Blacksmith.Call deposit with a sufficiently large
_amount
value as its parameter. This gives us a smallminer.rewardWritoff
, a large pool.accRewardsPerToken and a largeminer.amount
value.Call
claimRewards
, and get a highminedSinceLastUpdate
value in_claimCoverRewards
. Blacksmith mints our rewards in theif
condition above. minedSinceLastUpdates value ends up with a very high value because of the big difference betweenminer.rewardWriteoff
andminer.amount.mul(pool.accRewardsPerToken).div(CAL_MULTIPLIER)
.
We can repeat this process over and over again. Thus, Blacksmith becomes an Infinite Printing Machine (COVER Printer Go Brrr). The main reason it prints so many COVER tokens is that, with 1 lpToken in the pool, all the rewards go to that one. If there are more units in the pool it is shared among all of them. We took the high reward for one token and spread it across each unit of the amount we deposited. The system works like this but offsets this increase with a rewardWriteoff. But we exploited the vulnerability to give a wrong reward write-off 😈. Actually, the hacker did it not us 😂. Let’s get down from our high horses before they take us down. In fact, we should pay him a visit.
Original Hack
Here is the timeline of the original hack.
A new pool is added to Blacksmith.
The hacker sends 15,255.552810089260015362 lpTokens for that new pool to Blacksmith.
The hacker withdraws 15,255.552810089260015361 lpTokens from the pool leaving 0.000000000000000001 of his tokens in the pool.
Miraculously another user withdraws all his tokens from the pool leaving only the hackers' tokens in the pool. This action satisfies the condition of
lpTotal
being equal to 1.The hacker waits for some blocks and deposits his tokens again and as you should expect, a small
pool.accRewardsPerToken
value was used to calculate hisminer.rewardWriteoff
but the realpool.accRewardsPerToken
was magnitudes higher. So he had a smallrewardWriteoff
.The hacker called
claimRewards
. His smallrewardWriteoff
was not enough to offset the fake rewards he earned so he claimed 40,796,131,214,802,481,236.212114436030863813 COVER tokens from the Infinite Minting Machine (sorry, from Blacksmith).
The hacker has shown us the way. Now it’s time for us to do ours.
reHacking
The code to re-hack it is simple. There are no complex calls. We will use Foundry to fork Ethereum Mainnet where the hack happened and run the script using forge test
, a tool from Foundry. You can find the complete code on GitHub.
The code above is the main code snippet used to complete the hack. The constant variables, e.g. addresses, have already been set. Let’s dive in.
Line 2: The test script starts with forking and selecting the Ethereum Mainnet at block 11542183. We fork at this block because the owner of Blacksmith added a new pool in block 11540124. The lpTotal of this pool at this block is 0. The pool added was a Balancer pool. Balancer is just another DEX like Uniswap.
Line 4: Foundry gives us ETH, but we need the lpToken called Balancer Pool Token (BPT). So we get BPT tokens here. You can check the code on GitHub for how we get BPT.
Line 9: We deposit 1 BPT token into the pool when we apply the decimals of BPT token, this is 0.000000000000000001 BPT tokens. That satisfies the condition for having a small lpTotal. We will have a large pool.accRewardsPerToken
the next time we deposit.
Line 12 & Line 13: We have to wait to mine some blocks before we can deposit again so that the pool will be updated. We use Foundry to simulate this.
Line 15: We deposit all our remaining BPTBalance into Blacksmith. The pool is updated to have a new pool.accRewardsPerToken
but because of the vulnerability, the old pool.accRewardsPerToken
is used to calculate our miner.writeoff which is too small.
Line 19: We claim our rewards and make Blacksmith print that cash. This is possible because of the large pool.accRewardsPerToken
and small miner.rewardWriteoff
used in calculating our reward.
That’s it, but how much did we steal?
On Github, you can access the files under the test folder. The Cover Protocol folder contains Hacker.sol and Hacker-info.sol. Hacker-info. sol contains logs and comments. Run it to have a good description of the steps taken in the code. Hacker.sol is the code without any logs. After cloning the repo from Github and running forge install, you can either run Hacker.sol or Hacker-info.sol using:
forge test --match-path test/Cover-Protocol/Hacker.sol
or
forge test --match-path test/Cover-Protocol/Hacker-info.sol -vv
When we run the second command we get these logs:
From the last line, you’ll notice we claimed 15,850,201,257,390,927.856559235768024284 COVER tokens.
Yes, 😩 we were finally able to steal the tokens. But we aren’t thieves, the aim of this piece is for us to learn. So now that we have learnt how to hack it. We should also be able to fix it.
Mitigation
Fixing the issue is quite simple. The vulnerability was caused because pool.accRewardPerToken did not update. To allow pool update properly, we’ll need to have it referencing storage and not memory. So any update the code does to the storage variable will immediately reflect in the storage reference. We’ll have to change the memory keyword to storage in the deposit function to fix this issue. Simple right?
Conclusion
As developers and auditors, we need to take note of value and reference variables in Solidity and know when exactly to use them.
When we write code, we may need to store a variable in memory to save gas. We should ensure nothing else in the code will update that value while it is in use. Keep your eyes peeled for calls to other functions. These functions can modify the value in storage as updatePool
did, and we’ll end up using a stale value like depositPool
.
When we audit code, we should take any value written from memory to storage and vice-versa as a code smell until proven otherwise.
Woah 😩, that was a long ride. I feel exhausted sitting and writing in this chair. Thanks for reading and getting to the end with me. It wasn’t easy, especially if you went through the code with us. I really appreciate your time and concentration. We will meet again in our next issue. Farewell. 🏎🏎
Sorry, I forgot to mention this a second ago. If you have any questions, notice discrepancies, bugs, corrections or suggestions, please don’t hesitate to mention them in the comments. I need them to make this newsletter better for you. Let’s have a symbiotic relationship 😉. I’ll also appreciate it if you subscribe and share with your network. Thanks again. Farewell for real this time. 🏎🏎
Written with malice😈 by nonseodion.