2024年6月14日更新:あるコミュニティメンバーがこのブログを丁寧に検証し、ADUインシデントに関する情報を提供してくれました。これは私たちの以前の分類ではカバーされていなかった新しい形態です。感謝申し上げます。洞察に富んだフィードバックはいつでも歓迎します!
市場の安定性を高めるため、リフレクショントークン(別名リワードトークン)は投資家に追加の収入源を提供するように設計されています。これにより、投資家はトークンを取引するのではなく保有することを促されます。悪名高い2021年のミームコインシーズンにおいて、リフレクショントークンは不可欠なメカニズムとなり、DxSaleなどのプラットフォームでローンチされた後、急速に市場の注目を集めました(例:SafeMoon V1)。
熱狂が冷め、2023年に市場が落ち着いたにもかかわらず、私たちのシステムはこうしたトークンメカニズムを悪用した数万件のハッキングインシデントを実際に検出しました。これらの「収穫者型」攻撃は、他のタイプのDeFi攻撃と比較すると規模は比較的小さいものの、無視できないユーザー資産の損失をもたらしました。
このブログでは、主に私たちの調査から得られたセキュリティ関連の知見を共有することに焦点を当てます。具体的には、まずリフレクショントークンのメカニズムについて簡単に紹介します。その後、リフレクショントークンに関連するセキュリティインシデントをレビューし、リフレクショントークンメカニズムを悪用したものに焦点を当てます。次に、理論的に潜在的なセキュリティ問題について議論します。最後に、緩和策と解決策についての考えを共有します。
0x1 リフレクショントークンのメカニズム
私たちの知る限り、このメカニズムはReflect Financeによって最初に導入されたもので、取引額の一定割合を非取引的な方法ですべてのトークン保有者に手数料として分配するように設計されています。2021年3月、有名なSafeMoon V1がBNBチェーン上でリリースされ、リフレクショントークンをさらに普及させました。
0x1.1 基本概念
詳細に入る前に、より良い理解を得るためにいくつかの基本的な概念を紹介する必要があります。
r空間とt空間という2種類の空間があり、それぞれリフレクト空間(reflected space)、真空間(true space)と読みます。2つの空間の暗号通貨は、相対的な流通量に基づいた交換レートを持っています。さらに、r空間の通貨はデフレ的であり、つまり、すべての取引において一定の割合が焼却され、その結果、焼却された量が流通量から差し引かれます。
Alice、Bob、Eveの3人が両方の空間で取引できるとします。以下の図に示す通りです。AliceとBobがt空間でペアワイズ送金を行う場合、Eveは何の報酬も受け取りません。しかし、3人全員がまず自分のトークンを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));
機能性とユーザーインタラクションの観点から、このコントラクトの関数は以下の3つのカテゴリに分類されます:残高照会とトークン送金、そして特徴的なreflect関数です。前者2つは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 トークン送金のための関数
一般的に、資産を送金するシナリオは以下の4つがあります:
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);
}
上記で示したコードがこのメカニズムの中核を成しています。トークンによっては、実装をカスタマイズするために特定の追加機能を組み込む場合があります。例えば、クジラがトークンを売却する際のパニック売りを防ぐために、取引手数料を利用して「スワップ・アンド・リクィファイ」機能を稼働させるものもあります。
0x2 やられたリフレクショントークンの事後分析
前述の通り、私たちが主に焦点を当てるのはリフレクショントークンメカニズムを悪用した攻撃です。したがって、SafeMoon V2攻撃(一般的なERC20公開バーンの問題)や、最近のZongZi攻撃(スポット価格を悪用した古典的な価格操作に関連するもの)など、このメカニズムに関連しないインシデントは対象外とします。
これらの攻撃の根本原因を明らかにするため、詳細な分析を行いました。これらすべてのインシデントの一覧についてはこちらを参照してください。その大半は、コードの脆弱性または不適切な管理操作のいずれかに起因する通常のセキュリティインシデントであることが分かりました。しかし、少数は(バックドアの存在など)非常に疑わしいものであり、私たちはこれを異常なセキュリティインシデントと呼んでいます。以下のサブセクションでは、まず通常のセキュリティインシデントを紹介し、その後異常なものについて詳しく見ていきます。
0x2.1 通常のセキュリティインシデント
私たちの調査によると、これらのインシデントは2種類の問題、すなわちコードレベルの問題と操作レベルの問題に起因しており、それぞれ単独で、あるいは組み合わさって発生しています。
-
コードレベルの問題。これはコントラクトのお粗末な実装に起因するもので、開発者がリフレクショントークンのメカニズムを完全に理解していないことが原因である可能性が高く、実際のトークン供給量と記録された
totalSupplyの値との間に不整合が生じ、これがrateを操作するために利用され得ます:-
1.1 ゼロコストバーン
-
1.2 トークン送金時の
rSupplyからの余分な控除 -
1.3 r空間とt空間の値の混同(精度の損失を利用して利益を得る)
-
-
操作レベルの問題。これは管理者による不適切な操作に起因します。具体的には、これらのインシデントにおいては、適切に除外されていないAMMペアアドレスの不適切な設定を指します。
注意すべき点は、リストアップされたすべてのコードレベルの問題が、実際の供給量とtotalSupplyとの間の不整合を引き起こし、コントラクトを脆弱にする可能性があるということです。しかし、これは必ずしもこれらの脆弱性が悪用可能であること、より正確に言えば、悪用する価値があるほど利益が出ることを意味するわけではありません。なぜなら、攻撃者がrateを操作するにはコストがかかる場合があるからです。簡略化のため、以下では「悪用可能」という用語を「悪用する価値があるほど利益が出る」という意味で使用します。その結果、一部のシナリオでは、これらの脆弱性を悪用可能にするために操作レベルの問題が必要となります。
具体的には、問題1.1は直接悪用可能ですが、問題1.2と1.3は問題2と組み合わさって初めて悪用可能になります。したがって、これらの観察結果に基づき、議論対象のインシデントはタイプIとタイプIIの2つのタイプに分けることができます。以下は関連データを示す表です:
| タイプ | インシデント | 根本原因 | # (%) |
|---|---|---|---|
| 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%) |
この表から、タイプIIのインシデントが大きな割合を占めていることが分かります。具体的には、タイプIIには2つのバリエーションがあります:タイプII-a(すなわち問題1.2と問題2)とタイプII-b(すなわち問題1.3と問題2)です。さらに、タイプII-bカテゴリに属するSHEEPのようなインシデントについては、そのエクスプロイトから、攻撃者(この例など)が自動化された手法を用いて同様に脆弱なコントラクトを特定している可能性が示唆されます。具体的な詳細については、以降のサブセクションで検討します。
0x2.1.1 タイプI:コードレベルの問題のみ(問題1.1)
タイプIに属するインシデントは1件のみです。すなわち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 タイプII-a:問題1.2と問題2の組み合わせ
タイプII-aのインシデントは2つの問題の組み合わせを含みます:
- 問題1.2:トークン送金時の
rSupplyからの余分な控除。 - 問題2:AMMペアが除外されていない。
タイプII-aには3件の攻撃インシデントがあり、これらは問題1.2における脆弱性の形態に応じて、さらに2つのサブカテゴリに分けることができます。以下の通りです:
1. _reflectFee関数における余分なリフレクト
このサブカテゴリには2件のインシデントが属します。すなわちBEVOインシデントとFETAインシデントです。以下では、説明のためにBEVOコントラクトを使用します。
'トークン送金のための関数'(セクション0x1.2.2)で紹介した通り、すべてのトークン送金は取引手数料の一部のリフレクトをトリガーします。BEVOでは、手数料は元のもの以外に、burnとcharityという2つの追加部分に分割されます。
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);
}
注意すべき点は、charityアカウントは除外されており、このアカウントに送られた金額は焼却されることを意味します。これは_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);
}
上記のコードスニペットから、charity部分をリフレクトして焼却する箇所が2つあり、これが送金中に実際のトークン供給量がtotalSupplyと不整合になる原因となっていることが分かります。より多くのトークンが送金されるにつれて、この追加の減少により、rSupplyの値はプール内のトークン供給量よりも少なくなります。
純粋に理論的な説明は少し抽象的かもしれないので、例を使ってプロセスを明確にしましょう。Aliceが10トークンをBobに送金しようとし、次のように3トークンが差し引かれるとします:手数料に1トークン、バーンに1トークン、charityに1トークンです。charity部分はリフレクトも焼却もされるため、実際の内訳は2トークンがリフレクトされ(手数料1+charity1)、2トークンが焼却されます(バーン1+charity1)。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計算の誤り
この形態に属するインシデントは1件のみです。すなわち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関数を通じてより多くのADUトークンを収穫するために、BEVOと同様のエクスプロイトを使用できます。
しかし、当時のオンチェーン状態を踏まえると、攻撃者が収穫した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 タイプII-b:問題1.3と問題2の組み合わせ
タイプII-bのインシデントは2つの問題の組み合わせを含みます:
- 問題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が大幅に膨らみます。
しかし、タイプIのケースとは異なり、呼び出し元は自分自身の残高を変えずにトークンを焼却することはできません。言い換えれば、攻撃者がより多くのトークンを収穫することは、不可能ではないにしても困難です。では、タイプ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関数内のトレースを分析することで、攻撃者とペアの理論上の残高がそれぞれ実際には27.523と2.972であり、比率が9.26になることを計算できます。しかし、精度損失のため、残高はそれぞれ27と2に切り捨てられ、比率が13.50に膨らみます。その結果、Profitは正の値になります。
最後に、攻撃者は逆方向のスワップを実行することで利益を得ることができます。
0x2.2 異常なセキュリティインシデント
このサブセクションでは、FDPおよびDBALLトークンの調査から得られた知見を共有します。私たちの分析によると、FDPとDBALLトークンの両方の管理者が問題のある特権関数を呼び出しており、これは事実上バックドアとして機能していました。これによりプロジェクトはリスクにさらされ、最終的に攻撃につながりました。具体的には、DBALLプロジェクトにおいて、トークンオーナーによる一連の疑わしいトランザクションを特定しました。これはラグプルと見なす明確な証拠を提供するものです。
これら2つのトークンを標的としたエクスプロイトは、0x2.1.2の下で説明した'タイプ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トークンしか渡していませんが、その30分以内に、総供給量に等しい量のDBALLがこのトランザクションを通じて関連アドレスに送金されました。 -
その関連アドレスは直ちにPancakeSwapペアでスワップを行い、約56 WBNBを取得しました。
- これら2つのアドレスの資金フローを分析すると、両者とも最終的に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);
}
コントラクトのデプロイからプロジェクトのローンチまでのライフサイクルにおいて、この文がtrueである場合、すべての非除外ユーザーの残高はゼロになります。しかし、いったんプロジェクトがローンチされて取引が始まると、このif文の本来の意図は失われます。
リフレクショントークンメカニズムによりrSupplyが減少するため、rateもそれに応じて減少します。もしあるトランザクションの後にrSupplyが初期rateを下回った場合、現在のrateが跳ね上がり、すべての非除外ユーザーの残高損失につながります。さらに、理論的には以下が起こり得ます

これにより、精度損失のためにrateがゼロになり、ゼロ除算パニックを引き起こす可能性があります。
0x4 緩和策と解決策
リフレクショントークンメカニズムは、投資家に追加の報酬を得るために取引ではなくトークンを保有することを奨励することで、市場の安定性を高める方法を提供します。しかし、これはr空間とt空間の値の混同など、新たなセキュリティ課題や潜在的なリスクももたらします。したがって、ブロックチェーン開発者と投資家がこのメカニズムとその潜在的なリスクについてより深く理解し、解決策を模索することが重要です。
BlockSecはローンチ前後の両段階においてセキュリティサービスと製品を提供しています。私たちのセキュリティ監査サービスは、コードの安全性と透明性を確保するための徹底的なレビューを実施します。私たちのPhalcon Security製品は、継続的なセキュリティ監視と攻撃検出機能を提供し、運営者と投資家がプロジェクトを監視し、セキュリティリスクが検出された際に自動的な対応を取ることを可能にします。
関連記事
- How L2 Blockchains Can Do Better to Protect Their Users
- Top 10 "Awesome" Security Incidents in 2023
- Conceptual Full Analysis A Rise Of Bitcoin With Inscription
BlockSecについて
BlockSecはフルスタックのWeb3セキュリティサービスプロバイダーです。当社は、新興のWeb3世界におけるセキュリティとユーザビリティの向上に取り組み、その大規模な普及を促進することを目指しています。この目的のため、BlockSecはスマートコントラクトおよびEVMチェーンのセキュリティ監査サービス、プロアクティブにセキュリティ開発と脅威の遮断を行うためのPhalconプラットフォーム、資金の追跡と調査のためのMetaSleuthプラットフォーム、そしてweb3ビルダーが暗号の世界を効率的に活用するためのMetaSuites拡張機能を提供しています。
これまでに、当社はUniswap Foundation、Compound、Forta、PancakeSwapなど1,000社以上のクライアントにサービスを提供しており、Matrix Partners、Vitalbridge Capital、Fenbushi Capitalなどの著名な投資家から2回の資金調達ラウンドで数千万米ドルを調達しています。
-
ウェブサイト: https://blocksec.com/
-
メール: [email protected]
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



