Smart Contract Vulnerabilities that ChatGPT cannot uncover (Part 2)
Welcome back to the series. In the last part, we talked about sneaky Denial of Service attacks. In this edition, we will examine an outdated cache exploit from a famous DeFi example, Cover Protocol.
I was curious, so I asked ChatGPT 4 if it could audit the exploited function:
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);
Miner storage miner = miners[_lpToken][msg.sender];
BonusToken memory bonusToken = bonusTokens[_lpToken];
_claimCoverRewards(pool, miner);
_claimBonus(bonusToken, miner);
miner.amount = miner.amount.add(_amount);
miner.rewardWriteoff = miner.amount.mul(pool.accRewardsPerToken).div(CAL_MULTIPLIER);
miner.bonusWriteoff = miner.amount.mul(bonusToken.accBonusPerToken).div(CAL_MULTIPLIER);
IERC20(_lpToken).safeTransferFrom(msg.sender, address(this), _amount);
emit Deposit(msg.sender, _lpToken, _amount);
}
I’ll spare you the word salad that it spit back out at me, but you can imagine it wasn’t very helpful. There were general suggestions about reentrancy and adding more validation checks for safety, followed by an admission that more information is needed to see how some of the other functions are implemented. So, I asked again:
The closest to the correct answer ChatGPT got was here:
But this still isn’t what’s wrong with the contract. And no, more information is not really needed. The issue, which was spotted in the attack on Cover Protocol, is staring us right in the face. I am adding the same code as above, this time with the in-line comments I made added back (I wasn’t going to give ChatGPT any hints):
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]; //caches data
require(pool.lastUpdatedAt > 0, "Blacksmith: pool does not exists");
require(IERC20(_lpToken).balanceOf(msg.sender) >= _amount, "Blacksmith: insufficient balance");
updatePool(_lpToken); //updates the pool. updatePool checks the current balance of the lptoken in the contract
Miner storage miner = miners[_lpToken][msg.sender];
BonusToken memory bonusToken = bonusTokens[_lpToken];
_claimCoverRewards(pool, miner); //still uses the old pool values
_claimBonus(bonusToken, miner); //still uses the old pool values
miner.amount = miner.amount.add(_amount);
// update writeoff to match current acc rewards/bonus per token
miner.rewardWriteoff = miner.amount.mul(pool.accRewardsPerToken).div(CAL_MULTIPLIER); //still uses the old pool values
miner.bonusWriteoff = miner.amount.mul(bonusToken.accBonusPerToken).div(CAL_MULTIPLIER); //still uses the old pool values
IERC20(_lpToken).safeTransferFrom(msg.sender, address(this), _amount);
emit Deposit(msg.sender, _lpToken, _amount);
}
The attacker exploited the outdated cache value of pool to dramatically increase the value of the rewards received.
According to this breakdown by Mudit Gupta:
- A new pool was approved for liquidity mining, merely hours before the hack. This pool is perfectly normal but since it was new, the blacksmith contract didn’t have any LP token of this pool.
- The attacker deposited some tokens of this pool into the Blacksmith contract.
- The Blacksmith contract keeps track of rewards on a per token basis. If a lot of tokens are locked, the per token reward will be small. If very few tokens are locked, the per token reward will be large. The relevant variable is called
accRewardsPerToken
and is calculated astotalPoolRewards / totalTokenBalance
. - The attacker then withdrew almost all of the LP tokens from the Blacksmith contract, reducing the
totalTokenBalance
amount to almost zero. - The attacker then deposited some tokens of this pool again into the Blacksmith contract. This is where the bug showed its true colors. Since the
totalTokenBalance
was reduced a lot in the previous transaction, the newly calculatedaccRewardsPerToken
shot up. The contract usesrewardWriteoff
to keep the affect ofaccRewardsPerToken
in check. However, due to the bug, the old (small) value ofaccRewardsPerToken
was used when calculating therewardWriteoff
value. Due to this, the large value ofaccRewardsPerToken
remained unchecked. - The attacker then withdrew their rewards. Since there was a large, unchecked value in
accRewardsPerToken
, the total reward paid out of the system got inflated and the contract ended up minting 40,796,131,214,802,500,000 COVER tokens.
Moral of the story here is, always check to make sure you’re using the correct values in your function. Because ChatGPT won’t catch it!
If you found this article helpful and enjoy learning about all sorts of cool tricks with Solidity and DeFi hacks, star our CryptoCadet Academy repo on GitHub for more!
TechJD is the Founder of Ascendant.Finance, which assists web2 businesses to transition to web3, consulting on all facets including SEC compliance, tokenomics, development, and connecting with angel investors. For more information check out ascendant.finance or join the Discord.
https://twitter.com/ascendantproj