DEV Community

Daniel
Daniel

Posted on

How a 1 USDC Position Let an Attacker Withdraw 800 USDC From Fluid MoneyMarket’s Shared Liquidity

A withdrawal cap only protects funds if every downstream layer uses the capped amount.

That was the problem in Fluid MoneyMarket’s normal supply withdrawal path.

When a user requests more than their position balance, MoneyMarket converts the request into raw units and caps withdrawAmountRaw_ to the position’s actual tokenRawSupply_. This keeps the internal position update within the user’s balance.

But the protocol then calls the Liquidity layer with the original, uncapped supplyAmount_.

The result is a mismatch between ownership accounting and the real token transfer.

In the proof of concept, a victim supplied 1,000 USDC and an attacker supplied only 1 USDC. The attacker then requested an 800 USDC withdrawal. MoneyMarket exhausted the attacker’s 1 USDC position in storage, while Liquidity transferred the full 800 USDC. The attacker ended with a 799 USDC net profit, and the victim’s later withdrawal reverted.

I reported the finding as High in the Sherlock Fluid DEX V2 contest, where it earned $34.

Public Sherlock submission

No oracle manipulation, reentrancy, governance compromise, privileged role, or unusual ERC20 behavior was required. The issue came from one value being capped for storage while another value was used for the external withdrawal.

The broken accounting invariant

MoneyMarket and Liquidity use different accounting layers, but they still need to agree on the economic amount being withdrawn.

The relevant invariant is:

Value withdrawn from Liquidity
<=
Value debited from the withdrawing user’s MoneyMarket position
Enter fullscreen mode Exit fullscreen mode

The vulnerable path violates that invariant because the storage debit is based on withdrawAmountRaw_, while the actual Liquidity call still depends on the original supplyAmount_.

That means MoneyMarket can correctly determine that a user owns only a small position and still authorize a much larger withdrawal against its pooled Liquidity balance.

Where the mismatch begins

The vulnerable logic is in _processNormalSupplyAction.

For a normal withdrawal, the user provides a negative supplyAmount_. MoneyMarket converts that amount into raw units and calculates withdrawAmountRaw_.

If the requested raw withdrawal exceeds the position’s current supply, the code caps it:

if (withdrawAmountRaw_ > tokenRawSupply_) {
    withdrawAmountRaw_ = tokenRawSupply_;
}
Enter fullscreen mode Exit fullscreen mode

Source: MoneyMarket operate helper

The cap itself is reasonable. It allows the protocol to consume at most the available position instead of passing an excessive raw amount into the storage update.

The vulnerability is that this new effective withdrawal amount is not propagated to the value later sent to Liquidity.

Storage uses the capped amount

The final raw amount reaches _updateStorageForWithdraw.

That helper rejects a raw withdrawal larger than the stored position:

if (tokenRawSupply_ < withdrawAmountRaw_) revert();
Enter fullscreen mode Exit fullscreen mode

It then subtracts only the amount passed to it:

tokenRawSupply_ =
    tokenRawSupply_ - withdrawAmountRaw_;
Enter fullscreen mode Exit fullscreen mode

Source: MoneyMarket storage withdrawal helper

Because _processNormalSupplyAction already capped withdrawAmountRaw_, this storage update succeeds even when the original user request was larger than the position.

So the internal accounting remains bounded by what the attacker actually owns.

Liquidity still receives the uncapped request

After the storage update, MoneyMarket calls:

LIQUIDITY.operate(
    token_,
    supplyAmount_,
    0,
    to_,
    ...
);
Enter fullscreen mode Exit fullscreen mode

Source: MoneyMarket Liquidity call

The important detail is that supplyAmount_ is still the original user supplied withdrawal request.

It is not recomputed from the capped withdrawAmountRaw_.

The protocol therefore applies one amount to the attacker’s position and another amount to the real asset movement.

This is not a precision or rounding edge case. The raw conversion successfully detects that the request exceeds the position and the code intentionally caps it. The failure happens afterward, when the external call goes back to the original input.

Why Liquidity can pay the larger amount

Liquidity tracks supply using:

_userSupplyData[msg.sender][token_]
Enter fullscreen mode Exit fullscreen mode

Source: Liquidity user module

During this flow, msg.sender at the Liquidity layer is the MoneyMarket contract.

Liquidity therefore does not see separate balances for Alice, Bob, or each MoneyMarket NFT. It sees MoneyMarket’s aggregated supply position.

That separation is expected. MoneyMarket is responsible for tracking how much of that aggregate position belongs to each user.

The problem is that after limiting the attacker’s internal debit, MoneyMarket still asks Liquidity to release the larger requested amount.

As long as MoneyMarket has enough aggregate supply at Liquidity and the configured withdrawal constraints permit the operation, Liquidity can fulfill it from the pooled position.

That is how funds backing other MoneyMarket suppliers become available to the attacker.

The PoC demonstrates the full economic impact

The Foundry proof uses a listed USDC normal supply market and two ordinary users.

The victim supplies:

uint256 victimSupply = 1_000e6;
Enter fullscreen mode Exit fullscreen mode

The attacker supplies:

uint256 attackerDeposit = 1e6;
Enter fullscreen mode Exit fullscreen mode

The attacker then chooses an explicit withdrawal larger than their position:

uint256 overWithdraw = 800e6;
Enter fullscreen mode Exit fullscreen mode

The exploit uses the normal operate() path:

moneyMarket.operate(
    attackerNftId,
    attackerPosIndex,
    abi.encode(
        -int256(overWithdraw),
        alice
    )
);
Enter fullscreen mode Exit fullscreen mode

The test confirms three separate facts.

First, the attacker receives the full requested amount:

assertEq(
    attackerBalAfterExploit - attackerBalAfterDeposit,
    overWithdraw,
    "attacker got overWithdraw"
);
Enter fullscreen mode Exit fullscreen mode

Second, the attacker’s internal MoneyMarket supply is fully exhausted:

assertEq(
    attackerRawAfterExploit,
    0,
    "attacker raw supply should be exhausted in storage"
);
Enter fullscreen mode Exit fullscreen mode

Third, the victim can no longer complete a full withdrawal afterward:

vm.expectRevert(stdError.arithmeticError);

moneyMarket.operate(
    victimNftId,
    victimPosIndex,
    abi.encode(
        type(int256).min,
        bob
    )
);
Enter fullscreen mode Exit fullscreen mode

The resulting state is:

Victim supplied
1,000 USDC

Attacker supplied
1 USDC

Attacker received
800 USDC

Attacker net profit
799 USDC

Attacker remaining raw supply
0

Victim withdrawal afterward
REVERTED
Enter fullscreen mode Exit fullscreen mode

This is the important proof boundary. The test does not merely show inconsistent storage. It shows an actual token transfer above the attacker’s deposited amount and downstream failure for another supplier.

The validated Foundry run completed with:

1 passed; 0 failed; 0 skipped
Enter fullscreen mode Exit fullscreen mode

The PoC did not modify the protocol contracts.

Why the protocol’s amount limits do not stop it

MoneyMarket also checks amounts through _verifyAmountLimits(int256).

That validation enforces configured minimum and maximum magnitudes. It does not enforce the user specific condition:

requested withdrawal
<=
position balance
Enter fullscreen mode Exit fullscreen mode

An attacker can therefore request more than their own position while still staying within protocol configured amount limits.

The exact maximum extractable amount per call depends on available pooled liquidity and Liquidity withdrawal constraints.

The PoC does not establish an unlimited drain under every configuration, and the finding does not require that claim. It proves that the protocol can transfer more underlying than it debits from the attacker’s position.

Why the withdraw all branch is different

The issue is specific to the normal explicit withdrawal amount path.

The report distinguishes it from the sentinel branch:

supplyAmount_ == type(int256).min
Enter fullscreen mode Exit fullscreen mode

That branch represents withdrawing the full available position.

There, MoneyMarket rewrites supplyAmount_ to the actual withdrawable amount before calling Liquidity. The value used for the external transfer therefore follows the position balance.

The vulnerable branch instead behaves like this:

User provides explicit negative withdrawal
        ↓
withdrawAmountRaw_ is capped
        ↓
Storage uses the capped amount
        ↓
supplyAmount_ remains unchanged
        ↓
Liquidity receives the original request
Enter fullscreen mode Exit fullscreen mode

This distinction matters because it isolates the bug to one concrete value propagation path rather than every MoneyMarket withdrawal.

Severity

The exploit is permissionless once normal system conditions exist.

The target token must be listed, MoneyMarket must have pooled supply for that token at Liquidity, and the attacker needs a MoneyMarket NFT with a normal supply position. Those are ordinary prerequisites for using the feature.

The attack does not rely on an oracle, callback, governance action, special token behavior, or compromised role.

The demonstrated impact is direct extraction from MoneyMarket’s pooled Liquidity position followed by a failed withdrawal for another supplier.

The maximum amount is still bounded by available pooled liquidity and configured Liquidity limits, so the PoC should not be read as proving that any arbitrary amount can always be drained in one transaction.

What it does prove is that an individual user’s balance is no longer the effective authorization boundary for the withdrawal.

That is the basis for the High severity assessment.

Fix the effective withdrawal amount once

The protocol should ensure that the value forwarded to Liquidity is derived from the same final amount used for the MoneyMarket storage debit.

If partial fulfillment is intentional, the safe flow is:

User requests withdrawal
        ↓
Convert to raw units
        ↓
Cap raw amount to position balance
        ↓
Convert the final capped raw amount
back into normal units
        ↓
Use that final amount for Liquidity
Enter fullscreen mode Exit fullscreen mode

The report recommends following the same conversion style used by the withdraw all path and rounding down when converting the capped raw amount back into normal units so the protocol cannot over withdraw.

The final adjusted amount should also be the amount checked by _verifyAmountLimits.

A simpler alternative is to revert whenever the requested withdrawal exceeds the position balance. That changes the user experience from partial fulfillment to rejection, but it also prevents the two layers from executing different withdrawal amounts.

The regression test should recreate a large victim position and a small attacker position, attempt an over balance withdrawal, and assert either that the operation reverts or that the attacker receives no more than the economic value removed from their position. The victim should remain able to withdraw afterward.

The broader audit lesson

This finding is a useful example of a value propagation bug.

A security check can be correct where it is written and still fail to protect funds if the corrected value is not carried through the rest of the call chain.

For multi layer accounting flows, it is worth tracing the same user controlled value through every transformation:

User input
→ normal amount
→ raw amount
→ capped raw amount
→ storage debit
→ external protocol argument
→ actual token transfer
Enter fullscreen mode Exit fullscreen mode

Here, MoneyMarket correctly discovered that the attacker did not own the full requested amount and protected its own storage accordingly.

The protection failed because the final Liquidity call returned to the original request instead of using the adjusted value.

The cap protected the accounting record, but not the pooled assets.

Conclusion

Fluid MoneyMarket’s normal supply withdrawal path used two different effective withdrawal amounts inside the same transaction.

withdrawAmountRaw_ was capped to the user’s real tokenRawSupply_, so MoneyMarket storage consumed only the available position. But LIQUIDITY.operate() still received the original supplyAmount_, allowing the external transfer to exceed the value debited from that user.

Because Liquidity accounts for the MoneyMarket contract as an aggregated supplier, the excess can be paid from pooled funds backing other MoneyMarket positions.

The PoC demonstrated that this mismatch was economically exploitable and could leave another supplier unable to withdraw.

The invariant for the fix is simple:

The amount transferred by Liquidity must never exceed the economic amount debited from the withdrawing user’s MoneyMarket position.

Once a withdrawal amount is adjusted, every downstream accounting and transfer operation must use that same effective value.

Top comments (0)