Reproduced Exploit
FloorDAO Exploit — Self-Donated Rebase Inflates the Staking `index`, Over-Paying gFLOOR Redemptions
FloorStaking is an OlympusDAO-V2 fork. Stakers can hold their position either as sFLOOR (a rebasing token, balance grows each epoch) or as gFLOOR (a non-rebasing "wrapped" token whose FLOOR value is tracked by an ever-growing index). The exploit abuses the index:
Loss
~40.15 WETH (~$64K at the time) — drained from the FLOOR/WETH UniswapV3 pool
Chain
Ethereum
Category
Arithmetic / Overflow
Date
Sep 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-09-FloorDAO_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/FloorDAO_exp.sol.
Vulnerability classes: vuln/logic/reward-calculation · vuln/arithmetic/precision-loss
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable sources: FloorStaking, sFLOOR, gFLOOR.
Key info#
| Loss | |
| Vulnerable contract | FloorStaking — 0x759c6De5bcA9ADE8A1a2719a31553c4B7DE02539 |
| Vulnerable dependency | sFLOOR rebase token — 0x164AFe96912099543BC2c48bb9358a095Db8e784 |
| Victim pool | FLOOR/WETH UniV3 1% pool — 0xB386c1d831eED803F5e8F274A59C91c4C22EEAc0 |
| Liquidity source for flash | same UniV3 pool (flash() of all its FLOOR) |
| Attacker EOA | 0x4453aed57c23a50d887a42ad0cd14ff1b819c750 |
| Attacker contract | 0x6ce5a85cff4c70591da82de5eb91c3fa38b40595 |
| Attack tx | 0x1274b32d4dfacd2703ad032e8bd669a83f012dde9d27ed92e4e7da0387adafe4 |
| Chain / block / date | Ethereum / 18,068,772 / Sep 5, 2023 |
| Compiler | FloorStaking v0.7.5, optimizer 200 runs (OlympusDAO V2 fork) |
| Bug class | Rebase-accounting manipulation — staked principal mis-counted as reward profit, inflating the share index |
TL;DR#
FloorStaking is an OlympusDAO-V2 fork. Stakers can hold their position either as sFLOOR
(a rebasing token, balance grows each epoch) or as gFLOOR (a non-rebasing "wrapped" token whose
FLOOR value is tracked by an ever-growing index). The exploit abuses the index:
- The attacker flash-borrows essentially the entire FLOOR supply (152,089.8 FLOOR) from the
FLOOR/WETH UniV3 pool and stakes it into
FloorStakingchoosing_rebasing = false, receivinggFLOORpriced at the current index. - The attacker immediately calls
unstake(..., _trigger = true, _rebasing = false). The_trigger = trueflag makesunstakecallrebase()(Staking.sol:178-179). rebase()'s reward math isepoch.distribute = FLOOR.balanceOf(staking) − circulatingSupply(Staking.sol:233-239). Because the attacker just deposited ~all FLOOR, the contract's FLOOR balance vastly exceeds the staked (sFLOOR) supply, so the attacker's own principal is booked as a giant "reward" and distributed tosFLOORholders on the next rebase — bumping theindex.gFLOOR → FLOORredemption isbalanceFrom(g) = g · index / 1e18(gFLOOR.sol:114-116). The attacker mintedgFLOORat the old index but redeems at the new, inflated index, so it withdraws more FLOOR than it deposited.- Repeat 17×. The index climbs
3.509e9 → 3.881e9(≈ +10.6%) and the attacker walks away with 14,606.1 FLOOR of pure profit after repaying the flash loan + fee, then swaps it for 40.15 WETH.
The root flaw is the OlympusDAO "staking principal vs. reward" confusion: rebase() cannot
distinguish new staked principal from yield earned by the treasury, and unstake lets the caller
trigger that rebase in the same transaction after loading the contract with borrowed principal.
Background — FloorDAO's OlympusDAO-V2 staking#
FLOOR (9-decimal) can be staked for one of two receipt tokens:
sFLOOR— a rebasing token (9 decimals). 1sFLOOR≈ 1 FLOOR, and balances grow each epoch as the treasury distributes rewards. Internally it tracks "gons"; rebasing multiplies everyone's gon balance up.gFLOOR— a non-rebasing governance token (18 decimals). Its FLOOR value is tracked by anindex. The conversions are- mint (FLOOR → gFLOOR):
balanceTo(a) = a · 1e18 / index(gFLOOR.sol:123-125) - redeem (gFLOOR → FLOOR):
balanceFrom(g) = g · index / 1e18(gFLOOR.sol:114-116)
- mint (FLOOR → gFLOOR):
The index itself is derived from sFLOOR rebase growth — gFLOOR.index() simply forwards to
sFLOOR.index() (gFLOOR.sol:105-107,
sFLOOR.sol:279-281). Each rebase grows
sFLOOR._totalSupply, which grows the index, which raises the FLOOR redeemed per gFLOOR.
On-chain state at fork block 18,068,772 (read from the trace):
| Parameter | Value | Source |
|---|---|---|
index at start | 3,509,076,800 (3.509 in 9-dec terms) | output.txt:81 |
epoch.number | 1322, length 28,800 s (8 h) | output.txt:135 |
| FLOOR held by staking contract | 1,356,712,874,641,176 (1.357M FLOOR) | output.txt:43 |
| FLOOR in the UniV3 pool (flash source) | 152,089,813,098,499 (152,089.8 FLOOR) | output.txt:38 |
| WETH in the UniV3 pool (the prize) | 535.25 WETH | output.txt:34 |
gFLOOR totalSupply | 337,812.48 gFLOOR | output.txt:78 |
The vulnerable code#
1. unstake lets the caller trigger a rebase in-line, then redeems gFLOOR at the new index#
function unstake(address _to, uint256 _amount, bool _trigger, bool _rebasing)
external returns (uint256 amount_)
{
amount_ = _amount;
uint256 bounty;
if (_trigger) {
bounty = rebase(); // ⚠️ attacker-controlled: fires a rebase NOW
}
if (_rebasing) {
sFLOOR.safeTransferFrom(msg.sender, address(this), _amount);
amount_ = amount_.add(bounty);
} else {
gFLOOR.burn(msg.sender, _amount); // burn gFLOOR
amount_ = gFLOOR.balanceFrom(_amount).add(bounty); // ⚠️ priced at the POST-rebase index
}
require(amount_ <= FLOOR.balanceOf(address(this)), "Insufficient FLOOR balance in contract");
FLOOR.safeTransfer(_to, amount_);
}
2. rebase books contract FLOOR balance minus staked supply as "reward to distribute"#
function rebase() public returns (uint256) {
uint256 bounty;
if (epoch.end <= block.timestamp) {
sFLOOR.rebase(epoch.distribute, epoch.number); // apply LAST epoch's distribute → grows index
epoch.end = epoch.end.add(epoch.length);
epoch.number++;
if (address(distributor) != address(0)) {
distributor.distribute();
bounty = distributor.retrieveBounty();
}
uint256 balance = FLOOR.balanceOf(address(this)); // ⚠️ includes attacker's just-deposited principal
uint256 staked = sFLOOR.circulatingSupply();
if (balance <= staked.add(bounty)) {
epoch.distribute = 0;
} else {
epoch.distribute = balance.sub(staked).sub(bounty); // ⚠️ principal mis-booked as reward
}
}
return bounty;
}
The protocol's intended invariant is "FLOOR held by the staking contract beyond the staked sFLOOR
supply is yield the treasury earned, so distribute it." That assumption is false the instant a user
deposits, because the deposit transiently makes balance ≫ staked. The deposit path
(stake, Staking.sol:88-115) pulls the
FLOOR in before anyone can re-measure, and _rebasing = false mints gFLOOR whose redemption value
is governed by the very index this surplus will inflate.
3. The conversion asymmetry that turns the index bump into profit#
// gFLOOR.sol — mint at index_old, redeem at index_new
function balanceTo(uint256 a) returns (uint256) { return a * 1e18 / index(); } // deposit
function balanceFrom(uint256 g) returns (uint256) { return g * index() / 1e18; } // withdraw
If index rises by factor r between deposit and withdraw, then
balanceFrom(balanceTo(a)) = a · index_new / index_old = a · r > a. The extra FLOOR is paid out of
the contract's balance, i.e. ultimately out of other stakers' and the treasury's FLOOR.
Root cause#
rebase() defines reward as contractBalance − stakedSupply. This conflates two economically
opposite quantities:
- Treasury yield (FLOOR minted/sent to the staking contract as genuine reward) — should be distributed.
- User-deposited principal (FLOOR a staker just transferred in) — must not be distributed; it is owed back to that staker.
Because stake() transfers principal in and unstake(_trigger=true) re-runs rebase() in the same
call, an attacker can:
- Spike
contractBalancewith borrowed principal, - Have
rebase()book that spike as a reward and grow the shareindex, then - Redeem
gFLOORminted at the old index for FLOOR valued at the new index.
Four design facts compose into the exploit:
_triggeris caller-controlled and permissionless. Anyone can force a rebase at the exact moment the contract is loaded with their own principal.- Reward = balance − staked is a snapshot of attacker-manipulable state. Flash loans let the
attacker make
balancearbitrarily large for one transaction. - gFLOOR mint/redeem use the live index with no per-position index anchor. A position minted at
index_oldis redeemed atindex_new; the protocol never records the index at which a given gFLOOR was created, so it cannot detect that the redemption over-pays. - The flash-loan source is the very pool that holds the WETH prize. The attacker borrows FLOOR from the FLOOR/WETH pool, profits in FLOOR, repays FLOOR + fee, and swaps the surplus FLOOR back into the pool's WETH — all atomically.
Preconditions#
- The staking contract must hold less FLOOR than it can be flash-spiked with, and the attacker must be
able to make
contractBalance > circulatingSupplyafter depositing — satisfied by flash-borrowing ~all FLOOR. The PoC's loop only proceeds whilebalanceAttacker + balanceStaking > circulatingSupply(FloorDAO_exp.sol:63) — i.e. while there is enough surplus to keep inflating the index. epoch.end <= block.timestampso thatrebase()actually advances an epoch and applies the previousepoch.distribute. In the live attack the epoch boundary had naturally passed; the PoC relies on the fork being pastepoch.endat block 18,068,772.- A FLOOR flash-loan source. The FLOOR/WETH UniV3 pool provides one via
flash(), and conveniently also holds the WETH that becomes the realized profit. - No warmup obstacle:
stake(..., _claim = true)withwarmupPeriod == 0sends the receipt immediately (Staking.sol:96-97).
Step-by-step attack walkthrough (ground-truth numbers from the trace)#
The attacker contract takes a FLOOR flash loan and, inside the callback, runs a 17-iteration stake→unstake loop, each iteration nudging the index up. All figures below are read directly from output.txt.
Iteration 1 (representative — output.txt:63-230)#
| # | Action | Concrete values | Effect |
|---|---|---|---|
| 0 | Flash-borrow FLOOR from the UniV3 pool | flash(amount1 = 152,089,813,098,498) = 152,089.8 FLOOR (:30) | Attacker holds ~all FLOOR. |
| 1 | stake(152,089.8 FLOOR, _rebasing=false, _claim=true) | inner rebase(0,1322): epoch advances, index unchanged 3,509,076,800; mints gFLOOR = 152089813098498·1e18/3509076800 = 43,341,830,848,073,202,615,571 (43,341.83 gFLOOR) (:122-123) | gFLOOR minted at the OLD index. rebase sets epoch.distribute = balance−staked = 1,509,196,738,915,154 − 1,375,877,722,515,691 = 133,319,016,399,463 (:106-118). |
| 2 | unstake(43,341.83 gFLOOR, _trigger=true, _rebasing=false) | inner rebase(133319016399463, 1323) applies the surplus → index jumps 3,509,076,800 → 3,815,252,590 (:163); balanceFrom(43,341.83 gFLOOR) = 43341830848073202615571·3815252590/1e18 = 165,360,032,398,453 = 165,360.0 FLOOR (:214-217,:220) | Redeemed at the NEW index. Deposited 152,089.8 FLOOR, received 165,360.0 FLOOR → +13,270.2 FLOOR in one pass. |
The index ratio 3,815,252,590 / 3,509,076,800 = 1.08725 exactly matches the FLOOR-out / FLOOR-in
ratio 165,360,032,398,453 / 152,089,813,098,498 = 1.08725 — confirming the entire gain is the index
inflation, to the wei.
The 17-iteration loop (FloorDAO_exp.sol:57-73)#
Each subsequent iteration re-stakes the (now larger) FLOOR balance and unstakes after another triggered rebase. The surplus shrinks as the index converges, so the per-epoch rebaseAmount tapers:
| Epoch | rebaseAmount | index after | source |
|---|---|---|---|
| 1322 | 0 | 3,509,076,800 | :84 |
| 1323 | 133,319,016,399,463 | 3,815,252,590 | :163 |
| 1325 | 14,058,617,247,638 | 3,847,539,119 | :352 |
| 1327 | 2,188,149,309,569 | 3,852,564,346 | :541 |
| … | … | … | |
| 1353 | 881,648,383,985 | 3,879,140,595 | (tail) |
| 1355 | 882,089,258,242 | 3,881,166,370 | (tail) |
Final index 3,881,166,370 vs start 3,509,076,800 ⇒ ≈ +10.6% cumulative inflation.
Cash-out (output.txt:3275-3308)#
- After repaying the flash loan (
flashAmount + fee1, fee = 1% = 1,520,898,130,985 FLOOR — output.txt:72-end of flash) the attacker retains 14,606,145,072,279 = 14,606.15 FLOOR (:231,:3271). - It then swaps that FLOOR into the same UniV3 pool:
Pool::swap(..., false, 14606145072279, ...)returns 40,146,353,823,753,349,478 = 40.146 WETH (:3275-3308).
Profit / loss accounting#
| Item | Amount |
|---|---|
| Flash-borrowed FLOOR | 152,089.8 FLOOR |
| Flash fee (1%) | 1,520.9 FLOOR |
| FLOOR repaid to pool | 153,610.7 FLOOR |
| FLOOR retained after loop | 14,606.15 FLOOR |
| Final swap → WETH | 40.146 WETH |
| Net attacker profit | ≈ 40.15 WETH |
The 14,606 FLOOR profit is drawn from the staking contract's FLOOR balance — i.e. treasury reserves and other stakers' principal — because the inflated index made the contract pay out more FLOOR than the attacker ever deposited.
Diagrams#
Sequence of one stake → unstake cycle#
Why the rebase over-pays (state view)#
Index inflation vs. the gFLOOR round-trip#
Remediation#
- Never derive "reward" from a raw balance snapshot.
rebase()must compute distributable yield from an explicitly accounted source (treasury transfers, a dedicated reward escrow), not fromFLOOR.balanceOf(this) − stakedSupply. As written, any FLOOR transferred in for any reason — a deposit in flight, a donation, a flash-loan spike — is mistaken for yield. - Do not let
unstake/staketrigger a rebase that prices the same call's redemption. Either remove the caller-controllable_triggerrebase, or snapshot the index before processing the user's principal and use that snapshot for the mint/redeem in the same transaction. - Anchor each gFLOOR position to the index at which it was created (or require a warmup/epoch
delay between stake and unstake), so a position minted at
index_oldcannot be redeemed atindex_newwithin the same transaction. - Settle deposits net of any in-flight rebase.
stakealready does_amount.add(rebase())for the rebase bounty; the deeper fix is to ensure the deposited principal is excluded from the reward base used to compute the nextepoch.distribute. - Add a sanity invariant: total FLOOR redeemable across all sFLOOR + gFLOOR holders must never
exceed
FLOOR.balanceOf(staking); a single-transaction index move large enough to break this should revert. (This is the same class of bug that hit OlympusDAO V2-fork stakers historically.)
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to whole-compile under forge test):
_shared/run_poc.sh 2023-09-FloorDAO_exp --mt testExploit -vvvvv
- RPC: an Ethereum archive endpoint is required (fork block 18,068,772, Sep 2023).
foundry.tomlpointsmainnetat an Infura archive endpoint; any archive node serving historical state at that block works. - Result:
[PASS] testExploit().
Expected tail (from output.txt):
Ran 1 test for test/FloorDAO_exp.sol:FloorStakingExploit
[PASS] testExploit() (gas: 4864916)
Logs:
floor token balance after exploit: 14606.145072279
weth balance after swap: 40.146353823753349478
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: FloorDAO post-mortem — https://medium.com/floordao/floor-post-mortem-incident-summary-september-5-2023-e054a2d5afa4 · PeckShieldAlert — https://twitter.com/PeckShieldAlert/status/1698962105058361392
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-09-FloorDAO_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
FloorDAO_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "FloorDAO 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.