Back to Blog

リフレクショントークンを再考する:セキュリティの観点から

Phalcon
2024年6月6日
28 min read

2024年6月14日更新:あるコミュニティメンバーが本ブログを注意深く検証し、the ADU incidentに関する情報を提供してくれました。これは、これまでの分類には含まれていなかった新しい形態です。ありがとうございます。洞察に満ちたフィードバックはいつでも歓迎いたします!

市場の安定性を高めるため、reflectionトークン(別名reward token)は、投資家に追加の収益源を提供するために設計されています。これにより、投資家はトークンを取引するのではなく、保有し続けるよう促されます。悪名高い2021年のミームコインシーズンでは、reflectionトークンは不可欠なメカニズムとなり、DxSale(例:SafeMoon V1)などのプラットフォームでローンチされた後、瞬く間に市場の注目を集めました。

熱狂が薄れ、2023年に市場が冷え込んだにもかかわらず、私たちのシステムは、このようなトークンメカニズムを悪用した数万件のハッキングインシデントを実際に検出しました。これらの刈り取り型攻撃は、他の種類のDeFi攻撃と比較すると金額規模は比較的小さいものの、ユーザー資産に無視できない損失をもたらしました。

本ブログでは、私たちの調査から得られたセキュリティ関連の知見を共有することに主眼を置いています。具体的には、まずreflectionトークンのメカニズムについて簡単に紹介します。その後、reflectionトークンに関連するセキュリティインシデントをレビューし、特にreflectionトークンのメカニズムを悪用したものに焦点を当てます。次に、理論上の潜在的なセキュリティ問題について議論します。最後に、緩和策と解決策についての考察を共有します。

0x1 Reflectionトークンのメカニズム

私たちの知る限り、このメカニズムはReflect Financeによって初めて導入されたもので、取引額の一定割合を非取引的な方法ですべてのトークン保有者に手数料として分配するよう設計されています。2021年3月、著名なSafeMoon V1がBNBチェーン上でリリースされ、これによりreflectionトークンはさらに普及しました。

0x1.1 基本概念

詳細に入る前に、より良い理解のためにいくつかの基本的な概念を紹介する必要があります。

ここにはr空間t空間という2種類の空間があり、それぞれreflected space(反射空間)とtrue space(実空間)と呼ばれます。両空間の暗号通貨は、相対的な流通量に基づく交換レートを持っています。さらに、r空間の通貨はデフレ性を持ちます。つまり、取引ごとに一定の割合がバーン(焼却)され、その結果、バーンされた分だけ流通量から差し引かれます。

Alice、Bob、Eveの3人がいずれの空間でも取引可能であると仮定します。これは下図の通りです。もしAliceとBobがt空間でお互いに送金を行った場合、Eveは何の報酬も受け取りません。しかし、もし3人全員が最初にトークンをr空間に変換し、その後AliceとBobがお互いに送金すると、Eveは最終的に自分のトークンをt空間に戻すことで不労所得を得ることになります。これがreflectionトークンメカニズムの基本的な考え方です。

なお、トークンの流動性提供プールなど、すべてのアカウントが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関数という3つのカテゴリーに分類できます。前者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関数

送金プロセス中にreflectionトークンメカニズムが受動的にトリガーされることに加えて、ユーザーは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 Rekt Reflectionトークンの事後分析

前述の通り、私たちの主な焦点はreflectionトークンのメカニズムを悪用した攻撃にあります。したがって、このメカニズムに関係しないインシデント、例えばSafeMoon V2の攻撃(一般的なERC20のパブリックバーン問題)や、最近のZongZi攻撃(スポット価格を悪用した昔ながらの価格操作に関連するもの)は対象外とします。

私たちは、これらの攻撃の根本原因を解明するために詳細な分析を行いました。これらのインシデントの一覧についてはこちらを参照してください。私たちは、そのほとんどが、コードの脆弱性または不適切な管理操作のいずれかに起因する通常のセキュリティインシデントであることを発見しました。しかし、いくつかは非常に不審なもの(例えば、バックドアの存在)であり、これらを異常なセキュリティインシデントと呼びます。以下のサブセクションでは、まず通常のセキュリティインシデントを紹介し、その後で異常なものを詳しく見ていきます。

0x2.1 通常のセキュリティインシデント

私たちの調査によると、これらのインシデントは、コードレベルの問題と運用レベルの問題という2種類の問題に起因しており、単独または組み合わせて発生しています。

  1. コードレベルの問題。これは、コントラクトのお粗末な実装から生じるもので、おそらく開発者がreflectionトークンのメカニズムを完全に理解していないことに起因し、トークンの実際の供給量と記録されたtotalSupplyの値との間に不整合が生じます。これはレートを操作するために利用され得ます:

    • 1.1 ゼロコストのバーン

    • 1.2 トークン送金時におけるrSupplyからの余分な差し引き

    • 1.3 r空間とt空間の値の混同(精度損失を利用して利益を得る)

  2. 運用レベルの問題。これは管理者による不適切な操作に起因します。具体的には、これらのインシデントにおいては、AMMペアアドレスが適切に除外されていないという不適切な設定を指します。

なお、上記に挙げたすべてのコードレベルの問題は、実際の供給量とtotalSupplyの間に不整合を生じさせ、コントラクトを脆弱にする可能性があることに注意してください。しかし、これは必ずしもこれらの脆弱性が悪用可能であること、より正確に言えば、悪用する価値があるほど収益性があることを意味するわけではありません。なぜなら、攻撃者がレートを操作するにはコストがかかる場合があるからです。簡略化のため、以下では「悪用可能」という用語を「悪用する価値があるほど収益性がある」という意味で使用します。その結果、これらの脆弱性を悪用可能にするためには、一部のシナリオでは運用レベルの問題が必要となります。

具体的には、問題1.1は直接悪用可能ですが、問題1.2と1.3は問題2と組み合わさることで初めて悪用可能になります。したがって、これらの観察結果に基づき、議論対象のインシデントはType-IType-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%)

この表から、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に属するインシデントは1件のみで、それはthe CATOSHI incidentであり、総供給量に影響を与えるゼロコストのバーンです。

まず、the CATOSHI contractburnOf関数を見てみましょう:

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の両方が比例的に大幅に減少しているため:

ratereflect関数の呼び出しを通じて容易に下方操作でき、*balanceOf(attacker)*を大幅に増加させることができます。これにより攻撃者は水増しされた残高から利益を得ることができます。

なぜでしょうか?攻撃者の新しい残高は次のように計算されることに注意してください:

*balanceOf(attacker)balanceOf(attacker)'*の比率は:

以下が成り立つため

したがって

これは、攻撃者がより多くのトークンを獲得し、それを価値のあるトークン(この場合はWETH)に交換して利益を得られることを意味します。

0x2.1.2 Type-II-a:問題1.2と問題2の組み合わせ

Type-II-aのインシデントは、2つの問題の組み合わせを伴います:

  • 問題1.2:トークン送金時におけるrSupplyからの余分な差し引き。
  • 問題2:AMMペアが除外されていない。

Type-II-aには3件の攻撃インシデントがあり、これらは問題1.2における脆弱性の形態に応じて、さらに2つのサブカテゴリーに分けることができます:

1. _reflectFee関数における余分なreflect

このサブカテゴリーには2件のインシデント、すなわちthe BEVO incidentthe FETA incidentが属しています。以下では、the BEVO contractを用いて説明します。

「トークン送金のための関数」(セクション0x1.2.2)で紹介した通り、すべてのトークン送金は取引手数料の一部の反映(reflect)をトリガーします。BEVOでは、手数料は元々のもの以外に、burncharityという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);
}

_sendToCharity関数に示されているように、charityアカウントは除外されており、これはこのアカウントに送られた金額がバーンされることを意味します

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分をreflectしかつバーンする箇所が2つあることがわかり、これにより送金中に実際のトークン供給量がtotalSupplyと不整合になります。トークンの送金が増えるほど、この余分な減少によりrSupplyの値はプール内のトークン供給量よりも小さくなっていきます。

純粋に理論的な説明は少し抽象的かもしれないので、例を使ってこのプロセスを明確にしましょう。Aliceが10トークンをBobに送金しようとし、以下のように3トークンが差し引かれるとします:手数料に1、burnに1、charityに1。charity分はreflectとburnの両方が行われるため、実際の内訳はreflectが2トークン(手数料1+charity 1)、burnが2トークン(burn 1+charity 1)となります。Bobに送金される残りの7トークンと合わせると、このプロセスには合計11トークンが関与することになり、これは不整合です。

しかし、なぜこの不整合が悪用されて利益を生み出せるのでしょうか?以下では、数学的な魔法の杖を振ってその帰結を導き出します。

以前にプール(すなわちPancakeSwapペア)からトークンを取得していたと仮定します。これをr空間でrAmount、t空間でtAmountとします。プールは除外されていないため、_rOwned[pair]rReserveとし、t空間での対応する値もtReserveとします。すると:

余分な減少のため、rSupplyはプール内のトークン供給量より小さくなります:

「残高照会のための関数」セクション(0x1.2.1)を思い出すと、現在のrateは以下の式で計算できます:

この時点で、reflect関数(このコントラクトではdeliver関数にリネームされている)を通じて保有トークンをreflectすると、rateは*rate'*になります:

以下が成り立つため

したがって

式1、3、6を組み合わせると、以下の不等式を導き出すことができます:

これは、プールから直接(skim関数を通じて)獲得できるトークンの数が、reflectした量よりもさらに多いことを意味しており、reflect関数を呼び出すコストを賄えるため利益が出るということです。その後、攻撃者は獲得したトークンを価値のあるトークン(この場合はWBNB)に交換して利益を得ることができます。

なお、BEVOコントラクトは問題1.3にも脆弱ですが、この攻撃では悪用されていません。

2. _getRValues関数における不正確なrTransferAmount計算

この形態に属するインシデントは1件のみで、それはthe ADU incidentです。まず、以下のコードスニペットを見てみましょう。

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

送金時にはtax feeとteam feeの両方が差し引かれるべきであることがわかります。しかし、_getTvalues関数では、tTransferAmounttFeetTeamの両方が差し引かれていますが、_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 Type-II-b:問題1.3と問題2の組み合わせ

Type-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と同じ桁数の_valueburn関数を呼び出すと、rateが大幅に膨張します。

しかし、Type-Iのケースとは異なり、呼び出し元は自分の残高を変えずにトークンをバーンすることはできません。言い換えれば、攻撃者がより多くのトークンを獲得することは難しい、あるいは不可能です。では、Type-II-bのケースはどうすれば悪用可能になるのでしょうか?

SHEEPトークンのインシデントを例にとってみましょう。攻撃者が保有するSHEEPトークンの価値は以下のように表せます:

ここで、SHEEPの価格はPancakeSwapペアにおけるスポット価格で表すことができ、以下のように計算されます:

するとValueはさらに以下のように表せます:

攻撃者はその後、burn関数を繰り返し実行し、最終的にペアを同期させます。攻撃者もペアも除外されていないため、前述のrate膨張により両者の残高は減少します。したがって、その比率は次のように調整されます:

前述の定義に基づき、これらの比率をさらに以下のように表現できます:

ここでXは、バーンされた_valueの合計を表します。

ここでマジックの登場です:式8と9をさらに単純化すると、後者の比率は明らかに前者よりも小さくなり、この場合利益がマイナスの値になってしまうため、疑問が残ります:

実際には、攻撃者はreflectionトークンにおける精度損失の問題を利用していました。非除外ユーザーの場合、私たちが提供した式の通り、残高計算は実際には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で説明した「Type-II-a:問題1.2と問題2の組み合わせ」セクションで議論したものと非常によく似ています。しかし、実際のトークン供給量がtotalSupplyから乖離する理由を分析すると、いくつかの不審な活動が明らかになります。

0x2.2.1 FDPのインシデント

FDPのケースにおける実際のトークン供給量とtotalSupplyの間の不整合は、コントラクトオーナーのみが呼び出せる特権関数であるtransferOwnership関数の呼び出しに起因します。名前が示す通り、この関数はコントラクトの所有権を変更するためのものです。しかし、the FDP contractでは、この関数は所有権の移転とは無関係です。代わりに、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);
}

この関数を呼び出したトランザクションは以下の表にまとめられています:

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へと遷移してしまいます。この巧妙な操作により、オーナーは自身の残高を変更できますが、同時にトークン供給量の不整合も引き起こします。

これは単なる偶発的なミスなのでしょうか?私たちの調査では、これは意図的なラグプルである可能性が高いことが示唆されています。その理由は以下の通りです:

  1. オーナーはmanualDevBurn関数にわずか1トークンしか渡していませんが、その30分以内に、総供給量に等しい量のDBALLがこのトランザクションを通じて関連するアドレスに送金されました。

  2. その関連アドレスは、直ちにPancakeSwapペアでスワップを行い、約56 WBNBを取得しました。

  1. これら2つのアドレスの資金フローを分析すると、両方とも最終的にTornado.Cashを通じてBNBを送金していたことがわかります。

0x3 レート計算における潜在的な問題

これまでに議論したインシデントに加えて、理論的には、非除外ユーザーの残高計算においてさらなる議論に値する潜在的な問題が存在することも発見しました。この問題は、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);
}

コントラクトのデプロイからプロジェクトのローンチまでのライフサイクルの間、この条件文がtrueであれば、すべての非除外ユーザーの残高はゼロになります。しかし、プロジェクトがローンチされ取引が始まると、このif文の本来の意図は失われてしまいます。

reflectionトークンのメカニズムによりrSupplyは減少していくため、それに伴ってrateも減少します。あるトランザクションの後にrSupplyが初期のrateを下回った場合、現在のrateが跳ね上がり、すべての非除外ユーザーの残高損失につながります。さらに、理論的には以下のことが起こり得ます

これにより、精度損失のためrateがゼロになり、ゼロ除算パニックを引き起こす可能性があります。

0x4 緩和策と解決策

reflectionトークンメカニズムは、投資家に追加報酬を得るためにトークンを取引するのではなく保有するインセンティブを与えることで、市場の安定性を高める方法を提供します。しかし同時に、r空間とt空間の値の混同など、新たなセキュリティ上の課題や潜在的なリスクも生み出します。したがって、ブロックチェーン開発者や投資家にとって、このメカニズムとその潜在的リスクについてより深く理解し、解決策を模索することが重要です。

BlockSecは、ローンチ前とローンチ後の両段階に対応するセキュリティサービスと製品を提供しています。私たちのセキュリティ監査サービスは、コードのセキュリティと透明性を確保するための徹底的なレビューを実施します。私たちのPhalcon製品は、継続的なセキュリティモニタリングと攻撃検知機能を提供し、運営者や投資家がプロジェクトを監視し、セキュリティリスクが検出された際に自動的にアクションを取ることを可能にします。

関連記事


BlockSecについて

BlockSecはフルスタックのWeb3セキュリティサービスプロバイダーです。当社は、新興のWeb3世界の大規模な普及を促進するために、セキュリティとユーザビリティの向上に取り組んでいます。そのために、BlockSecはスマートコントラクトおよびEVMチェーンのセキュリティ監査サービス、セキュリティ開発と脅威の事前ブロックのためのPhalconプラットフォーム、資金追跡と調査のためのMetaSleuthプラットフォーム、そしてweb3ビルダーが暗号世界を効率的に活動するためのMetaSuites拡張機能を提供しています。

これまでに、当社はUniswap Foundation、Compound、Forta、PancakeSwapなど300社以上のクライアントにサービスを提供しており、Matrix Partners、Vitalbridge Capital、Fenbushi Capitalをはじめとする卓越した投資家から2回の資金調達ラウンドで数千万米ドルを調達しています。