Reflecting on Reflection Tokens: A Security Perspective

In this blog, our primary focus is on sharing security-related insights from our research on the reflection token mechanism.

Reflecting on Reflection Tokens: A Security Perspective

Updated on June 14, 2024: A community member carefully examined this blog and provided information about the ADU incident, which is a new form not covered in our previous categorization. Thanks, and all insightful feedback is welcome!

To enhance market stability, reflection tokens (aka reward tokens) are crafted to offer investors an additional avenue for earning income. This encourages investors to hold their tokens instead of trading them. During the infamous 2021 meme coin season, reflection tokens became an indispensable mechanism, rapidly capturing market attention after being launched on platforms like DxSale (e.g., SafeMoon V1).

Despite the frenzy diminishing and the market cooling in 2023, our system detected dozens of thousands of hack incidents exploiting such token mechanisms in the wild. These reaper-style attacks, while relatively small scale in amount compared with other types of DeFi attacks, resulted in non-negligible losses to user assets.

In this blog, our primary focus is on sharing security-related insights from our research. Specifically, we will first provide a brief introduction to the mechanism of the reflection token. Following that, we will review security incidents related to reflection tokens, with a focus on those that exploit the reflection token mechanism. Then, we will discuss a potential security issue theoretically. Finally, we will share some thoughts about mitigation and solutions.

0x1 Mechanism of the Reflection Token

To our knowledge, this mechanism was first introduced by Reflect Finance, designed to distribute a percentage of the transaction amount as fees to all token holders in a non-transactional manner. In March 2021, the renowned SafeMoon V1 was released on the BNB chain, which made the reflection token further popularizing.

0x1.1 Basic Concepts

Before diving into the details, some fundamental concepts should be introduced to achieve better understanding.

There are two kinds of spaces: r-space and t-space, read as reflected space and true space, respectively. The crypto currencies of two spaces have exchange rates based on the relative circulation volume. Besides, the currency in r-space is deflationary, that is, for every transaction a certain percentage will be burned and as a result, the burned amount is deducted from the circulation volume.

Consider that Alice, Bob and Eve are all able to make transactions in both spaces, as shown in the below figure. If Alice and Bob conduct pairwise transfers in t-space, Eve will not receive any rewards. However, if all three first convert their tokens to r-space and then let Alice and Bob transfer to each other, Eve will eventually gain passive income by converting her tokens back to t-space. That is the basic idea of the reflection token mechanism.

Note that not all accounts, such as the token's liquidity-providing pool, are capable of trading in r-space, i.e., certain accounts need to be excluded from r-space.

0x1.2 Contract-Level Explanation

Now let's delve into the REFLECT contract of Reflect Finance to explore this mechanism.

This contract first defines several variables for account management:

mapping (address => uint256) private _rOwned; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space

Then, it defines the essential constants of the contract. It can be observed that _rTotal is set to a certain multiple of _tTotal (i.e., the token's totalSupply, which is used as the return value of the totalSupply function):

uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));

From the perspective of functionality and user interaction, functions of this contract fall into the following three categories: balance inquiry and token transfer, alongside a distinctive reflect function. The former two are compatiable with ERC-20 standard, however, the internal logic vary from other ERC-20 tokens. Each of these functions will be elaborated upon below.

0x1.2.1 Functions for balance inquiry

The balance calculation differs for excluded and non-excluded users:

function balanceOf(address account) public view override returns (uint256) {
    if (_isExcluded[account]) return _tOwned[account];
    return tokenFromReflection(_rOwned[account]);
}

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

It can be expressed by the following formula:

The rate in the formula above is calculated by invoking the _getRate function, which is actually calculated from the return value of the _getCurrentSupply function within the contract.

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

It is not difficult to derive the corresponding formula from the above code snippet:

0x1.2.2 Functions for token transfer

In general, there are four secnearios to transfer assets, as follows:

function _transfer(address sender, address recipient, uint256 amount) private {
    require(sender != address(0), "ERC20: transfer from the zero address");
    require(recipient != address(0), "ERC20: transfer to the zero address");
    require(amount > 0, "Transfer amount must be greater than zero");
    if (_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferFromExcluded(sender, recipient, amount); // t-space -> r-space
    } else if (!_isExcluded[sender] && _isExcluded[recipient]) {
        _transferToExcluded(sender, recipient, amount); // r-space -> t-space
    } else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    } else if (_isExcluded[sender] && _isExcluded[recipient]) {
        _transferBothExcluded(sender, recipient, amount); // t-space -> t-space
    } else {
        _transferStandard(sender, recipient, amount); // r-space -> r-space
    }
}

For excluded accounts, both _rOwned and _tOwned should be added or subtracted from the respective space. For non-excluded accounts, only _rOwned needs to be considered. For example, the below code snippet shows the implementation of transfering assets from r-space to t-space, where sender is a non-exlcued account and recipient is an excluded account.

function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);           
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}
  • The _getValues function calculates the corresponding amount, transferAmount, and fee (i.e., amount = transferAmount + fee) for both spaces.

    function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) {
        (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount);
        uint256 currentRate =  _getRate();
        (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
        return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee);
    }
    
  • The _reflectFee function reflects the fee in r-space. Specifically, _rTotal is reduced by the fee in r-space (i.e., rFee), which in turn lowers the rate. According to the balanceOf(user) calculation formula, fees are reflected to all the non-excluded token holders in this manner.

    function _reflectFee(uint256 rFee, uint256 tFee) private {
        _rTotal = _rTotal.sub(rFee);
        _tFeeTotal = _tFeeTotal.add(tFee);
    }
    

0x1.2.3 The `reflect` function

In addition to the passive triggering of the reflection token mechanism during the transfer process, users can actively invoke the reflect function to initiate this mechanism. Specifically, if one consumes the tokens they hold to invoke this function, the rate will decrease as rSupply decreases, thereby providing benefits to other token holders. In other words, sacrificing oneself for the sake of others. By doing so, the project maintainers can incentivize token holders through the utilization of this function.

function reflect(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);
}

The codes provided above form the core of the mechanism. Different tokens may incorporate specific additional functions to tailor their implementation. For example, some may use transaction fees to power a "swap and liquify" function to prevent stampede when whales decide to sell their tokens.

0x2 Post-Mortem of Rekt Reflection Tokens

As mentioned earlier, our main focus is on attacks that exploit the reflection token mechanism. Therefore, incidents unrelated to this mechanism, such as the SafeMoon V2 attack (a common ERC20 public burn issue) and the recent ZongZi attack (related to an old-school price manipulation exploiting spot price), are not covered.

We have conducted an in-depth analysis of these attacks to demystify their root causes. You can refer to here for a list of all these incidents. We found that most of them are normal security incidents caused by either code vulnerabilities or improper administrative operations. However, a few are quite suspicious (e.g., the presence of a backdoor), which we refer to as abnormal security incidents. In the following subsections, we will first introduce the normal security incidents and then go through the abnormal ones in detail.

0x2.1 Normal security incidents

Our investiation suggests that these incidents stem from two types of issues, i.e., code-level issues and operation-level issues, either individually or in combination.

  1. Code-level issues. This arises from the crappy implementation of the contract, likely due to developers not fully understanding the mechanism of the reflection token, leading to an inconsistency between the actual supply of tokens and the recorded value of totalSupply, which can be used to manipuate the rate:

    • 1.1 Zero-cost burn

    • 1.2 Extra deduction from rSupply during token transfers

    • 1.3 Confusion between r-space and t-space values (with precision loss to make profits)

  2. Operation-level issues. This results from improper operation by administrators. Specifically, in these incidents, this refers to the improper configuration of the AMM pair addresses, which are not properly excluded.

It is worth noting that ALL the listed code-level issues could lead to inconsistencies between the actual supply and totalSupply, making the contracts vulnerable. However, this does not necessarily mean that these vulnerabilities are exploitable or, more precisely, profitable enough to be worth exploiting, as there might be a cost for the attacker to manipulate the rate. For simplicity, in the following, we will use the term "exploitable" to mean "profitable enough to be worth exploiting". As a result, an operation-level issue in some scenarios is necessary to make these vulnerabilities exploitable.

Specifically, issue 1.1 can be directly exploited, whereas issues 1.2 and 1.3 require a combination with issue 2 to become exploitable. Therefore, the incidents under discussion can be divided into two types, Type-I and Type-II, based on these observations. Below is a table outlining the relevant data:

Type Incident(s) Root Cause(s) # (%)
I CATOSHI Code-level issue only (1.1) 1 (0.79%)
II (a) BEVO, FETA, ADU Combination of both (1.2 & 2) 3 (2.38%)
II (b) SHEEP, and 120+ Others Combination of both (1.3 & 2) 122 (96.83%)

The table reveals that Type-II incidents account for a significant proportion. Specifically, there are 2 variation within Type-II: Type-II-a (i.e., issue 1.2 with issue 2) and Type-II-b (i.e., issue 1.3 with issue 2). Furthermore, for SHEEP-like incidents of category Type-II-b, the exploits suggests that attackers (e.g., this one) might be using automated methods to identify similarly vulnerable contracts. Specific details will be explored in the subsequent subsections.

0x2.1.1 Type-I: Code-level Issue Only (Issue 1.1)

Only one incident belongs to Type-I, i.e., the CATOSHI incident, a zero-cost burn affecting the total supply.

Let’s first take a look at the burnOf function in the CATOSHI contract:

function burnOf(uint256 tAmount) public {
    uint256 currentRate = _getRate();
    uint256 rAmount = tAmount.mul(currentRate);
    _tTotal = _tTotal.sub(tAmount);
    _rTotal = _rTotal.sub(rAmount);
    emit Transfer(_msgSender(), address(0), tAmount);
}

Obviously, the amount burned by this function is not deducted from the caller (i.e., msg.sender). However, _rOwned[msg.sender] should be reduced by rAmount, and if the account is excluded, _tOwned[msg.sender] should also be reduced by tAmount.

Due to this oversight, attackers can initially burn a large amount of tokens at zero cost and then invoke the contract's reflect function. Since both _tTotal and _rTotal have been significantly reduced proportionally:

The rate can be easily manipulated downward via invoking the reflect function, causing the balanceOf(attacker) to increase substantially. This allows attackers to profit from the inflated balance.

Why? Note that the attacker's new balance is calculated as follows:

The ratio between balanceOf(attacker) and balanceOf(attacker)' is:

As

Hence

Which means the attacker harvests more tokens can be swapped for valuable tokens (WETH in this case) to make profits.

0x2.1.2 Type-II-a: Combination of Issue 1.2 and Issue 2

Type-II-a incidents involve the combination of two issues:

  • Issue 1.2: Extra deduction from rSupply during token transfers.
  • Issue 2: AMM pair is not excluded.

In Type-II-a, there are three attack incidents, which can be further divided into two sub-categories according to the vulnerability forms in issue 1.2, as follows:

1. Extra reflect in the _reflectFee function

Two incidents belong to this sub-category, i.e., the BEVO incident and the FETA incident. In the following, we will use the BEVO contract for illustration.

As introduced in 'Functions for Token Transfer' (section 0x1.2.2), every token transfer triggers the reflection of a portion of the transaction fee. In BEVO, fees are divided into two additional parts: burn and charity, apart from the original one.

function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
    _rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rChairty is deducted from _rTotal
    _tFeeTotal = _tFeeTotal.add(tFee);
    _tBurnTotal = _tBurnTotal.add(tBurn);
    _tCharityTotal = _tCharityTotal.add(tCharity);
    _tTotal = _tTotal.sub(tBurn);
}

Note that the charity account is excluded, which means the amount sent to this account is burnt, as shown in the _sendToCharity function.

function _sendToCharity(uint256 tCharity, address sender) private {
    uint256 currentRate = _getRate();
    uint256 rCharity = tCharity.mul(currentRate);
    address currentCharity = _charity[0];
    _rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
    _tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // since charity account is excluded, the charity part is burned
    emit Transfer(sender, currentCharity, tCharity);
}

From the above code snippet, we can see that there are two places to reflect and burn the charity portion, causing the actual token supply to become inconsistent with the totalSupply during transfers. As more tokens are transferred, the value of rSupply will be less than the supply of tokens in the pool due to the additional decrease.

The purely theoretical description may be a bit abstract, so let's use an example to clarify the process. Suppose Alice wants to transfer 10 tokens to Bob, and 3 tokens are deducted as follows: 1 for the fee, 1 for the burn, and 1 for charity. Since the charity portion is both reflected and burned, the actual breakdown is 2 tokens reflected (1 fee + 1 charity) and 2 tokens burned (1 burn + 1 charity). Together with the remaining 7 tokens to be transferred to Bob, a total of 11 tokens are involved in this process, which is faulty.

But why can this inconsistency be exploited to make profits? Below, we will wave our mathematical magic wand to derive the consequences.

Suppose we have previously acquired some tokens from the pool (i.e., PancakeSwap pair), denoted as rAmount in r-space and tAmount in t-space. Since the pool has not been excluded, let's denote _rOwned[pair] as rReserve, with the corresponding value in t-space also denoted as tReserve. Then we have:

Due to the additional decrease, rSupply is now less than the supply of tokens in the pool:

Recall the 'Functions for Balance Inquiry' section (0x1.2.1), the current rate can be calculated using the following formula:

At this time, if we reflect the tokens we hold through the reflect function (which is renamed as the deliver function in this contract), the rate becomes rate':

As

Then we have

Combining formulas 1, 3 and 6, we can derive the following inequality:

This means that the number of tokens we can harvest directly from the pool (via the skim function) is even greater than what we have delivered, making it profitable because the cost of invoking the reflect function can be covered. After that, the attacker can swap the harvested tokens for valuable tokens (WBNB in this case) to make a profit.

Note that the BEVO contract is also vulnerable to issue 1.3, which has not been exploited in the attack.

2. Incorrect rTransferAmount calculation in the _getRValues function

Only one incident belongs to this form, i.e., the ADU incident. Let's first take a look at the below code snippet.

function _transferStandard(address sender, address recipient, uint256 tAmount) private {
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
    _rOwned[sender] = _rOwned[sender].sub(rAmount);
    _rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
    _takeTeam(tTeam);
    _reflectFee(rFee, tFee);
    emit Transfer(sender, recipient, tTransferAmount);
}

function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
    (uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
    uint256 currentRate =  _getRate();
    (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
    return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}

function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
    uint256 tFee = tAmount.mul(taxFee).div(100);
    uint256 tTeam = tAmount.mul(teamFee).div(100);
    uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam is deducted from tAmount
    return (tTransferAmount, tFee, tTeam);
}

function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
    uint256 rAmount = tAmount.mul(currentRate);
    uint256 rFee = tFee.mul(currentRate);
    uint256 rTransferAmount = rAmount.sub(rFee); // However, there is no rTeam deducted from rAmount
    return (rAmount, rTransferAmount, rFee);
}

We can see that both the tax fee and the team fee should be deducted during transfers. However, in the _getTvalues function, the tTransferAmount is subtracted by both tFee and tTeam, while in the _getRValues function, only rFee is subtracted. This discrepancy leads to the inconsistency problem mentioned earlier, which worsens as more token transfers occur.

Since the pair is also not excluded in the token, this token is exploitable. Specifically, an attacker could use similar BEVO exploits to harvest more ADU tokens via the pair's skim function after calling the deliver function.

However, given the on-chain status at that time, it is impossible for the attacker to swap the harvested ADU tokens for WBNB to make a profit (due to the require statement in the tokenFromReflection function). Therefore, the attacker would need to employ a more complex exploitation strategy to profit, which will not be detailed here.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

0x2.1.3 Type-II-b: Combination of Issue 1.3 and Issue 2

Type-II-b incidents involve the combination of two issues:

  • Issue 1.3: Confusion between r-space and t-space values (noting that precision loss must also be present to make profits).
  • Issue 2: AMM pair is not excluded.

Issue 1.3 stems from the mishandling of values between r-space and t-space during the implementation of the internal _burn function.

function burn(uint256 _value) public {
    _burn(msg.sender, _value);
}

function _burn(address _who, uint256 _value) internal {
    require(_value <= _rOwned[_who]);
    _rOwned[_who] = _rOwned[_who].sub(_value); // _rOwned should be minus a r-space value
    _tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
    // For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
    emit Transfer(_who, address(0), _value);
}

Considering the essential contants of the contract, the r-space value is typically a large multiple of the t-space value. Therefore, invoking the burn function with a _value that is of the same order of magnitude as tSupply will significantly inflate the rate.

However, unlike the Type-I cases, the caller cannot burn tokens while keeping their own balance unchanged. In other words, it is difficult, if not impossible, for the attacker to harvest more tokens. Hence, how could the Type-II-b cases be exploitable?

Taking the SHEEP token incident as an example. The value of the SHEEP tokens held by the attacker can be denoted as:

Where the price of SHEEP can be expressed by the spot price in PancakeSwap pair, calculated as:

Then the Value can be further expressed as:

The attacker then repeatedly executes the burn function and eventually synchronizes the pair. Since neither the attacker nor the pair are excluded, their balances drop due to the rate inflation we mentioned above. Thus the ratio adjusts to:

Based on the previous definitions, we can further express these ratios as:

Where X represents the sum of burned _value.

Here comes the magic: the latter ratio is plainly smaller than former if we simplify 8 and 9 further, which leaves us wondering because the profit will be a nagative value in this case:

In fact, the attacker took advantage of a precision loss issue in the reflection token. For non-excluded users, as per the formula we've provided, the balance calculation is actually rounded down in the tokenFromReflection function. Thus the return value of the balanceOf query may be smaller than its theoretical value. That is to say, the ratio' may be greater than the ratio if we take this issue into consideration.

function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
    require(rAmount <= _rTotal, "Amount must be less than total reflections");
    uint256 currentRate =  _getRate();
    return rAmount.div(currentRate);
}

By debugging the attack transaction, we can calculate the theoretical balance of the attacker and the pair before and after those manipulations. The results of our calculations are outlined in the table below:

Δ in the table is an extremely small value, far less than 1.

Through analyzing the trace within the pair's sync function, we can calculate that the theoretical balances of the attacker and the pair are actually 27.523 and 2.972, respectively, resulting in a ratio of 9.26. However, due to precision loss, the balances are rounded down to 27 and 2, respectively, inflating the ratio to 13.50. As a result, the Profit becomes a positive value.

Finally, the attacker can profit by performing a reversed swap.

0x2.2 Abnormal security incidents

In this subsection, we will share our findings from the investigation of the FDP and DBALL tokens. Our analysis indicates that the managers of both FDP and DBALL tokens invoked problematic privileged functions, effectively acting as backdoors, which put the projects at risk and ultimately led to attacks. Specifically, in the DBALL project, we identified a series of suspicious transactions by the token owner, which provide clear evidence to consider it a rug pull.

The exploitations targeting these two tokens closely resemble those discussed in the 'Type-II-a: Combination of Issue 1.2 and Issue 2' section described under 0x2.1.2. However, when analyzing the reasons why the actual token supply may diverge from totalSupply, some suspicous activities come to light.

0x2.2.1 The FDP incident

The discrepancy between the actual token supply and totalSupply in the FDP case stems from the invocation of the transferOwnership function, a privileged function that can only be invoked by the contract owner. As the name suggests, this function is supposed to alter the ownership of the contract. However, in the FDP contract, this function has nothing to do with ownership transfer. Instead, it increases _rOwned[newOwner] without altering totalSupply. This clearly violates the design principles of the normal token minting process.

function transferOwnership(address newOwner) public virtual onlyOwner {
    (, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
    _rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);       
    emit Transfer(address(0), newOwner, tTransferAmount);
}

Transactions that called this function are summarized in the following table:

0x2.2.2 The DBALL incident

Things get tricky for the DBALL case. Using MetaSleuth to analyze the fund flow of the DBALL owner, we observed an imbalance in the inflow and outflow of DBALL tokens to this address, with the source of funds for this transaction not being recorded.

By querying the historical states on chain, we finally identified that the DBALL balance of the owner changed before and after this transaction. We can observe that the owner called the privileged manualDevBurn function to burn 1 token in the t-space. The implementation of this function is as follows:

function manualDevBurn (uint256 tAmount) public onlyOwner() {  
    uint256 rAmount = tAmount.mul(_getRate());
    if (_isExcluded[_msgSender()]) {
        _tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
    }
    _rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
    
    _tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
    _rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
    
    emit Transfer(_msgSender(), address(0), tAmount);
}

At first glance, everything seems fine. However, due to the contract specifying a compiler version lower than 0.8, an arithmetic underflow occurs during the subtraction of _rOwned[_msgSender()], transitioning from 0 to nearly type(uint256).max. This subtle manipulation allows the owner to alter their balance, but it also results in inconsistency in the token supply.

Is it just an accidental mistake? Our investigation suggests it is more likely an intentional rug pull. The reasons are summarized as follows:

  1. The owner passed only 1 token into the manualDevBurn function, yet within half an hour, an amount of DBALL equal to the total supply was transferred to an associated address through this transaction.

  2. That associated address immediately swapped in the PancakeSwap pair and get approximately 56 WBNB.

  1. Analyzing the fund flows of these two addresses reveals that both eventually transferred BNB through Tornado.Cash.

0x3 A Potential Issue in Calculating the rate

Beyond the incidents we have discussed earlier, we also found that, theoretically, there exists a potential issue in the balance calculation of non-excluded users that deserves further discussion. This issue may arise during the calculation of the rate.

Let's take a look at the _getCurrentSupply function. In this function, the tail if statement determines whether rSupply is less than the initial rate (i.e., _rTotal / _tTotal).

function _getRate() private view returns(uint256) {
    (uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
    return rSupply.div(tSupply);
}

function _getCurrentSupply() private view returns(uint256, uint256) {
    uint256 rSupply = _rTotal;
    uint256 tSupply = _tTotal;      
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
        rSupply = rSupply.sub(_rOwned[_excluded[i]]);
        tSupply = tSupply.sub(_tOwned[_excluded[i]]);
    }
    if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
    return (rSupply, tSupply);
}

During the lifecycle from contract deployment to project launch, if this statement is true, the balances of all non-excluded users would be zero. However, once the project was launched and transactions began, the original intention of the if statement was lost.

Since rSupply will decrease due to the reflection token mechanism, the rate will decrease accordingly. If, after a certain transaction, rSupply falls below the initial rate, the current rate will jump, resulting in balance losses for all non-excluded users. Additionally, it's theoretically possible that

This causes the rate to become zero due to precision loss, potentially triggering a divide-by-zero panic.

0x4 Mitigation and Solutions

The reflection token mechanism offers a way to enhance market stability by incentivizing investors to hold their tokens instead of trading to receive additional rewards. However, it also introduces new security challenges and potential risks, such as confusion between r-space and t-space values. Therefore, it is crucial for blockchain developers and investors to gain a better understanding of the mechanism and its potential risks and seek solutions.

BlockSec provides security services and products for both pre- and post-launch stages. Our security auditing services conduct thorough reviews to ensure code security and transparency. Our Phalcon product offers continuous security monitoring and attack detection capabilities, enabling operators and investors to monitor projects and take automatic actions when security risks are detected.

Related Reading


About BlockSec

BlockSec is a full-stack Web3 security service provider. The company is committed to enhancing security and usability for the emerging Web3 world in order to facilitate its mass adoption. To this end, BlockSec provides smart contract and EVM chain security auditing services, the Phalcon platform for security development and blocking threats proactively, the MetaSleuth platform for fund tracking and investigation, and MetaSuites extension for web3 builders surfing efficiently in the crypto world.

To date, the company has served over 300 clients such as Uniswap Foundation, Compound, Forta, and PancakeSwap, and received tens of millions of US dollars in two rounds of financing from preeminent investors, including Matrix Partners, Vitalbridge Capital, and Fenbushi Capital.

Sign up for the latest updates