Back to Blog

反思反射代幣:安全視角

Phalcon
June 6, 2024
26 min read

2024 年 6 月 14 日更新:一位社群成員仔細檢查了這篇部落格,並提供了有關 ADU 事件的資訊,這是一種我們之前分類中未涵蓋的新型態。感謝您的貢獻,歡迎所有深刻的見解與回饋!

為了增強市場穩定性,反射代幣(又稱獎勵代幣)旨在為投資者提供額外的收入來源。這鼓勵了投資者持有代幣而非進行交易。在臭名昭著的 2021 年迷因幣(meme coin)浪潮中,反射代幣成為了不可或缺的機制,在 DxSale 等平台推出後迅速吸引了市場目光(例如 SafeMoon V1)。

儘管 2023 年熱潮消退且市場冷卻,我們的系統仍偵測到數十起在現實環境中利用此類代幣機制的駭客事件。這些「收割者」風格的攻擊雖然與其他類型的 DeFi 攻擊相比規模較小,但對使用者資產造成了不容忽視的損失。

在本部落格中,我們主要致力於分享研究中與安全性相關的見解。具體而言,我們首先會簡介反射代幣的機制。隨後,我們將回顧與反射代幣相關的安全事件,重點鎖定那些利用反射代幣機制的攻擊。接著,我們將在理論層面討論潛在的安全問題。最後,我們將分享一些關於緩解方案與解決方法的思考。

0x1 反射代幣的機制

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

0x1.1 基本概念

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

存在兩種空間:r-spacet-space,分別讀作反射空間(reflected space)和真實空間(true space)。這兩個空間的加密貨幣根據相對流通量有匯率換算。此外,r-space 中的貨幣是通縮的,也就是說,每一筆交易中都會燒毀一定百分比,因此燒毀的金額會從流通量中扣除。

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

請注意,並非所有帳戶(例如代幣的流動性提供池)都能在 r-space 中交易,即某些帳戶需要從 r-space 中排除

0x1.2 合約層級解釋

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

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

mapping (address => uint256) private _rOwned; // 使用者持有的反射代幣
mapping (address => uint256) private _tOwned; // 使用者持有的真實代幣
mapping (address => mapping (address => uint256)) private _allowances;

mapping (address => bool) private _isExcluded; // 使用者是否被排除在 r-space 之外
address[] private _excluded; // 被排除在 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-space 轉帳到 t-space 的實作,其中 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-space 中的手續費。具體來說,_rTotal 會減去 r-space 中的手續費(即 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 零成本銷毀(Zero-cost burn)

    • 1.2 代幣轉帳期間從 rSupply 中額外扣除

    • 1.3 r-space 和 t-space 值混淆(伴隨精度損失以獲利)

  2. 操作層級 問題。這是由 管理員操作不當 引起的。具體來說,在這些事件中,這指的是對 AMM 交易對位址配置不當,未正確將它們排除。

值得注意的是,所有 列出的程式碼層級問題都可能導致實際供應量與 totalSupply 的不一致,使合約面臨風險。然而,這並不一定意味著這些漏洞是可以利用的,或者更準確地說,獲利空間足以值得駭客發動攻擊,因為操縱 rate 對攻擊者來說可能存在成本。為了簡單起見,下文中我們將使用「可利用」一詞來表示「獲利空間足以值得發動攻擊」。因此,在某些場景下,操作層級的問題是使這些漏洞變得可利用的必要條件。

具體來說,問題 1.1 可以直接利用,而問題 1.2 和 1.3 需要與問題 2 結合才能變得可利用。因此,討論中的事件可根據這些觀察分為 Type-IType-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 大幅增加。這使得攻擊者可以從膨脹的餘額中獲利。

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

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 從 _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); // 由於慈善帳戶被排除,慈善部分被燒毀
    emit Transfer(sender, currentCharity, tCharity);
}

從上述程式碼片段中,我們可以看到有兩個地方可以反射和燒毀慈善部分,導致轉帳期間的實際代幣供應量與 totalSupply 不一致。隨著代幣轉帳次數增加,由於額外的減少,rSupply 的值將小於池中的代幣供應量。

純理論的描述可能有點抽象,讓我們用一個例子來釐清這個過程。假設 Alice 想轉帳 10 個代幣給 Bob,扣除 3 個代幣作為手續費,細節如下:1 個手續費,1 個燒毀,1 個慈善。由於慈善部分既被反映又被燒毀,實際情況是 2 個代幣被反映(1 手續費 + 1 慈善),2 個代幣被燒毀(1 燒毀 + 1 慈善)。連同剩下的 7 個代幣轉帳給 Bob,此過程中總共涉及 11 個代幣,這是錯誤的。

但這種不一致為什麼能被利用來獲利呢?下面,我們將揮舞數學魔法棒來推導結果。

假設我們之前已經從池中(即 PancakeSwap 交易對)獲取了一些代幣,在 r-space 中記為 rAmount,在 t-space 中記為 tAmount。由於 交易對未被排除,讓我們將 _rOwned[pair] 記為 rReserve,其在 t-space 中的相應值也記為 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 從 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); // 然而,rAmount 並未扣除 rTeam
    return (rAmount, rTransferAmount, rFee);
}

我們可以看到,轉帳時應該同時扣除稅款手續費和團隊手續費。然而,在 _getTvalues 函數中,tTransferAmount 減去了稅款手續費和團隊手續費,而在 _getRValues 函數中,僅減去了 rFee。這種差異導致了前面提到的不一致問題,且隨著代幣轉帳次數增加,問題會愈演愈烈。

由於該交易對也未從代幣中排除,該代幣是可以利用的。具體來說,攻擊者可以採用與 BEVO 類似的攻擊手法,在呼叫 deliver 函數後,透過交易對的 skim 函數收穫更多的 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-space 和 t-space 值混淆(需注意,為了獲利,必須存在精度損失)。
  • 問題 2:AMM 交易對未被排除。

問題 1.3 源於在實現內部 _burn 函數時對 r-space 和 t-space 值處置不當。

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 應減去 r-space 值
    _tTotal = _tTotal.sub(_value); // _tTotal 應減去 t-space 值
    // 對於 burn 函數的語義,_rTotal 也應減去 r-space 值。
    emit Transfer(_who, address(0), _value);
}

考慮到合約的基本常數,r-space 值通常是 t-space 值的巨大倍數。因此,使用與 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。

透過分析交易對同步函數中的 追蹤記錄,我們可以計算出攻擊者和交易對的理論餘額分別為 27.523 和 2.972,比率為 9.26。然而,由於精度損失,餘額被向下取整為 27 和 2,進而將比率推升至 13.50。結果,利潤(Profit) 變為了正值。

最終,攻擊者透過進行反向交換獲利。

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-space 中的 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 的值。這種微妙的操縱不僅讓所有者可以改變自己的餘額,也導致了代幣供應的不一致。

這只是一個意外的錯誤嗎?我們的調查顯示,這更像是蓄意的拉地毯(rug pull)。原因總結如下:

  1. 所有者僅向 manualDevBurn 函數傳遞了 1 個 代幣,然而在半小時內,透過 這筆交易,相當於總供應量的 DBALL 被轉移到了 關聯位址

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

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

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

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

讓我們看看 _getCurrentSupply 函數。在此函數中,尾部的 if 陳述句決定了 rSupply 是否小於初始率(即 _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-space 和 t-space 值之間的混淆。因此,區塊鏈開發人員和投資者深入理解該機制及其潛在風險並尋求解決方案至關重要。

BlockSec 為部署前和部署後的階段提供安全服務與產品。我們的安全審計服務經過詳盡審查,以確保程式碼的安全與透明。我們的 Phalcon 產品提供持續的安全監控和攻擊偵測能力,使營運商和投資者能夠監控專案,並在偵測到安全風險時採取自動化行動。

相關閱讀


關於 BlockSec

BlockSec 是一家全端 Web3 安全服務提供商。公司致力於增強新興 Web3 世界的安全性和可用性,以促進其大規模採用。為此,BlockSec 提供 智慧合約和 EVM 鏈安全審計服務、用於安全開發與主動封鎖威脅的 Phalcon 平台、用於資金追蹤與調查的 MetaSleuth 平台,以及協助 Web3 建構者在加密世界高效衝浪的 MetaSuites 擴充套件。

迄今為止,公司已服務了超過 300 家客戶,如 Uniswap Foundation、Compound、Forta 和 PancakeSwap,並獲得了包括矩陣夥伴(Matrix Partners)、Vitalbridge Capital 和分佈式資本(Fenbushi Capital)在內的卓越投資者兩輪數千萬美元的融資。