Back to Blog

リフレクション・トークンを振り返る:セキュリティの視点

June 6, 2024
27 min read

2024年6月14日更新:コミュニティメンバーがこのブログを詳細に調査し、ADUインシデントに関する情報を提供してくれました。これは、以前の分類ではカバーされていなかった新しい形式です。ご協力に感謝いたします。皆様からの有益なフィードバックを歓迎します。

市場の安定性を高めるために、リフレクショントークン(別名リワードトークン)は、投資家に追加の収入獲得手段を提供するために作成されています。これにより、投資家はトークンを取引するのではなく保有することが奨励されます。2021年の悪名高いミームコインシーズン中、リフレクショントークンは不可欠なメカニズムとなり、DxSaleなどのプラットフォーム(例:SafeMoon V1)でローンチされた後、急速に市場の注目を集めました。

2023年には、熱狂が収まり市場が冷え込みましたが、当社のシステムは、このようなトークンメカニズムを悪用した数万件のハッキングインシデントを検出しました。これらのリーパー型攻撃は、他のDeFi攻撃と比較して規模は小さいものの、ユーザー資産に無視できない損失をもたらしました。

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

0x1 リフレクショントークンのメカニズム

当社の知る限り、このメカニズムはReflect Financeによって初めて導入され、トランザクション金額の一定割合を手数料として、非トランザクション的な方法で全てのトークン保有者に分配するように設計されました。2021年3月、著名なSafeMoon V1がBNBチェーンでリリースされ、リフレクショントークンはさらに人気を博しました。

0x1.1 基本概念

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

r-spacet-spaceという2つの空間があります。それぞれリフレクトスペース、トゥルスペースと読みます。2つの空間の暗号通貨は、相対的な循環量に基づいて交換レートが決まります。さらに、r-spaceの通貨はデフレナリーです。つまり、各トランザクションごとに一定の割合がバーンされ、その結果、バーンされた金額は循環量から差し引かれます。

アリス、ボブ、イブが両方の空間でトランザクションを行えると仮定します。下の図を参照してください。アリスとボブがt-spaceでペア間送金を行う場合、イブは報酬を受け取りません。しかし、3人全員が最初にトークンをr-spaceに変換し、その後アリスとボブがお互いに送金すると、イブは最終的にトークンを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));

機能とユーザーインタラクションの観点から、このコントラクトの関数は、残高照会とトークン転送、および特徴的なリフレクト関数の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-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)を計算します。

    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)によって減少するため、rateが低下します。*balanceOf(user)*の計算式によると、この方法で非除外トークン保有者全員に手数料がリフレクトされます。

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

0x1.2.3 reflect関数

トランザクション処理中にリフレクショントークンメカニズムが受動的にトリガーされることに加えて、ユーザーは能動的にreflect関数を呼び出してこのメカニズムを開始できます。具体的には、保有しているトークンを消費してこの関数を呼び出した場合、rateが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リフレクショントークンの事後分析

前述の通り、当社の主な焦点はリフレクショントークンメカニズムを悪用する攻撃にあります。したがって、SafeMoon V2攻撃(一般的なERC20公開バーン問題)や最近のZongZi攻撃(スポット価格を悪用した古い価格操作に関連)など、このメカニズムとは無関係なインシデントは対象外です。

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

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

当社の調査によると、これらのインシデントは、コードレベルの問題または運用レベルの問題、あるいはその両方に起因しています。

  1. コードレベルの問題。これは、開発者がリフレクショントークンのメカニズムを完全に理解していないことによるずさんな実装から生じ、トークンの実際の供給量とtotalSupplyの記録値との間に不整合が生じ、これがレート操作に使用される可能性があります。

    • 1.1 ゼロコストバーン

    • 1.2 トークン転送中のrSupplyからの追加減算

    • 1.3 r-spaceとt-spaceの値の混同(利益を得るための精度損失)

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

なお、リストされている全てのコードレベルの問題は、実際の供給量とtotalSupplyの不整合につながる可能性があり、コントラクトを脆弱にします。しかし、これは必ずしもこれらの脆弱性が悪用可能である、あるいはより正確には、悪用する価値があるほど収益性が高いことを意味するわけではありません。攻撃者がレートを操作するためのコストがかかる可能性があるためです。簡潔にするために、以下では「悪用可能」という用語を「悪用する価値があるほど収益性が高い」と解釈します。その結果、シナリオによっては、運用レベルの問題がこれらの脆弱性を悪用可能にするために必要となります。

具体的には、問題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の両方が比例して大幅に減少しているためです。

rateは、reflect関数を呼び出すことによって簡単に下方操作でき、*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では、手数料は元の手数料に加えて、バーンチャリティの2つの追加部分に分割されます。

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

上記のコードスニペットから、チャリティ部分をリフレクトおよびバーンする場所が2つあり、転送中に実際のトークン供給量がtotalSupplyと不整合になることがわかります。より多くのトークンが転送されるにつれて、追加の減少によりrSupplyの値はプール内の供給量よりも少なくなります。

純粋に理論的な説明はやや抽象的かもしれませんが、例を使ってプロセスを明確にしましょう。アリスがボブに10トークンを転送しようとしており、3トークンが次のように差し引かれると仮定します。1つは手数料、1つはバーン、1つはチャリティです。チャリティ部分はリフレクトされ、バーンされるため、実際の内訳は2トークンがリフレクトされ(手数料1 + チャリティ1)、2トークンがバーンされます(バーン1 + チャリティ1)。ボブに転送される残りの7トークンと合わせて、合計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の誤った計算

この形式に該当するインシデントは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は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関数では、tTransferAmounttFeetTeamの両方から差し引かれますが、_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 タイプII-b:問題1.3と問題2の組み合わせ

タイプII-bのインシデントは、2つの問題の組み合わせを含みます。

  • 問題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の値の大きな倍数です。したがって、_valuetSupplyと同程度の大きさで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);
}

この関数を呼び出したトランザクションを以下の表にまとめます。

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()]の減算中に算術アンダーフローが発生し、0からほぼtype(uint256).maxに遷移します。この微妙な操作により、所有者は自分の残高を変更できますが、トークン供給量の不整合も生じます。

これは単なる偶発的な間違いでしょうか?当社の調査では、意図的なラグプルである可能性が高いことを示唆しています。その理由は次のとおりです。

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

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

  1. これらの2つのアドレスの資金フローを分析すると、両方とも最終的に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がゼロになり、ゼロ除算パニックを引き起こす可能性があります。

0x4 緩和策と解決策

リフレクショントークンメカニズムは、投資家に追加報酬を受け取るためにトークンを取引するのではなく保有することを奨励することで、市場の安定性を高める方法を提供します。しかし、r-spaceとt-spaceの値の混同など、新しいセキュリティ上の課題や潜在的なリスクも導入します。したがって、ブロックチェーン開発者や投資家がメカニズムとその潜在的なリスクをより深く理解し、解決策を求めることが重要です。

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回の資金調達で数千万米ドルを受け取っています。