更新於 2024 年 6 月 14 日:一位社群成員仔細審閱了此部落格,並提供了關於 ADU 事件 的資訊,這是一種未涵蓋於我們先前分類中的新形式。感謝您,我們歡迎所有富有洞察力的回饋!
為了提升市場穩定性,反射代幣(又稱獎勵代幣)被設計用來為投資者提供額外的收入來源,藉此鼓勵投資者持有代幣而非交易。在惡名昭彰的 2021 年迷因幣季節期間,反射代幣成為不可或缺的機制,在 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; // 使用者持有的反射代幣
mapping (address => uint256) private _tOwned; // 使用者持有的真實代幣
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // 使用者是否從 r 空間中排除
address[] private _excluded; // 從 r 空間中排除的帳戶
接著,它定義了合約的基本常數。可以觀察到,_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));
從功能性和使用者互動的角度來看,此合約的函數可分為以下三類:餘額查詢與代幣轉帳,以及一個獨特的反射函數。前兩者與 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);
}
可以用以下公式表示:

上述公式中的匯率是透過呼叫 _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 空間 -> r 空間
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r 空間 -> t 空間
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r 空間 -> r 空間
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t 空間 -> t 空間
} else {
_transferStandard(sender, recipient, amount); // r 空間 -> r 空間
}
}
對於被排除的帳戶,_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函數計算兩個空間對應的金額、轉帳金額和手續費(即金額 = 轉帳金額 + 手續費)。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),從而降低匯率。根據 balanceOf(user) 的計算公式,手續費就以此方式反射給所有未被排除的代幣持有者。function _reflectFee(uint256 rFee, uint256 tFee) private { _rTotal = _rTotal.sub(rFee); _tFeeTotal = _tFeeTotal.add(tFee); }
0x1.2.3 reflect 函數
除了在轉帳過程中被動觸發反射代幣機制外,使用者也可以主動呼叫 reflect 函數來啟動此機制。具體而言,若某人消耗其持有的代幣來呼叫此函數,隨著 rSupply 減少,匯率也會下降,從而使其他代幣持有者受益。換句話說,就是犧牲自己、造福他人。藉此,項目維護者可以透過使用此函數來激勵代幣持有者。
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);
}
上述程式碼構成了此機制的核心。不同的代幣可能會加入特定的附加函數以客製化其實作。例如,某些代幣可能會使用交易手續費來驅動「兌換並注入流動性」功能,以防止大戶決定賣出代幣時發生踩踏事件。
0x2 被攻擊反射代幣的事後分析
如前所述,我們的主要關注點在於利用反射代幣機制進行的攻擊。因此,與此機制無關的事件,例如 SafeMoon V2 攻擊(常見的 ERC20 公開銷毀問題)和近期的 ZongZi 攻擊(與利用現貨價格進行的老式價格操縱有關),均不在涵蓋範圍內。
我們對這些攻擊進行了深入分析,以揭開其根本原因的神秘面紗。您可以參閱此處取得所有這些事件的清單。我們發現,其中大多數是由程式碼漏洞或不當管理操作引起的正常安全事件。然而,少數事件頗為可疑(例如存在後門),我們將其稱為異常安全事件。在以下小節中,我們將首先介紹正常安全事件,然後詳細說明異常事件。
0x2.1 正常安全事件
我們的調查顯示,這些事件源自兩類問題,即程式碼層級問題和操作層級問題,可能單獨存在或相互結合。
-
程式碼層級問題。這源於合約的粗劣實作,可能是由於開發者未能充分理解反射代幣機制,導致代幣實際供應量與
totalSupply記錄值之間出現不一致,此不一致可被用來操縱匯率:-
1.1 零成本銷毀
-
1.2 代幣轉帳期間從
rSupply額外扣除 -
1.3 r 空間與 t 空間值混淆(利用精度損失獲利)
-
-
操作層級問題。這源於管理員的不當操作。具體而言,在這些事件中,指的是 AMM 交易對地址配置不當,未被正確排除。
值得注意的是,所有列出的程式碼層級問題都可能導致實際供應量與 totalSupply 不一致,使合約容易受到攻擊。然而,這並不必然意味著這些漏洞可被利用,或更準確地說,有足夠的利潤值得利用,因為攻擊者操縱匯率可能需要付出成本。為簡化說明,以下我們將使用「可利用」一詞來表示「有足夠利潤值得利用」。因此,在某些情境下,操作層級問題是使這些漏洞可被利用的必要條件。
具體而言,問題 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 函數,可以輕易地向下操縱匯率,使 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 中,手續費除了原本的部分外,還被分為額外兩部分:銷毀和慈善。
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity 從 _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 個慈善費)。加上要轉給 Bob 的 7 個代幣,此過程共涉及 11 個代幣,這是有問題的。
但為什麼這種不一致性可以被利用來獲利?以下,我們將施展數學魔法來推導其後果。
假設我們之前從池子(即 PancakeSwap 交易對)中獲取了一些代幣,在 r 空間中記為 rAmount,在 t 空間中記為 tAmount。由於池子未被排除,讓我們將 _rOwned[pair] 記為 rReserve,t 空間中對應的值也記為 tReserve。則有:

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

回想「餘額查詢函數」部分(0x1.2.1),當前匯率可以用以下公式計算:

此時,若我們透過 reflect 函數(在此合約中改名為 deliver 函數)反射我們持有的代幣,匯率變為 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 同時減去了 tFee 和 tTeam,而在 _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 空間與 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 應減去 r 空間的值
_tTotal = _tTotal.sub(_value); // _tTotal 應減去 t 空間的值
// 對於銷毀函數的語義,_rTotal 也應從 r 空間的值中減去。
emit Transfer(_who, address(0), _value);
}
考慮到合約的基本常數,r 空間的值通常是 t 空間值的很大倍數。因此,以與 tSupply 同量級的 _value 呼叫 burn 函數將大幅提高匯率。
然而,與 Type-I 案例不同,呼叫者無法在保持自身餘額不變的情況下銷毀代幣。換句話說,攻擊者難以(如果不是不可能的話)收割更多代幣。那麼,Type-II-b 案例如何可被利用?
以 SHEEP 代幣事件為例。攻擊者持有的 SHEEP 代幣價值可以表示為:

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

則價值可以進一步表示為:

攻擊者接著反覆執行 burn 函數,最終同步交易對。由於攻擊者和交易對均未被排除,他們的餘額因我們上述提到的匯率膨脹而下降。因此比率調整為:

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

其中 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 函數中的追蹤記錄,我們可以計算出攻擊者和交易對的理論餘額實際上分別為 27.523 和 2.972,比率為 9.26。然而,由於精度損失,餘額分別向下取整為 27 和 2,比率膨脹至 13.50。因此,利潤變為正值。
最後,攻擊者可以透過執行反向兌換來獲利。
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 合約中,此函數與所有權轉移毫無關係。相反,它在不改變 totalSupply 的情況下增加了 _rOwned[newOwner]。這明顯違反了正常代幣鑄造過程的設計原則。
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);
}
呼叫此函數的交易匯總在以下表格中:
| 時間戳 | 交易雜湊 | 呼叫者 | newOwner |
|---|---|---|---|
| 2021-06-05 17:23:57 | 0x46fa1f97...4606d9bc | 0xef309c...262586 | 0x9e96af...24481a |
| 2021-06-05 17:24:06 | 0x686e0d82...d6ebfb62 | 0xef309c...262586 | 0xb0c426...a72063 |
| 2021-06-05 17:26:12 | 0x44285339...7a526320 | 0xef309c...262586 | 0xef309c...262586 |
| 2021-06-05 19:39:00 | 0xaff7a688...dfe3f344 | 0xef309c...262586 | 0x9e96af...24481a |
| 2022-06-05 18:08:10 | 0x2c413604...f7718f25 | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 18:49:16 | 0x8f4309ca...97d4bcec | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 23:33:44 | 0xaa029544...3ff7b629 | 0xef309c...262586 | 0xef309c...262586 |
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()] 的減法操作中發生了算術下溢,從 0 轉變為接近 type(uint256).max。這種微妙的操縱使所有者能夠改變其餘額,但也導致了代幣供應量的不一致。
這只是一個偶然的錯誤嗎?我們的調查表明,這更可能是一次蓄意的地毯抽逃。原因總結如下:
-
所有者向
manualDevBurn函數傳入了僅 1 個代幣,然而在半小時內,相當於總供應量的 DBALL 代幣透過此交易被轉移到了一個關聯地址。 -
該關聯地址隨即在 PancakeSwap 交易對中進行兌換,獲得了約 56 個 WBNB。
- 分析這兩個地址的資金流向顯示,兩者最終都透過 Tornado.Cash 轉移了 BNB。
0x3 匯率計算中的潛在問題
除了我們之前討論的事件外,我們還發現,從理論上講,非排除使用者的餘額計算中存在一個潛在問題,值得進一步討論。此問題可能在計算匯率時出現。
讓我們來看看 _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 會因反射代幣機制而減少,匯率也會相應降低。若在某筆交易後,rSupply 降至初始匯率以下,當前匯率將會躍升,導致所有未排除使用者的餘額損失。此外,從理論上來說,以下情況也是可能發生的:

這會因精度損失導致匯率變為零,可能觸發除以零的恐慌錯誤。
0x4 緩解措施與解決方案
反射代幣機制提供了一種透過激勵投資者持有代幣而非交易來獲得額外獎勵的方式,以提升市場穩定性。然而,它也帶來了新的安全挑戰和潛在風險,例如 r 空間與 t 空間值混淆。因此,區塊鏈開發者和投資者必須更深入地理解此機制及其潛在風險,並尋求解決方案。
BlockSec 為上線前後各階段提供安全服務和產品。我們的安全審計服務進行徹底審查,以確保程式碼的安全性和透明度。我們的 Phalcon 產品提供持續的安全監控和攻擊偵測能力,使運營者和投資者能夠監控項目,並在偵測到安全風險時自動採取行動。
相關閱讀
關於 BlockSec
BlockSec 是一家全棧 Web3 安全服務提供商。公司致力於提升新興 Web3 世界的安全性和可用性,以促進其大規模採用。為此,BlockSec 提供智慧合約和 EVM 鏈安全審計服務、用於安全開發和主動阻止威脅的 Phalcon 平台、用於資金追蹤和調查的 MetaSleuth 平台,以及幫助 Web3 建構者在加密世界中高效瀏覽的 MetaSuites 擴充功能。
迄今為止,公司已服務超過 300 個客戶,包括 Uniswap Foundation、Compound、Forta 和 PancakeSwap,並在兩輪融資中獲得了來自頂尖投資者(包括 Matrix Partners、Vitalbridge Capital 和 Fenbushi Capital)的數千萬美元資金。
-
電子郵件:[email protected]
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth:https://metasleuth.io/
-
MetaSuites:https://blocksec.com/metasuites



