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

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

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 過去のリークしたリフレクショントークン

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

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

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

私たちの調査によると、これらのインシデントは、コードレベルの問題とオペレーションレベルの問題のいずれか、または両方から発生しています。

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

    • 1.1 Zero-cost burn
    • 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のみが差し引かれます。この不一致は、前述の不一致問題につながり、トークン転送が増えるにつれて悪化します。

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

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

しかし、タイプ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よりもはるかに小さいです。

ペアの同期関数内のトレースを分析すると、攻撃者とペアの理論的な残高はそれぞれ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 レート計算における潜在的な問題

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

_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の世界のセキュリティとユーザビリティを向上させ、その大量採用を促進することに尽力しています。この目的を達成するために、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
Weekly Web3 Security Incident Roundup | Feb 9 – Feb 15, 2026

Weekly Web3 Security Incident Roundup | Feb 9 – Feb 15, 2026

During the week of February 9 to February 15, 2026, three blockchain security incidents were reported with total losses of ~$657K. All incidents occurred on the BNB Smart Chain and involved flawed business logic in DeFi token contracts. The primary causes included an unchecked balance withdrawal from an intermediary contract that allowed donation-based inflation of a liquidity addition targeted by a sandwich attack, a post-swap deflationary clawback that returned sold tokens to the caller while draining pool reserves to create a repeatable price-manipulation primitive, and a token transfer override that burned tokens directly from a Uniswap V2 pair's balance and force-synced reserves within the same transaction to artificially inflate the token price.

Top 10 "Awesome" Security Incidents in 2025

Top 10 "Awesome" Security Incidents in 2025

To help the community learn from what happened, BlockSec selected ten incidents that stood out most this year. These cases were chosen not only for the scale of loss, but also for the distinct techniques involved, the unexpected twists in execution, and the new or underexplored attack surfaces they revealed.

#10 Panoptic Incident: XOR Linearity Breaks the Position Fingerprint Scheme

#10 Panoptic Incident: XOR Linearity Breaks the Position Fingerprint Scheme

On August 29, 2025, Panoptic disclosed a Cantina bounty finding and confirmed that, with support from Cantina and Seal911, it executed a rescue operation on August 25 to secure roughly $400K in funds. The issue stemmed from a flaw in Panoptic’s position fingerprint calculation algorithm, which could have enabled incorrect position identification and downstream fund risk.