Recently, a number of deflationary tokens such as FETA and BEVO have suffered flashloan exploits. We noticed that our analysis contradicted a number of previously published reports.
According to these published analyses, one might get the impression that SafeMoon-type deflationary tokens are inherently vulnerable. Allegedly, if DEX Pair is not added to excludeFromReward()
, an attacker can extract money from it. Such news would be quite disturbing given the popularity of SafeMoon clones.
In this post, we will show the real vulnerability conditions and bugged code.
Exploit Transaction | Exploiter | Exploit Contract | Victim Pair | Token Contract
The attacker:
deliver()
token method to burn 156 million FETA tokens (part of the normal SafeMoon implementation). Since a large number of tokens were burned, the deflationary nature of FETA increased all the balances, including the victim DEX pair. Its balance increased from 100 million to 313 millionskim()
method (part of the normal DEX Pair implementation)deliver()
to burn 205 million FETA tokensskim()
1.2 billion extra FETA tokens from PairSafeMoon is an ERC20 token, the balance of which grows over time.
Each transfer incurs a burn fee of 5%. Send 100 tokens and receive 95. Token balances of all other rewardable accounts are automatically increased (5 tokens distributed pro-rata).
To achieve balance auto-updating, the balance is not stored directly but as a Reflection. TokenBalance[owner] = Reflection[owner] / Rate
.
When Reflections are burnt, the Rate is decreased. All token balances increase automatically.
a. Rate = ReflectionTotal / TokenTotal
.
b. For example, if you burn half of the Reflections, the Rate is halved, and everyone’s token balance is doubled.
c. TokenTotal
(the sum of all token balances) remains the same.
Some accounts are excludedFromReward
. Their token balances don’t grow automatically. That is useful for DEXs. The contract owner had the power to move addresses to and from that list.
DEX Pair holds liquidity: amountA of tokenA and amountB of tokenB.
The user can swap one token for another.
The price is determined by a ratio of the amounts. PriceA = amountB/amountA
.
The price is automatically adjusted so that amountB*amountA
remains constant (though it is slightly adjusted by fee).
For example, if you send to Pair 10*amountA
of tokenA
, you will get 10/11*amountB
of tokenB
. Because after the operation there will be 11 times more tokenA
and 11 times less tokenB
- the product is preserved.
DEX Pair remembers its balances and allows everyone to extract surplus via skim()
method to keep the reserves product the same as remembered.
At first glance, the attack scenario seems clear:
The author of another token forgot to add the DEX Pair WBNB-FETA address to the excludedFromReward
list.
Because of this, the DEX Pair's balance began to grow on its own as fees were burned. This is not ideal, because the DEX Pair is trying to keep track of its balances.
This extra balance was considered the exchanger's contribution: they received more WBNB in return for their work.
However, once the attacker burned a large number of tokens, then extracted the excess from the DEX Pair and repeated this action. The attacker got a lot of tokens.
This allowed them to exchange a huge number of tokens for almost all WBNB in Pair.
From this superficial analysis, it turns out that the deflationary nature of the token and the forgetfulness of its deployer are to blame. It is necessary to add DEX Pair to excludedFromReward
. If suddenly somewhere else there are such pairs of other deflationary tokens (which is very likely), then an attacker can also extract funds from them, bringing down the token price.
However, in reality, it is not possible to use the above approach. It is impossible to extract more from a Pair than was burned in shares. For example, if deliver()
is applied to half of the total supply of tokens, then the Rate will drop two times, and the balance of the Pair will only double, which means that the skim()
call will give only half of this. This is not a larger share than what was initially burned.
Let's look at an example:
The attacker has 80% of the tokens. 10% is in the Pair, and another 10% is held by other users (possibly controlled by the attacker).
Burning 80% of the tokens will reduce the Rate of the token by a factor of five. The pair will have 50%, and the other 50% are held by other users.
The attacker can extract four-fifths of the balance from the Pair, or 40%.
The final distribution of tokens is 40% for the attacker, 10% for the Pair, and 50% for other users.
There is no benefit in repeating the scenario because the attacker's position has worsened.
Now let’s take a closer look at the attack with figures:
At the start of the attack:
a. rTotal
is 66700 (the actual figure was 66784287767507956726405749082784862500447315773962267311622542245534674271656, we will cut all reflection balances for simplicity).
b. The attacker has 156 million tokens (30500 reflections).
c. The Pair has 100 million tokens (19500 reflections).
d. And 21700 reflections were excludedFromReward
.
e. The Rate = (rTotal - rExcludedFromReward) / tSupply = 66700 - 21700) / tSupply = 195.5
.
After the first deliver()
of 156 million tokens (30500 reflections):
a. The attacker has 0 tokens (0 reflections).
b. The Pair has 313 million tokens (same 19500 reflections).
c. rTotal
is now 66700 - 30500 = 36200
.
d. The Rate becomes Rate = (36200 - 21700) / tSupply = 62.6
, that is 195.5 / 62.6 = 3.12
times lower than initially. This is suspicious.
The attacker skimmed 213 million from Pair (some were burned during the transfer).
a. The attacker has 205 million tokens (13000 reflections).
b. The Pair has 100 million tokens again (6500 reflections).
c. rTotal
and Rate
have lowered due to fees.
The attacker calls deliver()
for the second time and burns 205 million tokens (13000 reflections).
a. rTotal = 36200 - 13000 = 23200
b. Rate = (23200 - 21700) / tSupply = 4.83
. That is 62.6 / 4.83 = 13
times less. Suspicious again.
c. The pair balance becomes 1.3 billion tokens with a small Rate (still 6500 reflections)
Now the attacker can skim again and swap tokens back for WBNB.
It’s obvious now that in our case the Rate declines much faster than expected. Why does this happen?
Despite the fact that rTotal
was initially 66700 according to the FETA token’s logic, in reality there were 73300 reflections circulating. With extra reflections at the attacker’s disposal, they can practically burn “all” the supply but still keep a lot. Or burn 99% of rTotal
and get x100 to 6600 (10%) unaccounted “shadow” reflections. That is exactly what happened.
rTotal
state field is updated each time reflections are burned, for example in the function deliver()
we get:
function deliver(uint256 tAmount) public { address sender = _msgSender(); require(!_isExcluded[sender], "Excluded addresses cannot call this function"); (uint256 rAmount,,,,,,) = _getValues(tAmount); _rOwned[sender] = _rOwned[sender].sub(rAmount); _rTotal = _rTotal.sub(rAmount); _tFeeTotal = _tFeeTotal.add(tAmount); }
In the standard SafeMoon clone is the function _reflectFee()
. It adjusts rTotal
according to the amount that was burnt by transfer()
.
However, in our modified FETA token code the authors have split the fee into rFee
, rBurn
, rCharity
and forgot to adjust rTotal
with rCharity. Below is the code with our comments:
function _transferBothExcluded(address sender, address recipient, uint256 tAmount) private { uint256 currentRate = _getRate(); (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tBurn, uint256 tCharity) = _getValues(tAmount); uint256 rBurn = tBurn.mul(currentRate); uint256 rCharity = tCharity.mul(currentRate); // sub 100% of rAmount from sender, add 91% to receiver _bothTransferContent(sender, recipient, tAmount, rAmount, tTransferAmount, rTransferAmount); // add 3% to _rOwned[charity] _sendToCharity(tCharity, sender); // sub 9% of rAmount from rTotal _reflectFee(rFee, rBurn, rCharity, tFee, tBurn, tCharity); emit Transfer(sender, recipient, tTransferAmount); }
In _sendToCharity()
, 3% of transferred reflections (rAmount
) are added to the charity account (_rOwned[charity]
), but _reflectFee()
still “burns” 9% (all three fees):
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity);
Via this bug, ~10% of extra reflections appeared in the system which were not accounted for by rTotal
. This is what finally led to the exploit execution.
In order to find similar contracts in time, draw conclusions, and make recommendations for future projects, it is important to fully understand the causes and circumstances of this attack.
Adding a Pair to the excludedFromReward
list closes the attack vector and should always be done.
However, as we have shown, the "standard" clones of the SafeMoon project are not affected by the attack. The specific bug in rTotal
management was uncovered in FETA/BEVO code modification.
At CertiK we strongly recommend that all projects undergo a thorough code audit, even if the code is mostly forked from an audited project and changes appear (on the surface) to be minimal.