All incidents

ROIToken Ownership Takeover Drain

Share
Sep 08, 2022 04:58 UTCAttackLoss: 44,222.49 BUSD, 157.99 BNBPending manual check1 exploit txWindow: Atomic
Estimated Impact
44,222.49 BUSD, 157.99 BNB
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Sep 08, 2022 04:58 UTC → Sep 08, 2022 04:58 UTC

Exploit Transactions

TX 1BSC
0x0e14cb7eabeeb2a819c52f313c986a877c1fa19824e899d1b91875c11ba053b0
Sep 08, 2022 04:58 UTCExplorer

Victim Addresses

0xe48b75dc1b131fd3a8364b0580f76efd04cf6e9cBSC
0x745d6dd206906dd32b3f35e00533ad0963805124BSC

Loss Breakdown

44,222.49BUSD
157.99BNB

Similar Incidents

Root Cause Analysis

ROIToken Ownership Takeover Drain

1. Incident Overview TL;DR

On BNB Chain block 21143796, an unprivileged attacker exploited ROIToken (0xe48b75dc1b131fd3a8364b0580f76efd04cf6e9c) in transaction 0x0e14cb7eabeeb2a819c52f313c986a877c1fa19824e899d1b91875c11ba053b0. The exploit began when the attacker called transferOwnership(address) on ROIToken and reassigned ownership to an attacker-controlled helper contract. That ownership takeover unlocked privileged fee and reflection-list controls, which the attacker used to distort the ROI/BUSD PancakeSwap pair accounting and exit with profit.

The root cause is a direct access-control failure in ROIToken’s Ownable.transferOwnership(address). The function is publicly callable and writes _owner = newOwner without onlyOwner or an equivalent authorization check. Once ownership became attacker-controlled, the attacker could call excludeFromReward, includeInReward, setTaxFeePercent, setBuyFee, setSellFee, and setLiquidityFeePercent, which enabled the downstream pool drain. The seed balance diff shows a net attacker gain of 157.961413654311412007 BNB.

2. Key Background

ROIToken is a reflection token. Balances depend on reflection accounting variables such as _rOwned, _tOwned, _rTotal, _tTotal, and the _excluded reward list. The helper functions _getCurrentSupply() and _getRate() recompute the rate used to map reflected balances to visible balances, so changing which accounts are excluded changes the effective accounting state for non-excluded holders and for the liquidity pair.

The exploit also depends on PancakeSwap liquidity. The ROI/BUSD pair at 0x745d6dd206906dd32b3f35e00533ad0963805124 can be forced to adopt new token balances through sync(), and the attacker used that behavior after manipulating reflection participation. The attacker then exited through the ROI/BUSD pair and the downstream BUSD/WBNB route on PancakeSwap Router 0x10ed43c718714eb63d5aa57b78b54704e256024e.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability class is an access-control failure, and the affected component is ROIToken’s inherited Ownable implementation. In the verified source, transferOwnership(address newOwner) is declared public virtual, checks only that newOwner != address(0), emits OwnershipTransferred, and then assigns _owner = newOwner. There is no onlyOwner modifier on that function, even though the same file defines and uses onlyOwner on the administrative functions that follow.

That missing guard is the decisive code-level breakpoint. The intended invariant is that only the legitimate owner may transfer ownership and thereby reach owner-only controls. The exploit transaction proves the invariant is broken in practice: the trace shows ROIToken::transferOwnership(0x158Af3D23D96E3104Bcc65b76d1a6f53d0f74Ed0) and then shows the new owner successfully calling owner-only reflection and fee setters. The later pool drain is not a separate bug; it is the consequence of exposing those privileged controls to arbitrary callers.

4. Detailed Root Cause Analysis

The pre-state immediately before block 21143796 included a live ROIToken deployment with ownership held by 0x231aADf45C93f41CEE2e0F8938bEB62a14ECb892, an active ROI/BUSD pair with liquidity, and publicly readable reflection-accounting state. In the verified source, the ownership logic is:

function transferOwnership(address newOwner) public virtual {
    require(newOwner != address(0), "Ownable: new owner is the zero address");
    emit OwnershipTransferred(_owner, newOwner);
    _owner = newOwner;
}

By contrast, the follow-on control surface is protected and therefore becomes reachable only after ownership is stolen:

function excludeFromReward(address account) public onlyOwner() { ... }
function includeInReward(address account) external onlyOwner() { ... }
function setTaxFeePercent(uint256 taxFee) external onlyOwner() { _taxFee = taxFee; }
function setBuyFee(uint256 buyTaxFee, uint256 buyLiquidityFee) external onlyOwner { ... }
function setSellFee(uint256 sellTaxFee, uint256 sellLiquidityFee) external onlyOwner { ... }
function setLiquidityFeePercent(uint256 liquidityFee) external onlyOwner { ... }

The reflection-accounting sensitivity is also visible in code:

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

The seed trace confirms the exact exploit sequence. The attacker-controlled helper first seizes ownership, then zeroes fees, buys ROI, excludes twelve large holders plus the token contract and the helper from rewards, syncs the ROI/BUSD pair, primes the pair with ROI, raises the tax fee to 99, performs the flash-swap callback, restores the tax fee to 0, re-includes the helper, syncs again, and finally exits through PancakeSwap. Representative trace lines are:

ROIToken::transferOwnership(0x158Af3D23D96E3104Bcc65b76d1a6f53d0f74Ed0)
ROIToken::setTaxFeePercent(0)
ROIToken::setBuyFee(0, 0)
ROIToken::setSellFee(0, 0)
ROIToken::setLiquidityFeePercent(0)
ROIToken::excludeFromReward(...)
0x745D6Dd206906dd32b3f35E00533AD0963805124::sync()
ROIToken::setTaxFeePercent(99)
0xe75629094881CFdD3f0F11765fac9c35972f5dce::pancakeCall(...)
ROIToken::includeInReward(0x158Af3D23D96E3104Bcc65b76d1a6f53d0f74Ed0)
PancakeRouter::swapExactTokensForETHSupportingFeeOnTransferTokens(...)

This evidence establishes a direct chain from the missing ownership guard to the realized loss. Without the public transferOwnership, the attacker could not have reached the owner-only reflection and fee controls. Without those controls, the ROI/BUSD accounting distortion and profitable exit would not have been permissionlessly available.

5. Adversary Flow Analysis

The adversary cluster consists of EOA 0x91b7f203ed71c5eccf83b40563e409d2f3531114, ownership-receiving helper 0x158af3d23d96e3104bcc65b76d1a6f53d0f74ed0, and callback contract 0xe75629094881cfdd3f0f11765fac9c35972f5dce. The entire exploit occurs in a single transaction, making it an end-to-end ACT execution rather than a multi-block campaign.

Stage 1 is the ownership takeover. The trace shows the helper calling ROIToken::transferOwnership(...), after which storage slot 0 changes ownership from the legitimate owner to the attacker helper. Stage 2 is reflection-state preparation: the helper resets fees, performs swapETHForExactTokens, excludes twelve whale addresses, excludes the ROIToken contract, excludes itself, and calls sync() on the ROI/BUSD pair. Stage 3 is the drain and exit: the helper transfers ROI into the pair, sets the tax fee to 99, invokes a pair swap that triggers pancakeCall, restores fees, re-includes itself in rewards, syncs again, and sells ROI through ROI/BUSD and then BUSD/WBNB to realize BNB profit.

The attacker did not rely on privileged off-chain access, stolen keys, or any hidden calldata. The root cause artifact correctly characterizes the exploit as permissionless and reproducible with any fresh helper contract that can receive ownership and implement the flash-swap callback.

6. Impact & Losses

The measurable loss is visible in the seed balance diff. The attacker EOA’s native balance increased from 17490662516516208900 wei to 175452076170827620907 wei, for a net gain of 157961413654311412007 wei (157.961413654311412007 BNB). The same artifact shows the ROI/BUSD route lost 44222488755690675616339 BUSD smallest units (44,222.488755690675616339 BUSD at 18 decimals), which is consistent with the manipulated pool exit path.

The affected public components are ROIToken itself and the ROI/BUSD PancakeSwap liquidity pair. The exploit drained value from live liquidity and transferred it to the attacker in the same transaction. This is realized loss, not a temporary mark-to-market effect.

7. References

  • Seed transaction: 0x0e14cb7eabeeb2a819c52f313c986a877c1fa19824e899d1b91875c11ba053b0
  • Verified ROIToken source: 0xe48b75dc1b131fd3a8364b0580f76efd04cf6e9c
  • ROI/BUSD PancakeSwap pair: 0x745d6dd206906dd32b3f35e00533ad0963805124
  • PancakeSwap Router: 0x10ed43c718714eb63d5aa57b78b54704e256024e
  • Balance-diff evidence for attacker profit and BUSD depletion from the seed collector artifacts
  • Opcode-level seed trace showing ownership takeover, owner-only control use, flash-swap callback, and final exit