Reproduced Exploit

ROI Token Exploit — Missing `onlyOwner` on `transferOwnership` → Reflection-Accounting Mint

ROIToken is a SafeMoon-style "reflection" (RFI) token. Its Ownable.transferOwnership() is missing the onlyOwner modifier (ROIToken.sol:181-185), so anyone can become owner of the token. As owner the attacker gains full control of the fee parameters (setTaxFeePercent, setBuyFee, setSellFee) and of t…

Sep 2022BNB ChainAccess Control14 min read

Loss

~157.98 BNB (~$44,000) — gross attacker balance delta in the PoC: +163.33 BNB (5 → 168.33 BNB)

Chain

BNB Chain

Category

Access Control

Date

Sep 2022

Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2022-09-ROI_exp in the evm-hack-registry mirror. Upstream DeFiHackLabs PoC: src/test/…/ROI_exp.sol.


Vulnerability classes: vuln/access-control/missing-owner-check · vuln/logic/state-update

Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that fail to whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: ROIToken.sol.


Key info#

Loss157.98 BNB ($44,000) — gross attacker balance delta in the PoC: +163.33 BNB (5 → 168.33 BNB)
Vulnerable contractROIToken0xE48b75dc1b131fd3A8364b0580f76eFD04cF6e9c
Victim poolBUSD/ROI PancakeSwap pair — 0x745D6Dd206906dd32b3f35E00533AD0963805124
Attacker EOA0x91b7f203ed71c5eccf83b40563e409d2f3531114
Attacker contract0x158af3d23d96e3104bcc65b76d1a6f53d0f74ed0
Attack tx0x0e14cb7eabeeb2a819c52f313c986a877c1fa19824e899d1b91875c11ba053b0
Chain / block / dateBSC / fork at 21,143,795 / Sept 8, 2022
CompilerSolidity v0.8.4, optimizer enabled (200 runs)
Bug classBroken access control (un-guarded transferOwnership) chained with a reflection-token (RFI) accounting flaw

TL;DR#

ROIToken is a SafeMoon-style "reflection" (RFI) token. Its Ownable.transferOwnership() is missing the onlyOwner modifier (ROIToken.sol:181-185), so anyone can become owner of the token. As owner the attacker gains full control of the fee parameters (setTaxFeePercent, setBuyFee, setSellFee) and of the reward-exclusion lists (excludeFromReward / includeInReward).

The attacker then weaponizes the token's own reflection bookkeeping. In RFI tokens a holder's balance is stored two different ways depending on whether the holder is "excluded from reward":

  • Excluded → balance read from _tOwned[account] (a raw token amount).
  • Included → balance read from tokenFromReflection(_rOwned[account]) (a reflected amount that is divided by a global rate).

includeInReward() flips an account back to "included" but zeroes only _tOwned, leaving the inflated _rOwned untouched (ROIToken.sol:655-666). By being excluded while paying a 99 % "tax" on a flash-loaned amount — which credits the attacker's _rOwned with a huge reflected balance — and then calling includeInReward(self), the attacker makes balanceOf(self) jump from 0 back up to ~3.99 M ROI conjured out of the reflection ledger. Those phantom tokens are sold into the pool for 167.9 BNB.

Net result: starting with 5 BNB, the attacker walks away with 168.33 BNB — draining ~158 BNB of real liquidity from the BUSD/ROI pool.


Background — what ROIToken does#

ROIToken (source) is a fork of the SafeMoon "reflection" token family with extra buy-back / auto-LP machinery bolted on. The relevant mechanics:

  • Dual balance representation. Total supply is mirrored in a hugely-scaled "reflection" space. _rTotal = (MAX - (MAX % _tTotal)) (:437), and each holder's reflected balance lives in _rOwned. The conversion rate _getRate() = rSupply / tSupply shrinks every time fees are reflected, so an unchanged _rOwned represents more tokens over time. Excluded accounts are carved out of this and tracked in raw _tOwned instead (balanceOf, :567-570).
  • Configurable fees. A base _taxFee plus separate buy/sell tax & liquidity fees. The owner can set all of them at will (setTaxFeePercent, setBuyFee, setSellFee, setLiquidityFeePercent).
  • Reward exclusion lists. excludeFromReward / includeInReward move an account between the reflected and raw accounting modes.

On-chain parameters at the fork block:

ParameterValue
_decimals9
_tTotal (total supply)50,000,000 ROI
_taxFee (base, before attack)2 %
Token's configured uniswapV2Pairthe ROI/WBNB pair (created against WETH at construction, :537-538)
BUSD/ROI pair reserves (the victim)≈ 4,454,304 ROI / 49,570 BUSD

A subtle but load-bearing fact: the token's uniswapV2Pair is the ROI/WBNB pair, but the attacker operates against the BUSD/ROI pair. Because the buy/sell fee branches in _transfer only trigger when from/to == uniswapV2Pair (:755-765), no transfer through the BUSD/ROI pair ever hits removeAllFee() — so the global _taxFee the attacker sets to 99 applies to those transfers verbatim.


The vulnerable code#

1. transferOwnership is missing onlyOwner#

SOLIDITY
// ROIToken.sol:176-185
function renounceOwnership() public virtual onlyOwner {       // ← guarded
    emit OwnershipTransferred(_owner, address(0));
    _owner = address(0);
}

function transferOwnership(address newOwner) public virtual { // ⚠️ NO onlyOwner!
    require(newOwner != address(0), "Ownable: new owner is the zero address");
    emit OwnershipTransferred(_owner, newOwner);
    _owner = newOwner;                                        // ← anyone can seize ownership
}

renounceOwnership, lock, and every setter carry onlyOwner. transferOwnership does not. This single missing modifier hands the entire admin surface to any caller.

2. includeInReward zeroes _tOwned but keeps the inflated _rOwned#

SOLIDITY
// ROIToken.sol:655-666
function includeInReward(address account) external onlyOwner() {
    require(_isExcluded[account], "Account is not excluded");
    for (uint256 i = 0; i < _excluded.length; i++) {
        if (_excluded[i] == account) {
            _excluded[i] = _excluded[_excluded.length - 1];
            _tOwned[account] = 0;          // ⚠️ raw balance wiped...
            _isExcluded[account] = false;  // ⚠️ ...but balanceOf now reads _rOwned!
            _excluded.pop();
            break;
        }
    }
}
SOLIDITY
// ROIToken.sol:567-570  — the balance switch
function balanceOf(address account) public view override returns (uint256) {
    if (_isExcluded[account]) return _tOwned[account];
    return tokenFromReflection(_rOwned[account]);   // ← reads the *kept* _rOwned
}

While an account is excluded its _rOwned is irrelevant to balanceOf. includeInReward flips _isExcluded to false and discards _tOwned, so the account's balance instantly becomes tokenFromReflection(_rOwned). If _rOwned was inflated while the account was excluded, that inflation materializes as spendable tokens the moment it is re-included.

3. The 99 % "tax" credits _rOwned via reflection#

When the attacker (excluded sender, the pair as recipient — neither equal to the token's uniswapV2Pair) transfers ROI with _taxFee = 99, _getTValues computes tFee = 99 % and only 1 % is delivered. _getRValues multiplies through by the current rate, and _reflectFee shrinks _rTotal (:923-947). Shrinking _rTotal raises the value of every remaining _rOwned — including the attacker's, which (because it was credited the full flash-loaned _rOwned on receipt and only debited 1 % on send) is left enormous.


Root cause — why it was possible#

Two independent flaws compose into a clean drain:

  1. Broken access control (primary). transferOwnership lacks onlyOwner. Ownership of a live token is a single transferOwnership(attacker) call away. Once owner, the attacker can rewrite every fee and toggle every account's reward-exclusion state.

  2. Asymmetric reflection bookkeeping (the amplifier). The standard SafeMoon-derived excludeFromReward/includeInReward pair never reconciles _rOwned against _tOwned on re-inclusion. excludeFromReward snapshots _tOwned = tokenFromReflection(_rOwned), but includeInReward just sets _tOwned = 0 and trusts the stale _rOwned. The accounting is therefore path-dependent: an account that accrues reflection while excluded "double-counts" it on the way back in.

The attacker bridges these with a PancakeSwap flash swap so that no real capital is at risk: borrow ROI from the BUSD/ROI pair, route it so the attacker's _rOwned balloons under a 99 % tax, repay the loan with the 1 %-delivered remainder, then includeInReward(self) to convert the ballooned _rOwned into ~4 M ROI of phantom balance, and dump it for BNB.


Preconditions#

  • transferOwnership callable by anyone (no onlyOwner) — always true for this contract.
  • Owner-only setters (setTaxFeePercent, setBuyFee, setSellFee, excludeFromReward, includeInReward) — reachable once ownership is seized.
  • A pool to flash-loan ROI from and to dump phantom ROI into (the BUSD/ROI pair).
  • Working capital: only 5 BNB of seed (to buy the initial 100 k ROI used as a foothold). The ROI used to balloon _rOwned is flash-loaned and repaid in the same transaction, so the attack is effectively self-funded.

Attack walkthrough (with on-chain numbers from the trace)#

All figures are read directly from output.txt. ROI has 9 decimals; BUSD/WBNB have 18. The PancakeSwap Attacker contract is 0x7FA9385b… in the PoC (the historical attacker contract was 0x158af3…). The BUSD/ROI pair is reserve0 = ROI, reserve1 = BUSD.

#StepTrace refROI / BUSD effect
0Seed buy. Swap 5 BNB → BUSD → ROI, receiving 111,291.83 ROI.output.txt:1619-1671Attacker now holds 111,291.83 ROI; spent ~4.575 BNB.
1Seize ownership. ROI.transferOwnership(attacker).output.txt:1680-1684_owner ← attacker (owner was 0x231aADf4…).
2Neutralize fees & seed exclusions. setTaxFeePercent(0), setBuyFee(0,0), setSellFee(0,0), setLiquidityFeePercent(0), then excludeFromReward(...) 12 LP-holders + the token + self.output.txt:1685-1790Attacker is now excluded from reward; balanceOf(self) reads _tOwned.
3Plant a foothold in the pool. Transfer balanceOf − 100,000 = 11,291.83 ROI to the pair (tax = 0), withholding exactly 100,000 ROI.output.txt:1795-1801Pair ROI: 4,343,012.69 → 4,354,304.53; attacker holds 100,000 ROI.
4Arm the 99 % tax. setTaxFeePercent(99).output.txt:1818-1821Global _taxFee ← 99.
5Flash-swap 4,343,012.69 ROI from the pair (swap(amount0Out, 0, attacker, "3030")). The pair sends ROI to the attacker — but because this transfer is taxed 99 %, the attacker only receives 43,430.13 ROI on-balance, while its _rOwned is credited as if it received the full amount.output.txt:1822-1853Disbursement Transfer event shows 43,430.13 delivered (1 % of 4,343,012.69).
6Repay in pancakeCall. Attacker transfers its entire balanceOf (143,430.13 ROI = 100,000 foothold + 43,430.13) back to the pair; 99 % is taxed again so only 1,434.30 ROI lands, yet that satisfies the flash-swap's k check (the borrowed ROI was almost entirely "burned" into reflections, not removed).output.txt:1832-1844Repay Transfer event shows 1,434.30 delivered (1 % of 143,430.13). Pair ROI synced to 4,454,304.53.
7Re-include self. setTaxFeePercent(0) then includeInReward(self)_tOwned[self]=0, _isExcluded[self]=false, but _rOwned[self] (massively inflated by the two 99 % reflections) is kept. balanceOf(self) = tokenFromReflection(_rOwned) now ≈ 3,986,806 ROI of phantom balance.output.txt:1868-1877Attacker balance jumps from 0 → ~3.99 M ROI.
8Re-sync the pool. pair.sync() updates reserve0 to the pair's actual ROI balance (which the reflection math has eroded to 325,973.38 ROI), pricing ROI very high.output.txt:1878-1886Pair ROI reserve: 4,454,304.53 → 325,973.38.
9Dump the phantom ROI. swapExactTokensForETHSupportingFeeOnTransferTokens(3,986,806.27 ROI → BNB) through ROI → BUSD → WBNB.output.txt:1892-1952Attacker receives 167.91 WBNB, withdrawn to BNB.

Why the 99 % "tax" is the mint#

The attacker is excluded during steps 5–6, so the receive (step 5) credits its _rOwned with the full flash-loaned reflection amount, but the corresponding _tOwned increase is what the 1 %-delivered tokens reflect. On the send (step 6) only 1 % leaves. The other 99 % is "reflected" via _reflectFee — which shrinks _rTotal and thereby increases the token-value of the attacker's surviving _rOwned. When includeInReward then discards _tOwned and switches balanceOf to read _rOwned, that inflated reflected balance becomes ~3.99 M real, sellable ROI — none of which corresponds to tokens the attacker actually paid for.


Profit / loss accounting (BNB)#

ItemAmount (BNB)
Start balance5.0000
Spent on seed ROI buy (step 0)−4.5752
Balance after seed buy0.4248
Received from final ROI dump (step 9)+167.9069
End balance168.3317
Gross profit vs. start+163.3317

The widely-reported headline loss is 157.98 BNB (~$44K) (the value extracted from victim liquidity, net of routing through two pools and fees). The PoC's own balance delta is the cleaner mechanical figure: +163.33 BNB, of which ~167.9 BNB came straight out of the BUSD/ROI pool's real reserves in exchange for tokens conjured from the reflection ledger.


Diagrams#

Sequence of the attack#

sequenceDiagram autonumber actor A as "Attacker" participant R as "PancakeRouter" participant P as "BUSD/ROI Pair" participant T as "ROIToken" Note over T: "transferOwnership() has NO onlyOwner" rect rgb(255,243,224) Note over A,T: "Step 0 — seed: 5 BNB -> 111,291.83 ROI" A->>R: "swap 5 BNB -> BUSD -> ROI" R-->>A: "111,291.83 ROI" end rect rgb(255,235,238) Note over A,T: "Steps 1-2 — seize ownership, zero fees, exclude self" A->>T: "transferOwnership(self) [unguarded]" A->>T: "setTaxFeePercent(0) / setBuyFee(0,0) / setSellFee(0,0)" A->>T: "excludeFromReward(self) + 12 LP holders + token" Note over A: "balanceOf(self) now reads _tOwned" end rect rgb(232,245,233) Note over A,T: "Step 3 — plant foothold, withhold 100,000 ROI" A->>P: "transfer 11,291.83 ROI (tax 0)" Note over P: "pair ROI 4,354,304.53" end rect rgb(227,242,253) Note over A,T: "Steps 4-6 — arm 99% tax, flash-swap, repay" A->>T: "setTaxFeePercent(99)" A->>P: "swap(4,343,012.69 ROI out, data='3030')" P->>T: "transfer pair->attacker (99% taxed: only 43,430.13 lands)" T->>A: "pancakeCall(...)" A->>P: "repay: transfer 143,430.13 ROI (99% taxed: only 1,434.30 lands)" Note over A,T: "attacker _rOwned ballooned via reflections" end rect rgb(243,229,245) Note over A,T: "Steps 7-8 — re-include self, mint phantom balance, re-sync" A->>T: "setTaxFeePercent(0); includeInReward(self)" Note over T: "_tOwned[self]=0 but _rOwned kept -> balanceOf ~= 3,986,806 ROI" A->>P: "sync()" Note over P: "pair ROI reserve -> 325,973.38 (ROI now scarce/expensive)" end rect rgb(255,243,224) Note over A,T: "Step 9 — dump phantom ROI" A->>R: "swapExactTokensForETH(3,986,806.27 ROI -> BNB)" R-->>A: "167.91 BNB" end Note over A: "5 BNB in -> 168.33 BNB out (+163.33 BNB)"

State evolution (BUSD/ROI pool ROI reserve)#

flowchart TD S0["Stage 0 - Initial<br/>pair ROI 4,454,304.53<br/>pair BUSD 49,570.78"] S1["Stage 1 - After foothold transfer<br/>pair ROI 4,354,304.53<br/>attacker holds 100,000 ROI (excluded)"] S2["Stage 2 - After flash-swap + repay (99% taxed)<br/>pair ROI 4,454,304.53<br/>attacker balanceOf = 0, but _rOwned inflated"] S3["Stage 3 - After includeInReward(self)<br/>attacker balanceOf ~= 3,986,806 ROI (phantom)<br/>pair untouched yet"] S4["Stage 4 - After sync()<br/>pair ROI reserve -> 325,973.38<br/>ROI repriced high"] S5["Stage 5 - After dumping phantom ROI<br/>attacker +167.91 BNB<br/>pool drained"] S0 -->|"transfer 11,291.83 ROI (tax 0)"| S1 S1 -->|"flash-swap + 99% taxed repay"| S2 S2 -->|"includeInReward(self): keep _rOwned"| S3 S3 -->|"sync() to real (eroded) balance"| S4 S4 -->|"sell 3,986,806 phantom ROI"| S5 style S3 fill:#ffcdd2,stroke:#c62828,stroke-width:2px style S5 fill:#c8e6c9,stroke:#2e7d32

The flaw chain: access control + reflection bookkeeping#

flowchart TD Start(["transferOwnership(attacker) - PUBLIC, no onlyOwner"]) --> Own["attacker == owner()"] Own --> Setters["setTaxFeePercent / setBuyFee / setSellFee<br/>excludeFromReward / includeInReward unlocked"] Setters --> Excl["excludeFromReward(self)<br/>balanceOf reads _tOwned"] Excl --> Tax["setTaxFeePercent(99)"] Tax --> Flash["flash-swap ROI from pair"] Flash --> Reflect{"transfers via BUSD/ROI pair<br/>(pair != token.uniswapV2Pair)"} Reflect -->|"buy/sell branch never fires<br/>=> removeAllFee() skipped"| Apply99["99% reflected: _rTotal shrinks,<br/>attacker _rOwned value balloons"] Apply99 --> Incl["includeInReward(self)"] Incl --> Bug["_tOwned[self]=0 but _rOwned kept<br/>balanceOf = tokenFromReflection(_rOwned)"] Bug --> Mint(["~3.99M phantom ROI -> sold for 167.9 BNB"]) style Start fill:#fff3e0,stroke:#ef6c00 style Bug fill:#ffcdd2,stroke:#c62828,stroke-width:2px style Mint fill:#ffcdd2,stroke:#c62828,stroke-width:2px

Remediation#

  1. Restore the onlyOwner modifier on transferOwnership. This is the single change that prevents the whole attack:
    SOLIDITY
    function transferOwnership(address newOwner) public virtual onlyOwner {
        require(newOwner != address(0), "Ownable: new owner is the zero address");
        emit OwnershipTransferred(_owner, newOwner);
        _owner = newOwner;
    }
    
    Prefer inheriting OpenZeppelin's audited Ownable rather than a hand-rolled copy that can drop modifiers.
  2. Reconcile _rOwned on re-inclusion. includeInReward must recompute and keep a consistent balance, not silently discard _tOwned. The correct SafeMoon-era fix recomputes _rOwned[account] = _tOwned[account] * _getRate() before clearing _tOwned, so the holder's real balance is preserved exactly across the exclude→include round-trip and no phantom tokens can be conjured.
  3. Make reward-exclusion changes admin-gated behind a timelock, and never allow the same actor to flip exclusion state and execute a large self-transfer in the same transaction.
  4. Decouple fee/branch logic from a single hard-coded pair. Maintain a set of recognized pairs (or detect AMM pairs generically) so fee-exemption logic cannot be sidestepped simply by routing through a different pool than the token's configured uniswapV2Pair.
  5. Bound setTaxFeePercent. A 99 % reflection tax is never a legitimate operating value; cap the fee (e.g. require(taxFee <= 25)) so reflection accounting cannot be weaponized even by a compromised admin.

How to reproduce#

The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many unrelated PoCs that fail under forge test's whole-project build):

BASH
_shared/run_poc.sh 2022-09-ROI_exp -vvvvv
  • RPC: a BSC archive endpoint is required (fork block 21,143,795 is from Sept 2022). Most public BSC RPCs prune that state; use an archive provider.
  • Result: [PASS] testExploit(); the attacker's BNB balance goes from 5 → 168.33 BNB.

Expected tail:

CODE
[End] Attacker BNB Balance:: 168.331704163647122410

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 26.24s
Ran 1 test suite ... 1 tests passed, 0 failed, 0 skipped (1 total tests)

References:


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.