Reproduced Exploit
Fluid DEX v2: `_processNormalSupplyAction` permits an uncapped withdrawal
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 610. Standalone Foundry PoC and full write-up: 65649-fluid-processnormalsupplyaction-permits-an-uncapped-withdrawal_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/theft · vuln/logic
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable withdraw branch of
_processNormalSupplyActionis reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
In fluid-contracts/contracts/protocols/moneyMarket/core/operateModule/helpers.sol, the withdraw branch of _processNormalSupplyAction caps withdrawAmountRaw_ to the user's tokenRawSupply_ — but only for the position-storage update and health-factor check. It never re-caps supplyAmount_, the value it hands to LIQUIDITY.operate at the end of the branch. The vulnerable lines, reproduced verbatim:
} else {
withdrawAmountRaw_ = (((uint256(-supplyAmount_) * LC.EXCHANGE_PRICES_PRECISION) + 1) / supplyExchangePrice_) + 1; // rounded up so protocol is on the winning side
@> if (withdrawAmountRaw_ > tokenRawSupply_) withdrawAmountRaw_ = tokenRawSupply_; // added this check for safety
}
// ...
// Give the withdraw to the user ── supplyAmount_ is NOT capped, unlike withdrawAmountRaw_
LIQUIDITY.operate(token_, supplyAmount_, 0, to_, address(0), abi.encode(MONEY_MARKET_IDENTIFIER, NORMAL_WITHDRAW_ACTION_IDENTIFIER));
withdrawAmountRaw_ (raw units, used to decrement the stored position) is clamped to what the user actually supplied, so the storage update and health factor stay consistent. But supplyAmount_ (the token amount transferred out) is passed to LIQUIDITY.operate unchanged — so the protocol pays out the full requested amount regardless of the position size.
Why it's exploitable here#
Following the finding's worked example with supplyExchangePrice = 1e12:
- The attacker supplies a dust amount (
1e6). The position'stokenRawSupply_becomes~999998. - The attacker calls withdraw with
supplyAmount_ = -1000e18.withdrawAmountRaw_is computed as a huge number and capped down to999998(for storage), so the position update looks fine. supplyAmount_stays-1000e18.LIQUIDITY.operatetransfers the full1000e18to the attacker.- The attacker supplied
1e6and withdrew1000e18— a direct drain of honest depositors' liquidity.
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xce01759b…:
- L135 — Supply a dust amount: Attacker supplies 1e6 via
supply(), so the position'stokenRawSupply_becomes ~999998. - L148 — Enter withdraw():
withdraw(-1000e18, to_)requests roughly a million times more than was supplied. - L150 — Load tracked supply: Reads
tokenRawSupply_(~999998) — the only bound the branch will enforce. - L153 — Read exchange price:
_getExchangePricesreturnssupplyExchangePrice_ = 1e12, matching the finding's worked example. - L165 — Compute uncapped raw withdraw: Root cause:
withdrawAmountRaw_is derived from 1000e18; the next line (@>) caps only this raw value for storage/health —supplyAmount_is never re-capped. - L177 — Pay out uncapped supplyAmount_:
LIQUIDITY.operatetransfers the full uncapped 1000e18 to the attacker, draining honest depositors' reserve.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 65649-fluid-processnormalsupplyaction-permits-an-uncapped-withdrawal_exp
forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: supply 1e6, withdraw the full uncapped 1000e18, draining the reserve. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 65649-fluid-processnormalsupplyaction-permits-an-uncapped-withdrawal_exp (evm-hack-registry mirror).
- AuditVault finding: 610.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Fluid DEX v2:
_processNormalSupplyActionpermits an uncapped withdrawal". - 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.