Reproduced Exploit
Victim pays 1,000 payout tokens and receives 1 wei of fees
claimFees pulls payoutAmount (1,000) of the payout token, then sends balanceOf of every fee token to the caller. There is no minimum. A first claim takes the 1,000 fee tokens. One wei is donated back so the balance is not zero. The victim's claim still pays 1,000 payout tokens and receives that 1 w…
Loss
Victim pays 1,000 payout tokens and receives 1 wei of the fee token. Control min-out reverts and keeps 1,000
Chain
Ethereum
Category
defi
Date
Oct 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: 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l. Standalone Foundry PoC and full write-up: 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/defi/slippage
Reproduction: self-contained synthetic Foundry reduction; see output.txt.
Key info#
| Field | Value |
|---|---|
| Loss | Victim pays 1,000 payout tokens and receives 1 wei of the fee token. Control min-out reverts and keeps 1,000 |
| Vulnerable contract | FeeCollector.claimFees |
| Attacker EOA | 0x1111111111111111111111111111111111111111 |
| Attack contract | Exploit |
| Attack tx | Exploit.run() |
| Chain / block / date | Ethereum model · block 0 · 2024-10 |
| Compiler | solc 0.8.27, cancun, via IR |
| Bug class | claimFees sells payoutAmount for the full fee balance and takes no minimum |
TL;DR#
claimFees pulls payoutAmount (1,000) of the payout token, then sends balanceOf of every fee token to the caller. There is no minimum. A first claim takes the 1,000 fee tokens. One wei is donated back so the balance is not zero. The victim's claim still pays 1,000 payout tokens and receives that 1 wei. A control claim that requires 1,000 fee tokens out reverts, and that payer keeps the 1,000.
Background#
Spearbit reviewed Berachain POL in October 2024. FeeCollector.claimFees is the Uniswap V3 factory-owner pattern: anyone may buy the collected fee tokens by paying payoutAmount. The report says a delayed or sandwiched call loses payoutAmount, and that donating dust after a claim keeps the path alive even if a zero transfer would revert. This reduction is that sequence in one transaction.
The vulnerable code#
function claimFees(address recipient, address[] calldata feeTokens) external {
require(payout.transferFrom(msg.sender, rewardReceiver, payoutAmount), "payout");
for (uint256 i; i < feeTokens.length; i++) {
Erc20 fee = Erc20(feeTokens[i]);
uint256 feeTokenAmountToTransfer = fee.balanceOf(address(this));
// @> VULN: the whole fee balance is transferred; the caller cannot set a minimum
require(fee.transfer(recipient, feeTokenAmountToTransfer), "fee");
}
}
The control payer uses the same function and then requires the fee-token balance increase to be at least 1,000. That requirement reverts the inner call, so the payout transfer is undone.
Root cause#
The payout is taken before the caller can see what the fee balance is, and the function does not take a minimum-out argument. Any non-zero dust balance makes the claim succeed.
Preconditions#
payoutAmountis 1,000. The collector starts with 1,000 fee tokens.- The first claim empties it. 1 wei is donated back before the victim's claim.
- The control collector holds 1 wei, and the control payer demands 1,000 fee tokens out.
- Both claims are in this transaction. No warp.
Attack walkthrough#
- The victim receives 1 wei of the fee token (output.txt:9).
- The victim's payout balance is 0 (output.txt:11).
- The control payer still holds 1000000000000000000000 (output.txt:13).
[PASS](output.txt:6).
Diagrams#
Impact#
The victim pays the full 1,000 payout tokens and receives 1 wei of fees. The control path, which refuses a fill below 1,000 fee tokens, does not pay. The loss is the 1,000 payout tokens.
Remediation#
Take a minAmounts array and revert when a fee token's balance is below the caller's minimum, before pulling payoutAmount.
- require(fee.transfer(recipient, feeTokenAmountToTransfer), "fee");
+ require(feeTokenAmountToTransfer >= minAmounts[i], "slippage");
+ require(fee.transfer(recipient, feeTokenAmountToTransfer), "fee");
Check the minimum before transferFrom of the payout token, so a failing check does not depend on a later revert.
How to reproduce#
cd 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l_exp
forge test -vv
Sources#
- AuditVault finding: 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l
- Spearbit, Berachain POL, October 2024: https://github.com/spearbit/portfolio/blob/master/pdfs/Berachain-Pol-Spearbit-Security-Review-October-2024.pdf
FeeCollector.claimFeesas described atsrc/pol/FeeCollector.solline 91 in the report
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l_exp (evm-hack-registry mirror).
- AuditVault finding: 52868-feecollectorclaimfees-lacks-slippage-protection-leading-to-l.
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.