返回部落格

反思反射代幣:安全視角

Phalcon
2024年6月6日
閱讀約 26 分鐘

2024年6月14日更新:一位社群成員仔細審視了這篇部落格,並提供了關於ADU事件的資訊,這是一種我們先前分類中未涵蓋的新形式。謝謝,歡迎大家提出任何有見地的回饋!

為了增強市場穩定性,反射代幣(又稱獎勵代幣)被設計成為投資者提供額外的收益途徑。這鼓勵投資者持有代幣而非交易它們。在著名的2021年meme幣季節期間,反射代幣成為不可或缺的機制,在諸如DxSale(例如SafeMoon V1)等平台上推出後迅速吸引了市場的關注。

儘管熱潮已消退,市場在2023年趨於冷靜,但我們的系統仍偵測到成千上萬起利用此類代幣機制的攻擊事件在野外發生。這些收割式攻擊,雖然相對於其他類型的DeFi攻擊而言金額規模較小,但仍對用戶資產造成了不可忽視的損失。

在這篇部落格中,我們的主要重點是分享我們研究中與安全相關的見解。具體而言,我們將首先簡要介紹反射代幣的機制。接下來,我們將回顧與反射代幣相關的安全事件,重點關注那些利用反射代幣機制的事件。然後,我們將從理論上討論一個潛在的安全問題。最後,我們將分享一些關於緩解措施與解決方案的思考。

0x1 反射代幣的機制

據我們所知,此機制最早由Reflect Finance引入,旨在以非交易的方式將交易金額的一定百分比作為手續費分配給所有代幣持有者。2021年3月,著名的SafeMoon V1在BNB鏈上發布,這使得反射代幣進一步普及。

0x1.1 基本概念

在深入細節之前,應先介紹一些基本概念,以便更好地理解。

有兩種空間:r空間和t空間,分別讀作反射空間(reflected space)與真實空間(true space)。這兩個空間的加密貨幣之間的匯率是基於相對流通量計算的。此外,r空間中的貨幣是通縮性的,也就是說,每筆交易都會燒毀一定比例,因此燒毀的數量會從流通量中扣除。

假設Alice、Bob和Eve都能夠在兩個空間中進行交易,如下圖所示。如果Alice和Bob在t空間中進行相互轉帳,Eve將不會獲得任何獎勵。然而,如果三人先將代幣轉換到r空間,然後讓Alice和Bob互相轉帳,Eve最終將透過把她的代幣轉換回t空間而獲得被動收入。這就是反射代幣機制的基本理念。

請注意,並非所有帳戶都能在r空間中進行交易,例如代幣的流動性提供池,即某些帳戶需要被排除在r空間之外。

0x1.2 合約層級的解釋

現在讓我們深入研究Reflect Finance的REFLECT合約,來探討此機制。

此合約首先定義了幾個用於帳戶管理的變數:

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

接下來,定義了合約的基本常數。可以觀察到_rTotal被設定為_tTotal(即代幣的totalSupply,被用作totalSupply函式的回傳值)的某個倍數:

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

從功能和使用者互動的角度來看,此合約的函式分為以下三類:餘額查詢、代幣轉帳,以及一個獨特的reflect函式。前兩者與ERC-20標準相容,然而其內部邏輯與其他ERC-20代幣有所不同。以下將詳細闡述每個函式。

0x1.2.1 餘額查詢函式

被排除和非被排除用戶的餘額計算方式不同:

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);
}

可以用下面的公式表示:

上述公式中的rate是透過呼叫_getRate函式計算的,該函式實際上是根據合約中_getCurrentSupply函式的回傳值來計算的。

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);
}

從上面的程式碼片段中不難推導出相對應的公式:

0x1.2.2 代幣轉帳函式

一般來說,資產轉帳有四種情境,如下所示:

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
    }
}

對於被排除的帳戶,_rOwned和_tOwned都應在各自的空間中進行增減。對於非被排除的帳戶,只需考慮_rOwned即可。例如,下面的程式碼片段展示了將資產從r空間轉移到t空間的實現方式,其中sender是非被排除的帳戶,recipient是被排除的帳戶。

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);
}
  • _getValues函式計算兩個空間對應的amount、transferAmount和fee(即amount = transferAmount + fee)。

    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);
    }
  • _reflectFee函式在r空間中反射手續費。具體而言,_rTotal會減少r空間中的手續費(即rFee),這反過來會降低rate。根據*balanceOf(user)*的計算公式,手續費以這種方式反射給所有非被排除的代幣持有者。

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

0x1.2.3 reflect函式

除了在轉帳過程中被動觸發反射代幣機制之外,用戶還可以主動呼叫reflect函式來啟動此機制。具體而言,如果有人消耗自己持有的代幣來呼叫此函式,隨著rSupply的減少,rate將會下降,從而使其他代幣持有者受益。換言之,這是一種「捨己利人」的行為。透過這種方式,專案維護者可以利用此函式激勵代幣持有者。

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);
}

上面提供的程式碼構成了此機制的核心。不同的代幣可能會加入特定的附加功能來調整其實現方式。例如,有些代幣可能會利用交易手續費來驅動一個「swap and liquify」功能,以防止當巨鯨決定拋售其代幣時發生踩踏。

0x2 慘遭破解的反射代幣事後分析

如前所述,我們的主要重點在於利用反射代幣機制的攻擊。因此,與此機制無關的事件,例如SafeMoon V2攻擊(常見的ERC20公開燃燒問題)以及最近的ZongZi攻擊(與利用現貨價格的老式價格操縱攻擊有關),本文將不予討論。

我們對這些攻擊進行了深入分析,以揪出其根本原因。你可以參考這裡,查看所有這些事件的列表。我們發現,其中大多數是由程式碼漏洞或不當的管理操作所導致的正常安全事件。然而,少數則相當可疑(例如存在後門),我們將其稱為異常安全事件。在以下小節中,我們將先介紹正常的安全事件,然後詳細探討異常事件。

0x2.1 正常安全事件

我們的調查顯示,這些事件源於兩種類型的問題,即程式碼層級問題和操作層級問題,可能單獨出現,也可能同時出現。

  1. 程式碼層級問題。這源於合約的低劣實現,可能是由於開發人員未能完全理解反射代幣的機制,導致代幣的實際供應量與totalSupply記錄值之間的不一致,這可以被用來操縱rate:

    • 1.1 零成本燃燒

    • 1.2 代幣轉帳期間rSupply的額外扣減

    • 1.3 r空間與t空間數值的混淆(利用精度損失來獲利)

  2. 操作層級問題。這是由管理員的不當操作所導致的。具體來說,在這些事件中,指的是AMM交易對地址配置不當,未被正確排除。

值得注意的是,所有列出的程式碼層級問題都可能導致實際供應量與totalSupply之間的不一致,使合約產生漏洞。然而,這並不必然意味著這些漏洞是可被利用的,或更準確地說,是有足夠利潤可圖以值得被利用的,因為攻擊者操縱rate可能需要付出成本。為簡化說明,以下我們將使用「可被利用」一詞來表示「有足夠利潤可圖以值得利用」。因此,在某些情況下,需要有操作層級的問題,才能使這些漏洞變得可被利用。

具體而言,問題1.1可以被直接利用,而問題1.2和1.3則需要結合問題2才能變得可被利用。因此,根據這些觀察,本文討論的事件可以分為兩種類型,Type-I和Type-II。以下表格列出了相關數據:

類型 事件 根本原因 數量(百分比)
I CATOSHI 僅程式碼層級問題 (1.1) 1 (0.79%)
II (a) BEVO, FETA, ADU 兩者結合 (1.2 & 2) 3 (2.38%)
II (b) SHEEP, 及120+其他 兩者結合 (1.3 & 2) 122 (96.83%)

該表顯示Type-II事件佔了相當大的比例。具體而言,Type-II內部有2種變體:Type-II-a(即問題1.2搭配問題2)和Type-II-b(即問題1.3搭配問題2)。此外,對於Type-II-b類別中類似SHEEP的事件,其漏洞利用手法顯示攻擊者(例如此攻擊者)可能正在使用自動化方法來識別類似存在漏洞的合約。具體細節將在後續小節中探討。

0x2.1.1 Type-I:僅程式碼層級問題(問題1.1)

只有一起事件屬於Type-I,即CATOSHI事件,這是影響總供應量的零成本燃燒。

讓我們先看一下CATOSHI合約中的burnOf函式:

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);
}

顯然,此函式所燃燒的金額並未從呼叫者(即msg.sender)中扣除。然而,_rOwned[msg.sender]應減少rAmount,若該帳戶被排除,則_tOwned[msg.sender]也應減少tAmount。

由於這個疏忽,攻擊者可以先以零成本燃燒大量代幣,然後呼叫合約的reflect函式。由於_tTotal和_rTotal都已按比例大幅減少:

透過呼叫reflect函式,rate可以很容易地被向下操縱,導致*balanceOf(attacker)*大幅增加。這使攻擊者能夠從虛增的餘額中獲利。

為什麼呢?請注意,攻擊者的新餘額計算如下:

*balanceOf(attacker)與balanceOf(attacker)'*之間的比率為:

由於

因此

這意味著攻擊者收割了更多的代幣,可以將其兌換為有價值的代幣(本例中為WETH)來獲利。

0x2.1.2 Type-II-a:問題1.2與問題2的結合

Type-II-a事件涉及兩個問題的結合:

  • 問題1.2:代幣轉帳期間rSupply的額外扣減。
  • 問題2:AMM交易對未被排除。

在Type-II-a中,共有三起攻擊事件,根據問題1.2中漏洞的形式,可進一步細分為兩個子類別,如下所示:

1. _reflectFee函式中的額外反射

此子類別中有兩起事件,即BEVO事件和FETA事件。下面,我們將以BEVO合約為例進行說明。

正如在「代幣轉帳函式」(0x1.2.2節)中所介紹的,每次代幣轉帳都會觸發部分交易手續費的反射。在BEVO中,除了原有的部分外,手續費被分為兩個額外的部分:燃燒(burn)和慈善(charity)。

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);
}

請注意,慈善帳戶是被排除的,這意味著發送到該帳戶的金額實際上被燃燒了,如_sendToCharity函式所示。

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);
}

從上面的程式碼片段可以看出,有兩處對慈善部分進行了反射與燃燒,導致在轉帳過程中實際的代幣供應量與totalSupply變得不一致。隨著更多代幣的轉帳,由於額外的減少,rSupply的值將小於資金池中代幣的供應量。

純粹理論性的描述可能有些抽象,因此讓我們用一個例子來說明這個過程。假設Alice想要轉帳10個代幣給Bob,扣除3個代幣如下:1個作為手續費,1個作為燃燒,1個作為慈善。由於慈善部分同時被反射和燃燒,實際的分解為:反射2個代幣(1手續費+1慈善)、燃燒2個代幣(1燃燒+1慈善)。加上剩餘要轉給Bob的7個代幣,此過程共涉及11個代幣,這是有問題的。

但為什麼這種不一致可以被利用來獲利呢?下面,我們將揮動我們的數學魔杖來推導其結果。

假設我們先前已從資金池(即PancakeSwap交易對)中獲取了一些代幣,在r空間中記作rAmount,在t空間中記作tAmount。由於資金池並未被排除,我們將_rOwned[pair]記作rReserve,其在t空間中對應的值也記作tReserve。則我們有:

由於額外的減少,rSupply現在小於資金池中代幣的供應量:

回顧「餘額查詢函式」一節(0x1.2.1),目前的rate可以使用以下公式計算:

此時,如果我們透過reflect函式(在此合約中被重新命名為deliver函式)反射我們持有的代幣,rate將變為rate':

由於

因此我們有

結合公式1、3和6,我們可以推導出以下不等式:

這意味著我們能直接從資金池中(透過skim函式)收割的代幣數量甚至大於我們所交付的數量,這使得攻擊有利可圖,因為呼叫reflect函式的成本可以被覆蓋。之後,攻擊者可以將收割的代幣兌換為有價值的代幣(本例中為WBNB)來獲利。

值得注意的是,BEVO合約也存在問題1.3的漏洞,但該漏洞在這次攻擊中並未被利用。

2. _getRValues函式中不正確的rTransferAmount計算

此形式僅有一起事件,即ADU事件。讓我們先看看下面的程式碼片段。

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);
}

我們可以看到,在轉帳期間應扣除稅收手續費和團隊手續費兩者。然而,在_getTvalues函式中,tTransferAmount同時扣除了tFee和tTeam,而在_getRValues函式中,只扣除了rFee。這種差異導致了前面提到的不一致問題,並且隨著代幣轉帳次數的增加而愈發嚴重。

由於該代幣的交易對也未被排除,此代幣是可被利用的。具體而言,攻擊者可以在呼叫deliver函式後,透過交易對的skim函式使用類似BEVO的漏洞利用手法收割更多的ADU代幣。

然而,考慮到當時鏈上的狀態,攻擊者不可能將收割的ADU代幣兌換為WBNB來獲利(由於tokenFromReflection函式中的require語句)。因此,攻擊者需要採用更複雜的利用策略才能獲利,這裡就不再詳細討論了。

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:問題1.3與問題2的結合

Type-II-b事件涉及兩個問題的結合:

  • 問題1.3:r空間與t空間數值的混淆(注意,還必須存在精度損失才能獲利)。
  • 問題2:AMM交易對未被排除。

問題1.3源於在內部_burn函式的實現過程中,對r空間與t空間數值的不當處理。

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);
}

考慮到合約的基本常數,r空間的值通常是t空間值的一個很大的倍數。因此,以與tSupply相同量級的_value呼叫burn函式,將會大幅膨脹rate。

然而,與Type-I的情況不同,呼叫者無法在保持自己餘額不變的情況下燃燒代幣。換言之,攻擊者很難(即便不是不可能)收割更多的代幣。那麼,Type-II-b的情況究竟如何被利用呢?

以SHEEP代幣事件為例。攻擊者持有的SHEEP代幣的價值可以表示為:

其中SHEEP的價格可以用PancakeSwap交易對中的現貨價格表示,計算方式為:

那麼Value可以進一步表示為:

攻擊者接著重複執行burn函式,並最終同步交易對。由於攻擊者和交易對都未被排除,他們的餘額因我們上面提到的rate膨脹而下降。因此比率調整為:

根據之前的定義,我們可以進一步將這些比率表示為:

其中X代表已燃燒_value的總和。

魔法時刻來了:如果我們進一步簡化公式8和9,可以明顯看出後者的比率小於前者,這讓我們感到困惑,因為在這種情況下利潤將會是負值:

實際上,攻擊者利用了反射代幣中的精度損失問題。對於非被排除的用戶而言,根據我們提供的公式,餘額計算實際上在tokenFromReflection函式中是向下取整的。因此,balanceOf查詢的回傳值可能小於其理論值。也就是說,如果我們考慮到這個問題,ratio'可能會大於ratio。

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);
}

透過對攻擊交易進行除錯,我們可以計算出攻擊者和交易對在這些操縱前後的理論餘額。我們的計算結果概述於下表:

表中的Δ是一個極小的值,遠小於1。

透過分析交易對sync函式中的trace,我們可以計算出攻擊者和交易對的理論餘額實際上分別為27.523和2.972,得出的比率為9.26。然而,由於精度損失,餘額被向下取整為27和2,將比率膨脹至13.50。結果,Profit變成了正值。

最後,攻擊者可以透過執行反向swap來獲利。

0x2.2 異常安全事件

在本小節中,我們將分享我們對FDP和DBALL代幣調查的發現。我們的分析顯示,FDP和DBALL代幣的管理者都呼叫了有問題的特權函式,實際上這些函式充當後門的角色,使專案面臨風險,並最終導致了攻擊事件。具體而言,在DBALL專案中,我們識別出代幣所有者進行的一系列可疑交易,這些交易提供了明確的證據,可將其視為捲款跑路(rug pull)。

針對這兩個代幣的利用手法,與0x2.1.2節中「Type-II-a:問題1.2與問題2的結合」中討論的手法非常相似。然而,在分析實際代幣供應量可能與totalSupply產生偏差的原因時,一些可疑的活動浮出水面。

0x2.2.1 FDP事件

在FDP案例中,實際代幣供應量與totalSupply之間的差異源於呼叫transferOwnership函式,這是一個只能由合約所有者呼叫的特權函式。顧名思義,此函式應該用於變更合約的所有權。然而,在FDP合約中,此函式與所有權轉移毫無關係。相反,它增加了_rOwned[newOwner],卻不改變totalSupply。這明顯違反了正常代幣鑄造流程的設計原則。

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);
}

呼叫此函式的交易總結於下表:

0x2.2.2 DBALL事件

DBALL的情況變得更加棘手。使用MetaSleuth分析DBALL所有者的資金流向,我們觀察到此地址DBALL代幣的流入與流出並不平衡,此交易的資金來源並未被記錄。

透過查詢鏈上的歷史狀態,我們最終發現所有者的DBALL餘額在此交易前後發生了變化。我們可以觀察到,所有者呼叫了特權函式manualDevBurn,在t空間中燃燒了1個代幣。此函式的實現如下:

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);
}

乍看之下,一切似乎都正常。然而,由於合約指定的編譯器版本低於0.8,在_rOwned[_msgSender()]進行減法運算時發生了算術下溢(arithmetic underflow),該值從0變為接近type(uint256).max。這個微妙的操縱手法使所有者能夠改變他們的餘額,同時也導致了代幣供應量的不一致。

這僅僅是一個意外的失誤嗎?我們的調查顯示,這更有可能是一次蓄意的捲款跑路。原因總結如下:

  1. 所有者僅向manualDevBurn函式傳入了1個代幣,然而在半小時內,透過此交易,等同於總供應量的DBALL數量被轉移到了一個關聯地址。

  2. 該關聯地址立即在PancakeSwap交易對中進行swap,並獲得約56 WBNB。

  1. 分析這兩個地址的資金流向顯示,兩者最終都透過Tornado.Cash轉出了BNB。

0x3 rate計算中的一個潛在問題

除了我們前面討論的事件之外,我們還發現,從理論上講,非被排除用戶的餘額計算中存在一個值得進一步討論的潛在問題。此問題可能出現在rate的計算過程中。

讓我們看看_getCurrentSupply函式。在此函式中,末尾的if語句判斷rSupply是否小於初始rate(即*_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);
}

在合約部署到專案發布的生命週期中,如果此語句成立,所有非被排除用戶的餘額都會是零。然而,一旦專案發布並開始交易,該if語句的原始意圖就已喪失。

由於反射代幣機制會使rSupply減少,rate也會相應地降低。如果在某次交易之後,rSupply降至初始rate以下,目前的rate將會發生跳變,導致所有非被排除用戶的餘額損失。此外,理論上也存在以下情況的可能性:

這會由於精度損失導致rate變為零,可能引發除以零的panic錯誤。

0x4 緩解措施與解決方案

反射代幣機制透過激勵投資者持有代幣而非交易,以獲得額外獎勵,提供了一種增強市場穩定性的方式。然而,它也帶來了新的安全挑戰和潛在風險,例如r空間與t空間數值的混淆。因此,區塊鏈開發人員和投資者必須更深入地了解此機制及其潛在風險,並尋求相應的解決方案。

BlockSec為專案發布前後兩個階段都提供安全服務與產品。我們的安全審計服務進行徹底的審查,以確保程式碼的安全性與透明度。我們的Phalcon產品提供持續的安全監控與攻擊檢測能力,使運營者和投資者能夠監控專案,並在偵測到安全風險時採取自動化措施。

相關閱讀


關於 BlockSec

BlockSec是一家全端Web3安全服務提供商。該公司致力於提升新興Web3世界的安全性與可用性,以促進其大規模採用。為此,BlockSec提供智慧合約與EVM鏈安全審計服務、用於安全開發並主動阻止威脅的Phalcon平台、用於資金追蹤與調查的MetaSleuth平台,以及讓Web3建設者在加密世界中高效衝浪的MetaSuites擴充功能。

迄今為止,該公司已服務超過1,000家客戶,例如Uniswap Foundation、Compound、Forta和PancakeSwap,並從包括Matrix Partners、Vitalbridge Capital和分布式資本(Fenbushi Capital)等傑出投資者那裡,獲得了兩輪共計數千萬美元的融資。