Reproduced Exploit
Stakehouse Protocol — `withdrawETH` from `GiantMevAndFeesPool` can steal most of the ETH because `idleETH` is reduced before burning the LP token
1. GiantPoolBase.withdrawETH does, in this exact order: idleETH -= _amount; then lpTokenETH.burn(msg.sender, _amount); then a plain ETH transfer of _amount to msg.sender. 2. Burning triggers GiantLP's transfer hook, which calls
Chain
Other
Category
logic
Date
Nov 2022
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: 43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal. Standalone Foundry PoC and full write-up: 43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/wrong-condition · vuln/accounting/reward-inflation · vuln/logic/instruction-ordering
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal_exp.sol.
AuditVault taxonomy: lang/solidity · sector/farm · sector/liquid-staking · sector/options · sector/staking · sector/staking-pool · platform/code4rena · has/github · has/poc · severity/high · novelty/variant · genome: wrong-condition · locked-funds · variant · reward-accounting · frontrun-exposure
Key info#
| Impact | HIGH — any single withdrawETH call extracts a phantom reward on top of the withdrawer's own principal, funded from other depositors' still-locked share of the pool |
| Protocol | Stakehouse Protocol — GiantMevAndFeesPool / GiantPoolBase (liquid staking, "Giant Pool" aggregator) |
| Vulnerable code | GiantPoolBase.withdrawETH (contracts/liquid-staking/GiantPoolBase.sol#L57-L60) — idleETH -= _amount runs BEFORE lpTokenETH.burn(...) |
| Bug class | Wrong instruction order: an accounting decrement runs before the hook whose formula depends on it |
| Finding | Code4rena — Stakehouse Protocol, 2022-11 · #43031 (H-08) · reporter cccz |
| Report | 2022-11-stakehouse |
| Source | AuditVault |
| Status | Audit finding — confirmed by the Stakehouse team. Reproduced here as a standalone local PoC. |
| Compiler | ^0.8.24 (PoC); real code ^0.8.13 |
This is an audit finding, not a historical on-chain incident. The real
GiantMevAndFeesPool inherits GiantPoolBase (idle-ETH accounting) and
SyndicateRewardsProcessor (reward accrual); the PoC keeps the vulnerable
withdrawETH body verbatim — including the exact line ORDER that causes
the bug — plus a faithful reduction of the reward-accrual math that turns that
ordering into a real ETH extraction.
The report's own PoC first has to work around a separate, already-reported
bug (#43030/H-07 — GiantLP with a transferHookProcessor can't be burned) by
patching beforeTokenTransfer's _to branch with a zero-address guard: "You
should fix it first... it's a bug needed to be fixed and it's independent of
the current vulnerability." This synthetic applies the same pre-fix so H-08
can be demonstrated in isolation, exactly as the reporter did.
TL;DR#
GiantPoolBase.withdrawETHdoes, in this exact order:idleETH -= _amount;thenlpTokenETH.burn(msg.sender, _amount);then a plain ETH transfer of_amounttomsg.sender.- Burning triggers
GiantLP's transfer hook, which callsupdateAccumulatedETHPerLP()— andGiantMevAndFeesPooloverridestotalRewardsReceived()asbalance + totalClaimed - idleETH. - Because
idleETHwas already decremented by_amountbefore this formula runs, it reports_amountMORE "rewards received" than actually arrived — a phantom reward. - That phantom reward is distributed to the withdrawer's own (still pre-burn) LP balance in the very same call, funded straight out of the pool's real ETH balance — i.e., out of the OTHER depositors' still-locked principal.
withdrawETHthen pays the withdrawer their real principal too. Net result: a single, ordinary withdrawal call extracts principal plus a phantom reward equal to the withdrawn amount, shared proportionally by LP holders — in the two-depositor PoC, exactly half the withdrawn amount is stolen from the other depositor.
The vulnerable code#
The blamed function (verbatim, GiantPoolBase.withdrawETH):
function withdrawETH(uint256 _amount) external nonReentrant {
require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount");
require(lpTokenETH.balanceOf(msg.sender) >= _amount, "Invalid balance");
require(idleETH >= _amount, "Come back later or withdraw less ETH");
idleETH -= _amount; // @> VULN: runs BEFORE the burn below
lpTokenETH.burn(msg.sender, _amount);
(bool success,) = msg.sender.call{value: _amount}("");
require(success, "Failed to transfer ETH");
}
The override whose formula this ordering corrupts (GiantMevAndFeesPool.totalRewardsReceived):
function totalRewardsReceived() public view override returns (uint256) {
return address(this).balance + totalClaimed - idleETH;
}
Root cause#
totalRewardsReceived() is meant to answer "how much ETH has this contract
received from MEV/fees, excluding depositors' idle principal?" It computes
that by subtracting idleETH from the contract's balance. But withdrawETH
decrements idleETH for the FULL withdrawal amount before the burn's
reward-accrual hook (updateAccumulatedETHPerLP) reads
totalRewardsReceived(). For that one call, the formula sees a balance that
still includes the withdrawn ETH, but an idleETH that no longer accounts for
it — the difference (_amount) is misread as a freshly-arrived reward and
distributed to whoever is withdrawing, using their pre-burn LP balance.
Preconditions#
- At least one other LP holder shares the pool (their share of the phantom reward is what actually gets stolen — see the lone-depositor control test, where the pool simply can't cover the double-payment and the whole transaction reverts instead of anyone profiting).
- No special role or timing is required:
withdrawETHis the pool's own permissionless redemption function, called exactly as intended.
Attack walkthrough#
From output.txt:
userAdeposits 4 ETH.idleETH = 4 ether, pool balance4 ether.userBdeposits 4 ETH.idleETH = 8 ether, pool balance8 ether, total LP supply8 ether.userBcallswithdrawETH(4 ether)— redeeming only their own principal, nothing more.idleETHdrops to4 etherbeforelpTokenETH.burnfires the reward hook.totalRewardsReceived()reports4 etheras a phantom reward;accumulatedETHPerLPShareis inflated accordingly anduserBis paid2 etheragainst their still-pre-burn 4 ETH balance (half of the 8 ETH total supply's inflated share).- HARM:
userBends the call holding6 ETHfor a4 ETHdeposit — a2 ETHprofit extracted from a single ordinary withdrawal, paid for byuserA's now-under-collateralized 4 ETH claim (the pool's real balance drops to2 ETH). A control test with the recommended fix applied shows the same two-depositor scenario settles fairly (userBgets back exactly their4 ETH, the pool retains the full4 ETHbackinguserA's claim).
Impact#
Any depositor can extract more ETH than they put in on their very first withdrawal, at the expense of every other LP holder in the same Giant Pool — no special conditions, front-running, or repeated calls required. Left unpatched, whichever depositor withdraws first captures a share of everyone else's principal; the last depositor(s) to withdraw may find the pool unable to fully honor their claim.
Diagrams#
Remediation#
Per the report: move idleETH -= _amount to AFTER lpTokenETH.burn(...).
function withdrawETH(uint256 _amount) external nonReentrant {
require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount");
require(lpTokenETH.balanceOf(msg.sender) >= _amount, "Invalid balance");
require(idleETH >= _amount, "Come back later or withdraw less ETH");
- idleETH -= _amount;
lpTokenETH.burn(msg.sender, _amount);
+ idleETH -= _amount;
(bool success,) = msg.sender.call{value: _amount}("");
require(success, "Failed to transfer ETH");
}
How to reproduce#
cd ~/RustroverProjects/audits/evm-hack-registry/43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: all three tests PASS:
# test_exploit (self-contained Exploit: 2 depositors, 1 withdrawal, phantom reward)
# test_stealFromOtherDepositor (EOA rebuild mirroring the finding's own PoC shape)
# test_control_fixedOrderWithdrawIsFair (control: with the fix applied, withdrawal is fair)
PoC source: test/43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal_exp.sol
— the verbatim vulnerable withdrawETH line order inside a faithful reduction
of GiantPoolBase + GiantMevAndFeesPool + SyndicateRewardsProcessor, plus
the two-depositor orchestration and a control test using a fixed-order
variant.
Note:
beforeTokenTransferhere guards BOTH the_fromand_tobranches (the real contract is missing the_toguard — a separate, already-reported bug,#43030/H-07). This mirrors the reporter's own pre-fix so H-08 can be demonstrated cleanly, without the two findings entangling. The blamedwithdrawETHline order and thetotalRewardsReceivedformula are faithful to the finding.
Reference: finding #43031 (H-08) by cccz in the Code4rena Stakehouse Protocol review (Nov 2022) · curated by AuditVault
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal_exp (evm-hack-registry mirror).
- AuditVault finding: 43031-h-08-function-withdraweth-from-giantmevandfeespool-can-steal.
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.