Tom Linton 2020-12-28.
Cover is a peer to peer insurance protocol for DeFi protocols. It allows the market to set the price for the premium of coverage for a protocol, and market makers to fill the demand for coverage. As part of the market making, CLAIM and NOCLAIM tokens are minted in equal amounts. Cover allows for "shield mining" which is the process of mining the $COVER token by staking LP tokens from Balancer for providing liquidity for CLAIM/NOCLAIM tokens. The shield mining contract Blacksmith.sol is the contract containing the vulnerability.
function deposit(address _lpToken, uint256 _amount) external override {
require(block.timestamp >= START_TIME , "Blacksmith: not started");
require(_amount > 0, "Blacksmith: amount is 0");
Pool memory pool = pools[_lpToken];
require(pool.lastUpdatedAt > 0, "Blacksmith: pool does not exists");
require(IERC20(_lpToken).balanceOf(msg.sender) >= _amount, "Blacksmith: insufficient balance");
updatePool(_lpToken);
...
}
The above code excerpt is taken from Blacksmith.sol, and is part of the deposit
function which is called to deposit into the shield mining contract. An in-memory copy of a Pool is created. The intent of using memory
here is to avoid modifying the data in storage
(typically because you want to perform some calculation with it, but you don't need to retain any changes). At the end of the function call the changes in memory are discarded, making it more gas efficient than using storage.
function updatePool(address _lpToken) public override {
Pool storage pool = pools[_lpToken];
if (block.timestamp <= pool.lastUpdatedAt) return;
uint256 lpTotal = IERC20(_lpToken).balanceOf(address(this));
if (lpTotal == 0) {
pool.lastUpdatedAt = block.timestamp;
return;
}
// update COVER rewards for pool
uint256 coverRewards = _calculateCoverRewardsForPeriod(pool);
pool.accRewardsPerToken = pool.accRewardsPerToken.add(coverRewards.div(lpTotal));
pool.lastUpdatedAt = block.timestamp;
// update bonus token rewards if exist for pool
BonusToken storage bonusToken = bonusTokens[_lpToken];
if (bonusToken.lastUpdatedAt < bonusToken.endTime && bonusToken.startTime < block.timestamp) {
uint256 bonus = _calculateBonusForPeriod(bonusToken);
bonusToken.accBonusPerToken = bonusToken.accBonusPerToken.add(bonus.div(lpTotal));
bonusToken.lastUpdatedAt = block.timestamp <= bonusToken.endTime ? block.timestamp : bonusToken.endTime;
}
}
In contrast we can see in the updatePool
function it correctly uses storage
because the pool is being modified and that state change needs to be stored on chain. The changes to the pool in updatePool
are not reflected in the pool
referenced in the deposit
function because it is an in-memory of the pool prior to any changes. The incorrect pool is then later used as it is passed to _claimCoverRewards
and an invalid value of pool.accRewardsPerToken
is used to calculate the reward write off.
The deposit
function contains the following line:
miner.rewardWriteoff = miner.amount.mul(pool.accRewardsPerToken).div(CAL_MULTIPLIER);
As we previously established, it is possible for pool.accRewardsPerToken
to be incorrect here due to the memory/storage confusion. Using a very small deposit amount, it is possible to cause pool.accRewardsPerToken
to be very small here, resulting in a small value for miner.rewardWriteOff
. Meanwhile, the actual pool.accRewardsPerToken
in storage could be very high.
When an account then claims their rewards with claimCoverRewards
the following line will execute:
uint256 minedSinceLastUpdate = miner.amount.mul(pool.accRewardsPerToken).div(CAL_MULTIPLIER).sub(miner.rewardWriteoff);
The reward amount is calculated by multipling by the very large pool.accRewardsPerToken
and subtracting the previously incorrectly calculated miner.rewardWriteOff
There were multiple exploiters, but to simplify we'll only outline transactions from one party. Note that these transactions were made by the Grap.finance deployer account, and the $COVER that was drained in these transactions was returned to Cover making it a whitehack hack. It is possible that these funds would have been drained by a malicious actor had Grap.finance not performed these transactions.
- A new Balancer pool was initialized in this transaction.
- Grap.finance deposits 15,255.552810089260015362 of the BPT token.
- Grap.finance withdraws 15,255.552810089260015361 of the BPT token.
- Another user withdrew their BPT balance.
At this point, the difference between the BPT deposit/withdraw in steps 2 and 3 is 1 WEI, meaning that there is exactly 1 WEI of the BPT token left in the pool on the Blacksmith contract.
- Grap.finance deposits more BPT tokens.
The mismatch between the pool.accRewardsPerToken
and miner.rewardWriteoff
is now in place.
- Grap.finance calls
claimRewards
, receiving around 40,796,131,214,802,500,000 $COVER. - Grap.finance returns the $COVER.
This is one of the few exploits in the DeFi space that did not need a contract deployed, it could have been achieved with just a web3 wallet and a contract UI like the one provided by Etherscan. The attacker (0xf05ca010d0bd620cc7c8e96e00855dde2c2943df) does not appear to be a sophisticated user and may have stumbled across the exploit by accident. The attacker account was funded by other accounts that have a long history and so fund recovery may be possible.
It is likely this issue would have been uncovered by the use of a tool such as echidna.
A full reproduction using Hardhat and Solidity is available here.