リフレクション・トークンについての考察:セキュリティの観点から

このブログでは、リフレクション・トークン・メカニズムに関する当社の調査から得られたセキュリティ関連の洞察を共有することを主な目的としています。

リフレクション・トークンについての考察:セキュリティの観点から

2024年6月14日更新:コミュニティメンバーがこのブログを詳細に検討し、以前の分類に含まれていなかった新しい形態であるADUインシデントに関する情報を提供してくれました。ありがとうございます。皆様からの洞察に富んだフィードバックを歓迎します。

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

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

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

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

我々の知る限り、このメカニズムはReflect Financeによって初めて導入され、トランザクション金額の一部を、トランザクションを伴わない方法で全てのトークン保有者に手数料として分配するように設計されました。2021年3月、有名なSafeMoon V1がBNBチェーンでリリースされ、リフレクショントークンはさらに普及しました。

0x1.1 基本概念

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

r-space(リフレクトスペース)とt-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);
}

これは、以下の式で表すことができます。

上記の式におけるレートは、_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関数は、両方のスペースに対応する金額、転送金額、および手数料(つまり、金額 = 転送金額 + 手数料)を計算します。

    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)によって減少します。これにより、レートが低下します。*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 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の両方が比例して大幅に減少したため:

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)を思い出してください。現在のレートは次の式で計算できます。

この時点で、reflect関数(このコントラクトではdeliver関数に改名されています)を呼び出して保有するトークンをリフレクトすると、レートは*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のみが差し引かれます。この不一致は、前述の不整合問題につながり、トークン転送が多くなるにつれて悪化します。

ペアもトークンから除外されていないため、このトークンは悪用可能です。具体的には、攻撃者は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-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の値から減算されるべきです
    // バーン関数のセマンティクスとして、_rTotalもr-spaceの値から減算されるべきです。
    emit Transfer(_who, address(0), _value);
}

コントラクトの不可欠な定数を考慮すると、r-spaceの値は通常t-spaceの値の大きな倍数です。したがって、tSupplyと同じオーダーの_valueburn関数を呼び出すと、レートが大幅にインフレします。

しかし、タイプIの場合とは異なり、呼び出し元は自身の残高を維持したままトークンをバーンすることはできません。つまり、攻撃者がより多くのトークンを収穫することは困難、あるいは不可能であるため、タイプII-bのケースはどのように悪用可能なのでしょうか?

SHEEPトークンインシデントを例にとりましょう。攻撃者が保有するSHEEPトークンの価値は、次のように表すことができます。

ここで、SHEEPの価格はPancakeSwapペアのスポット価格で表され、次のように計算されます。

すると、Valueはさらに次のように表すことができます。

攻撃者はその後、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にインフレします。その結果、Profitは正の値になります。

最後に、攻撃者は逆スワップを実行することで利益を得ることができます。

0x2.2 異常なセキュリティインシデント

このサブセクションでは、FDPとDBALLトークンの調査から得られた発見を共有します。我々の分析によると、FDPとDBALLトークンの両方のマネージャーは、問題のある特権関数を呼び出しており、効果的にバックドアとして機能し、プロジェクトを危険にさらし、最終的に攻撃につながりました。具体的には、DBALLプロジェクトでは、トークン所有者による一連の疑わしいトランザクションを特定しており、これはラグプルと見なす明確な証拠を提供しています。

これらの2つのトークンを標的とした攻撃は、「タイプII-a:問題1.2と問題2の組み合わせ」セクション(0x2.1.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 レート計算における潜在的な問題

これまで議論してきたインシデントに加えて、理論的には、非除外ユーザーの残高計算において、さらに議論に値する潜在的な問題が存在することも発見しました。この問題は、レートの計算中に発生する可能性があります。

_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-spaceとt-spaceの値の混同など、新たなセキュリティ上の課題と潜在的なリスクも導入します。したがって、ブロックチェーン開発者と投資家がメカニズムとその潜在的なリスクをより深く理解し、解決策を模索することが重要です。

BlockSecは、ローンチ前およびローンチ後の両方のステージでセキュリティサービスと製品を提供しています。当社のセキュリティ監査サービスは、コードのセキュリティと透明性を確保するために徹底的なレビューを実施します。当社のPhalcon製品は、継続的なセキュリティ監視と攻撃検出機能を提供し、オペレーターと投資家がプロジェクトを監視し、セキュリティリスクが検出されたときに自動アクションを実行できるようにします。

関連記事


BlockSecについて

BlockSecは、フルスタックのWeb3セキュリティサービスプロバイダーです。同社は、Web3の普及を促進するために、新しいWeb3の世界のセキュリティとユーザビリティの向上に尽力しています。この目的のために、BlockSecはスマートコントラクトおよびEVMチェーンセキュリティ監査サービス、セキュリティ開発と脅威のプロアクティブなブロックのためのPhalconプラットフォーム、資金追跡と調査のためのMetaSleuthプラットフォーム、およびWeb3ビルダーが暗号通貨の世界で効率的にサーフィンするためのMetaSuites拡張機能を提供しています。

現在までに、同社はUniswap Foundation、Compound、Forta、PancakeSwapなどの300以上のクライアントにサービスを提供し、Matrix Partners、Vitalbridge Capital、Fenbushi Capitalなどの著名な投資家から2回の資金調達ラウンドで数千万米ドルを受け入れています。

Sign up for the latest updates