Reproduced Exploit
Swamp Finance Exploit — Atomic `earn()` Harvest-Sandwich on `StrategyBelt_Token`
Swamp Finance's StrategyBelt_Token is an auto-compounding yield strategy. It accounts user positions with the classic wantLockedTotal / sharesTotal share model:
Loss
+0.548 WBNB per reproduced cycle (≈ $110 at the time). The live attack repeated the cycle with flash-loaned +…
Chain
BNB Chain
Category
Flash Loan
Date
Nov 2023
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: 2023-11-SwampFinance_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SwampFinance_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/defi/sandwich-attack
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many PoCs that do not compile together, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: StrategyBelt_Token.sol.
Key info#
| Loss | +0.548 WBNB per reproduced cycle (≈ $110 at the time). The live attack repeated the cycle with flash-loaned + leveraged capital; SlowMist/MetaSec recorded the cumulative drain as the Swamp Finance incident. PoC @KeyInfo marks the total as "Unclear". |
| Vulnerable contract | StrategyBelt_Token (a Swamp Finance strategy) — 0xdA937DDD1F2bd57F507f5764a4F9550c750F7B31 |
| Entry farm | NativeFarm (Swamp MasterChef) — 0x33adbf5f1ec364a4ea3a5ca8f310b597b8afdee3 (pid 135) |
| Manipulated asset | beltBNB MultiStrategyToken (the strategy's want) — 0xa8Bb71facdd46445644C277F9499Dd22f6F0A30C |
| Attacker EOA | 0xfe2105e1317dfd6ed3887bf7882977c03cfebb7c |
| Attacker contract | 0x22ad9eef79615a1592e969bdf7b238a07281ab80 |
| Attack tx | 0x13e75878a21af9a9b2207f5d9e18f19a43083a9ffbac36df5a7d4d67a52c164f |
| Chain / fork block / date | BSC / 33,112,358 / ~Nov 2, 2023 |
| Compiler | Solidity v0.6.12, optimizer off (runs: 200) |
| Bug class | Permissionless atomic compounding (earn()) → share value-per-token inflation harvested in a single deposit→earn→withdraw sandwich, amplified by flash-loaned leverage |
TL;DR#
Swamp Finance's StrategyBelt_Token is an auto-compounding yield strategy. It accounts user
positions with the classic wantLockedTotal / sharesTotal share model:
- Deposit mints
sharesAdded = wantAmt · sharesTotal / wantLockedTotal(StrategyBelt_Token.sol:1204-1220). - Withdraw redeems
amount = shares · wantLockedTotal / sharesTotal(computed byNativeFarm.withdraw, NativeFarm.sol:1659). earn()harvests the external BELT farm, swaps the rewards back towant, and re-invests them via_farm(), which doeswantLockedTotal = wantLockedTotal.add(wantAmt)without minting any new shares (StrategyBelt_Token.sol:1246).
Adding want to wantLockedTotal while sharesTotal is unchanged is exactly how a vault credits
yield: it raises the value-per-share for everyone holding shares at that instant. The fatal flaw
is that earn() is permissionless and can be called atomically between a deposit and a withdrawal
in the same transaction (StrategyBelt_Token.sol:1293 —
function earn() public nonReentrant whenNotPaused, no onlyOwner/keeper gate).
So an attacker:
- Deposits a large position to capture a dominant fraction of
sharesTotal(75.3% in the PoC). - Calls
earn()to crystallise pending farm rewards intowantLockedTotalnow, instead of letting them stream to honest LPs over time. - Withdraws immediately at the freshly inflated value-per-share, pocketing the harvest yield that should have accrued to the LPs who were staked through the reward-accrual period.
To make a per-cycle skim worth the gas, the attacker borrows working capital: a DODO (DPP) flash loan of 3,100 WBNB + 150,000 BUSDT, plus a Venus loop (mint vUSDT collateral → borrow 500 BNB) — turning ~3,600 WBNB of transient capital into a single deposit→earn→withdraw round that returns 3,600.549 WBNB, a clean +0.548 WBNB, with all loans repaid in-tx.
Background — what the system does#
NativeFarm is Swamp's AutoFarm/MasterChef fork (NativeFarm.sol:1415).
Users deposit(pid, wantAmt) and the farm forwards the want to a per-pool strategy that
auto-compounds it. For pid 135 the strategy is StrategyBelt_Token, whose want is
beltBNB — itself a Belt Finance MultiStrategyToken
(MultiStrategyToken.sol:1419) that
wraps WBNB and farms it across Belt's sub-strategies.
The strategy holds two accounting scalars (StrategyBelt_Token.sol:1094-1095):
uint256 public wantLockedTotal = 0; // storage slot 15
uint256 public sharesTotal = 0; // storage slot 16
State read from the fork at block 33,112,358, right before the attacker's deposit (decoded from the trace's storage diffs):
| Scalar | Value (wei) | Meaning |
|---|---|---|
sharesTotal (slot 16) | 729,506,762,119,361,275,057 | ≈ 729.5 shares outstanding |
wantLockedTotal (slot 15) | 1,044,524,883,239,570,696,090 | ≈ 1,044.5 beltBNB managed |
Value-per-share ≈ 1044.5 / 729.5 ≈ 1.4318 beltBNB/share. Pending, not-yet-harvested BELT rewards
sat in the external Belt farm — value that should accrue gradually to existing share holders when
earn() is eventually run.
The vulnerable code#
1. Deposit mints shares against the current wantLockedTotal#
StrategyBelt_Token.sol:1186-1225:
function deposit(address _userAddress, uint256 _wantAmt)
public onlyOwner nonReentrant whenNotPaused returns (uint256)
{
IERC20(wantAddress).safeTransferFrom(msg.sender, address(this), _wantAmt);
...
uint256 sharesAdded = _wantAmt;
if (wantLockedTotal > 0) {
sharesAdded = _wantAmt
.mul(sharesTotal)
.mul(entranceFeeFactor)
.div(wantLockedTotal) // ← shares priced off CURRENT wlt
.div(entranceFeeFactorMax);
...
}
sharesTotal = sharesTotal.add(sharesAdded);
_farm();
return sharesAdded;
}
2. earn() — permissionless, credits yield into wantLockedTotal with NO new shares#
StrategyBelt_Token.sol:1293-1324
and _farm() 1231-1247:
function earn() public nonReentrant whenNotPaused { // ⚠️ NO access control / no cooldown
IMasterBelt(farmContractAddress).withdraw(pid, 0); // harvest BELT
uint256 earnedAmt = IERC20(earnedAddress).balanceOf(address(this));
earnedAmt = distributeFees(earnedAmt);
earnedAmt = buyBack(earnedAmt);
...
_safeSwap(... earnedToWantPath ...); // BELT → WBNB → want
lastEarnBlock = block.number;
_farm(); // re-invest harvested want
}
function _farm() internal {
...
uint256 wantAmt = IERC20(wantAddress).balanceOf(address(this));
IMasterBelt(farmContractAddress).deposit(pid, wantAmt);
wantLockedTotal = wantLockedTotal.add(wantAmt); // ⚠️ wlt ↑, sharesTotal unchanged
}
3. Withdraw redeems against the inflated wantLockedTotal#
NativeFarm.withdraw computes the redeemable want from the share ratio, then calls the strategy:
uint256 wantLockedTotal = IStrategy(pool.strat).wantLockedTotal();
uint256 sharesTotal = IStrategy(pool.strat).sharesTotal();
...
uint256 amount = user.shares.mul(wantLockedTotal).div(sharesTotal); // ← reads INFLATED wlt
if (_wantAmt > amount) { _wantAmt = amount; }
...
IStrategy(pool.strat).withdraw(msg.sender, _wantAmt); // pulls `amount` want back out
Root cause#
A share-based vault is only safe to credit yield (wantLockedTotal += harvest, shares unchanged)
when the action that triggers the credit is not controllable, in the same transaction, by a
freshly-entered depositor. Three design decisions compose into the bug:
earn()is permissionless (:1293). Anyone, at any time, can force the strategy to crystallise pending external rewards intowantLockedTotal. There is no keeper-only restriction and no per-block / cooldown guard (lastEarnBlockis recorded but never enforced)._farm()credits yield as a purewantLockedTotalincrease with no share minting (:1246). That is correct for streaming yield to long-term holders, but it means whoever holds shares at the instant of the credit captures the yield pro-rata — including someone who deposited one call earlier.- Deposit and withdraw use the spot
wantLockedTotal/sharesTotalratio with no time-lock. Sodeposit → earn → withdrawin one transaction lets the attacker (a) buy in before the pending rewards are recognised and (b) sell out after, harvesting the delta.
The result is a value-per-share jump that the attacker front-loads and immediately captures,
stealing the harvest yield that honest LPs earned by being staked across the reward-accrual window.
The Venus borrow and DODO flash loan are pure leverage — they let the attacker temporarily own a
huge fraction of sharesTotal, scaling the per-cycle skim.
Preconditions#
StrategyBelt_Token.earn()is callable (not paused) and pending BELT rewards exist in the Belt farm. In the PoC the harvest produced ~1.69 beltBNB of freshwant.- The strategy's deposit/withdraw path is reachable by an external caller.
NativeFarm.deposit/ withdraware public; the strategy's owndeposit/withdrawareonlyOwnerbut the owner isNativeFarmitself, so routing through the farm is the intended (and only) public path. - Working capital to acquire a dominant share fraction. The attacker used a DODO flash loan (3,100 WBNB + 150,000 BUSDT) + a Venus mint/borrow loop for 500 BNB, fully repaid in-tx — i.e. the attack is flash-loanable and needs ~zero seed capital (SwampFinance_exp.sol:83, :90-112).
Attack walkthrough (ground-truth numbers from the trace)#
All values are wei (1 WBNB / beltBNB = 1e18). Lines reference output.txt.
| # | Step | Call | Concrete numbers |
|---|---|---|---|
| 0 | Flash loan | DPPOracle.flashLoan(3100e18 WBNB, 150000e18 BUSDT) (:1687) | borrows 3,100 WBNB + 150,000 BUSDT |
| 1 | Enter Venus & lever | enterMarkets, vUSDT.mint(150000.15e18), vBNB.borrow(500e18) (:1721,:1737,:1805) | mints vUSDT collateral, borrows 500 BNB |
| 2 | Wrap BNB | WBNB.deposit{value: 500e18} → balance 3600.001 WBNB (:1944) | total dry powder = 3,600.001 WBNB |
| 3 | Buy beltBNB | beltBNB.deposit(3600.001e18 WBNB, 1) → mints 3189.8049e18 beltBNB (:1951) | beltBNB ppfs ≈ 1.1286 WBNB/beltBNB |
| 4 | Deposit into Swamp | NativeFarm.deposit(135, 3189.806e18 beltBNB) → strategy mints sharesAdded = 2225.565e18 (:2557,:2658 ret) | strat sharesTotal 729.5 → 2955.07, wantLockedTotal 1044.5 → 4234.33 |
| 5 | ⚠️ earn() | StrategyBeltToken.earn() harvests BELT, swaps to ~1.689 beltBNB, _farm() (:2664) | wantLockedTotal 4234.331 → 4236.020, sharesTotal unchanged |
| 6 | Withdraw all | NativeFarm.withdraw(135, type(uint).max) → amount = 2225.565e18 · 4236.020 / 2955.072 = 3190.291e18 beltBNB (:3440,:3508) | redeems 3190.291 beltBNB vs 3189.806 deposited = +0.4844 beltBNB |
| 7 | Unwrap & repay | beltBNB.withdraw(3190.291e18, 1) → 3600.549 WBNB (:3508); WBNB.withdraw(500), vBNB.repayBorrow(500), vUSDT.redeemUnderlying, repay DPP (:3813-3845) | got back 3,600.549 WBNB for 3,600.001 in |
| 8 | Profit | final exploiter WBNB balance 0.5491785 WBNB (:3998) | net +0.548 WBNB after all loans repaid |
Share-math reconciliation (exactly matches the trace):
Deposit: sharesAdded = 3189.806 · 729.5067 / 1044.5249 ≈ 2225.565 ✓ (slot 16: 729.5 → 2955.07)
earn(): wantLockedTotal 4234.331 → 4236.020 (+1.689 beltBNB harvest, shares flat)
Withdraw: amount = 2225.565 · 4236.020 / 2955.072 = 3190.291 beltBNB ✓ (observed exactly)
Skim: 3190.291 − 3189.806 = 0.4844 beltBNB → 0.548 WBNB after unwrap
The attacker owned 2225.565 / 2955.072 = 75.3 % of sharesTotal at the moment earn() fired,
so they captured the bulk of the 1.689-beltBNB harvest that should have streamed to the pre-existing
729.5 shares of honest LPs.
Profit accounting (WBNB)#
| Direction | Amount (WBNB) |
|---|---|
Deposited into beltBNB | 3,600.001 |
Withdrawn from beltBNB | 3,600.549 |
| Per-cycle gross skim | +0.548 |
| Seed capital (dealt) | 0.001 |
| Net profit (final balance − seed) | +0.548 |
DODO flash loan and Venus borrow are repaid to the wei inside the same transaction (:3813-3859), so the entire round is self-financed.
Diagrams#
Sequence of the attack#
The flaw inside earn() / share accounting#
Strategy state evolution (value-per-share)#
Remediation#
- Gate
earn()to a trusted keeper/role, or make harvesting non-atomic with user deposit/ withdraw. Ifearn()must stay public, enforce the recordedlastEarnBlock: require at least one block (or a real cooldown) between an account's deposit and anyearn()whose proceeds that account can redeem. - Decouple yield recognition from same-transaction redemption. Add a deposit/withdraw cooldown
(e.g. shares minted this block cannot be withdrawn until
block.number > depositBlock), so a freshly-entered position cannot capture a harvest it was not staked for. - Stream harvested yield instead of crediting it instantly. Vesting newly-harvested
wantintowantLockedTotallinearly over a window removes the step-change in value-per-share that the sandwich exploits (the standard "drip/lockedProfit" pattern used by Yearn-style vaults). - Apply meaningful entrance + exit fees sized above the maximum extractable per-block harvest,
so an atomic in-and-out is never profitable. (The strategy has
entranceFeeFactor/exitFeeFactorhooks but they were effectively ~0 at the fork block.) - Treat leverage as in-scope. Because the per-cycle skim scales with the attacker's share of the pool, assume an adversary can flash-borrow arbitrary capital (DODO/Venus here) and design the accounting so a single-transaction round is loss-making for the caller.
How to reproduce#
_shared/run_poc.sh 2023-11-SwampFinance_exp --mt testExploit -vvvvv
- RPC: a BSC archive node is required (fork block 33,112,358 is far in the past); most public
BSC RPCs prune that state.
foundry.tomlis configured with a working archive endpoint. - Result:
[PASS] testExploit(). Exploiter WBNB balance goes from0.001→0.549178…, i.e. +0.548 WBNB for the single reproduced cycle, with the DODO flash loan and Venus borrow repaid in-transaction.
Expected tail:
Ran 1 test for test/SwampFinance_exp.sol:SwampFinanceExploit
[PASS] testExploit() (gas: 3471696)
Exploiter WBNB balance before attack: 0.001000000000000000
Exploiter WBNB balance after attack: 0.549178564337632838
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: MetaSec — https://x.com/MetaSec_xyz/status/1720373044517208261 ; SlowMist Hacked — https://hacked.slowmist.io/ (Swamp Finance, BSC).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-11-SwampFinance_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SwampFinance_exp.sol.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Swamp Finance Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.