Skip to content

Latest commit

 

History

History
91 lines (65 loc) · 6.52 KB

2020-12-Cover.md

File metadata and controls

91 lines (65 loc) · 6.52 KB

Cover Protocol Mint Exploit

Tom Linton 2020-12-28.

Background

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.

Technical description

  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

Timeline

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.

  1. A new Balancer pool was initialized in this transaction.
  2. Grap.finance deposits 15,255.552810089260015362 of the BPT token.
  3. Grap.finance withdraws 15,255.552810089260015361 of the BPT token.
  4. 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.

  1. Grap.finance deposits more BPT tokens.

The mismatch between the pool.accRewardsPerToken and miner.rewardWriteoff is now in place.

  1. Grap.finance calls claimRewards, receiving around 40,796,131,214,802,500,000 $COVER.
  2. Grap.finance returns the $COVER.

Conclusion

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.