Reproduced Exploit
QuantAMM: missing onlyVault lets a caller pop every depositor's FeeData
Bob deposits 10,000 BPT five times. onAfterRemoveLiquidity is supposed to be called by the vault. Without onlyVault, the attacker passes Bob's address in userData and the hook pops all five FeeData rows. Bob's removeLiquidityProportional then reverts WithdrawalByNonOwner, and the 50,000 BPT stays i…
Loss
50,000 BPT stuck in the hook after five deposits are deleted
Chain
Ethereum
Category
access-control
Date
Dec 2024
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: 49676-critical-malicious-user-can-delete-all-users-deposited-liqui. Standalone Foundry PoC and full write-up: 49676-critical-malicious-user-can-delete-all-users-deposited-liqui_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/access-control/missing-modifier · vuln/dos/frozen-funds
Key info#
| Field | Value |
|---|---|
| Loss | 50,000 BPT stuck in the hook after five deposits are deleted |
| Vulnerable contract | Hook.onAfterRemoveLiquidity |
| Attacker EOA | 0x4967600000000000000000000000000000000001 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2024-12 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | onAfterRemoveLiquidity trusts userData and has no onlyVault |
TL;DR#
Bob deposits 10,000 BPT five times. onAfterRemoveLiquidity is supposed to be called by the vault. Without onlyVault, the attacker passes Bob's address in userData and the hook pops all five FeeData rows. Bob's removeLiquidityProportional then reverts WithdrawalByNonOwner, and the 50,000 BPT stays in the hook. The fixed hook rejects the outsider call, the rows remain, and Bob withdraws.
Background#
CodeHawks QuantAMM, 20 December 2024 to 15 January 2025, reported by perseverance. The hook's onAfterRemoveLiquidity deletes per-user fee rows that record BPT amount and token id. The recommended fix is the onlyVault modifier.
The vulnerable code#
modifier onlyVault() {
if (onlyFromVault) require(msg.sender == vault, "vault");
_;
}
address user;
assembly {
user := shr(96, mload(add(userData, 32)))
}
// @> VULN: onlyVault is absent, so this pop deletes FeeData for the user encoded by the caller
rows.pop();
The vulnerable deployment sets onlyFromVault to false, so the modifier does not check the caller.
Root cause#
The user whose rows are deleted comes from caller-supplied userData, and the function is not restricted to the vault.
Preconditions#
- Bob has five
FeeDatarows of 10,000 BPT each. The tokens sit on the hook. - The caller can pass
abi.encodePacked(bob)andbptAmountInequal to the sum.
Attack walkthrough#
- Bob deposits 10,000 BPT five times. The hook holds 50,000.
- The attacker calls
onAfterRemoveLiquiditywith Bob packed intouserData. - The loop pops every row.
feeLength(bob)is 0. - Bob's removal sums the rows, finds 0, and reverts
WithdrawalByNonOwner. - The hook still holds 50,000 BPT.
- With
onlyVaulton, the same call reverts, the five rows stay, and Bob withdraws all 50,000.
Diagrams#
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 49676-critical-malicious-user-can-delete-all-users-deposited-liqui_exp (evm-hack-registry mirror).
- AuditVault finding: 49676-critical-malicious-user-can-delete-all-users-deposited-liqui.
Alerts & third-party analyses
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.