Calculated from recorded token losses using historical USD prices at the incident time.
0xbf5b2d22fa88965ddfc6e6d685fc7cfc683340c49e126386759ed9e4027b14150xa62f9c5af106feee069f38de51098d9d81b90572Ethereum0x396abf9ff46e21694f4ef01ca77c6d7893a017b2EthereumOn Ethereum mainnet transaction 0xbf5b2d22fa88965ddfc6e6d685fc7cfc683340c49e126386759ed9e4027b1415 in block 18523441, an unprivileged adversary used an Aave flash loan to mint STONE, push StakeStone's idle ETH into strategies with rollToNextRound(), and then immediately burn the freshly minted STONE through instantWithdraw(). The key failure is that StakeStone's StoneVault computed a share-based withdrawal entitlement, but then paid the raw value returned by StrategyController.forceWithdraw() even when that downstream return exceeded the requested shortfall. The trace shows the vault asked strategies for 8579775372585181673317 wei and accepted 8617073005336909691789 wei back, so the vault overpaid by 37297632751728018472 wei, or 37.297632751728018472 ETH.
The attacker cluster consisted of sender EOA 0x9abe851bcc4fd1986c3d1ef8978fad86a26a0c57 and flash-loan receiver contract 0x9c52c485edd3d22847a1614b8988fbf520b33047. The receiver contract finished the transaction with 3 ETH, while the sender EOA paid 0.119363317574586483 ETH in gas.
StakeStone's StoneVault is a round-based ETH vault that mints shares. User share pricing depends on the vault's idle ETH in plus the strategy value reported by .
STONEAssetsVaultStrategyController.getAllStrategiesValue()Two public entrypoints matter for the incident:
rollToNextRound() rebalances idle ETH into the configured strategy set and advances the round.instantWithdraw(uint256 amount, uint256 shares) burns shares, computes the caller's ETH entitlement, and sources any shortfall from strategies through StrategyController.forceWithdraw(uint256).That design only remains safe if the amount paid to the caller is capped by the caller's entitlement. In StakeStone's implementation, that cap is missing across the StoneVault -> StrategyController -> Strategy boundary.
This is an ATTACK-class ACT issue caused by broken cross-module accounting. StoneVault.instantWithdraw() correctly computes a share-based ETH entitlement from burned STONE, but it does not enforce that the later strategy-sourced payout stays within that entitlement. Instead, once idle ETH is exhausted, the vault asks StrategyController.forceWithdraw() for the remaining shortfall and then blindly adds the controller's returned value to actualWithdrawn.
StrategyController.forceWithdraw() is itself an unchecked aggregation layer. It walks every configured strategy, calls each strategy's instantWithdraw(withAmount), sums the returned amounts, and repays all ETH back into AssetsVault. Nothing in that function verifies that the aggregate matches the requested _amount, and nothing in StoneVault caps the value afterward.
The critical invariant is straightforward: for any instant withdrawal, ETH transferred out of StoneVault must be less than or equal to the caller's share-based entitlement. The code-level breakpoint is the addition of the unchecked actualAmount returned from controller.forceWithdraw(ethAmount) inside StoneVault.instantWithdraw().
The relevant victim-side code from the verified StakeStone source is:
Verified StoneVault::instantWithdraw logic:
uint256 ethAmount = VaultMath.sharesToAsset(_shares, sharePrice);
stoneMinter.burn(msg.sender, _shares);
if (ethAmount <= idleAmount) {
actualWithdrawn = actualWithdrawn + ethAmount;
} else {
actualWithdrawn = actualWithdrawn + idleAmount;
ethAmount = ethAmount - idleAmount;
StrategyController controller = StrategyController(strategyController);
uint256 actualAmount = controller.forceWithdraw(ethAmount);
actualWithdrawn = actualWithdrawn + actualAmount;
}
Verified StrategyController::forceWithdraw and _forceWithdraw logic:
function forceWithdraw(uint256 _amount) external onlyVault returns (uint256 actualAmount) {
uint256 balanceBeforeRepay = address(this).balance;
if (balanceBeforeRepay >= _amount) {
_repayToVault();
actualAmount = balanceBeforeRepay;
} else {
actualAmount =
_forceWithdraw(_amount - balanceBeforeRepay) +
balanceBeforeRepay;
}
}
function _forceWithdraw(uint256 _amount) internal returns (uint256 actualAmount) {
uint256 length = strategies.length();
for (uint i; i < length; i++) {
address strategy = strategies.at(i);
uint256 withAmount = (_amount * ratios[strategy]) / ONE_HUNDRED_PERCENT;
if (withAmount != 0) {
actualAmount =
Strategy(strategy).instantWithdraw(withAmount) +
actualAmount;
}
}
_repayToVault();
}
The bug is the absence of a boundary check after forceWithdraw() returns. StoneVault computes ethAmount as the caller's legitimate shortfall, but then pays actualAmount, which is only a downstream strategy return value and may be larger than ethAmount.
The incident trace proves that this exact path was taken. Human-labeled excerpt from the seed transaction trace:
StoneVault::instantWithdraw(0, 8582162020025013545654)
...
StrategyController::forceWithdraw(8579775372585181673317)
...
AssetsVault::receive{value: 8617073005336909691789}()
emit WithdrawnFromStrategy(
param0: 0x9C52c485EdD3D22847A1614B8988Fbf520b33047,
param1: 8579775372585181673317,
param2: 8617073005336909691789,
param3: 4
)
AssetsVault::withdraw(0x9C52c485EdD3D22847A1614B8988Fbf520b33047, 8617073005336909691789)
param1 is the requested strategy shortfall and param2 is the actual returned amount accepted by the vault. Their difference is:
8617073005336909691789
-8579775372585181673317
= 37297632751728018472 wei
That 37.297632751728018472 ETH delta is the deterministic over-withdrawal caused by the uncapped return path. The exploit conditions were public and permissionless at the transaction pre-state: the attacker only needed public flash liquidity, public StoneVault entrypoints, and the live strategy configuration already deployed by the protocol.
The adversary realized the bug in a single transaction:
0x9abe851bcc4fd1986c3d1ef8978fad86a26a0c57 called attacker contract 0x9c52c485edd3d22847a1614b8988fbf520b33047.AavePool.flashLoanSimple() to borrow 8600 WETH, then unwrapped it to ETH.8600 ETH into StoneVault, minting 8582.162020025013545654 STONE.rollToNextRound(), which moved 8600.005 ETH into StakeStone's strategies and advanced the vault from round 3 to round 4 with share price 0.999721906037865163.STONE and called instantWithdraw(0, 8582162020025013545654).StoneVault computed a strategy shortfall of 8579.775372585181673317 ETH, but the controller returned 8617.073005336909691789 ETH from strategies and the vault paid all of it to the attacker contract.4.3 ETH Aave premium, leaving 3 ETH at the receiver contract. The sender EOA separately paid 0.119363317574586483 ETH in gas.This flow aligns with the three lifecycle stages in the underlying analysis:
rollToNextRound().instantWithdraw() and repayment of the flash loan.The direct protocol accounting loss is 37.297632751728018472 ETH, encoded on-chain as "37297632751728018472". That value is the excess returned by strategies over the caller's legitimate share-based entitlement.
The attacker cluster's realized profit was smaller because the exploit also had to cover the 4.3 ETH Aave premium and the sender EOA's gas spend. The receiver contract ended with exactly 3 ETH, and the sender EOA lost 0.119363317574586483 ETH in native balance for gas, leaving a net cluster gain of 2.880636682425413517 ETH after gas. Even so, the underlying vault loss is the larger 37.297632751728018472 ETH overpayment extracted from StakeStone.
The violated security principles are:
0xbf5b2d22fa88965ddfc6e6d685fc7cfc683340c49e126386759ed9e4027b1415StoneVault: 0xa62f9c5af106feee069f38de51098d9d81b90572StrategyController: 0x396abf9ff46e21694f4ef01ca77c6d7893a017b2/workspace/session/artifacts/collector/seed/1/0x7122985656e38bdc0302db86685bb972b145bd3c//workspace/session/artifacts/collector/seed/1/0xbf5b2d22fa88965ddfc6e6d685fc7cfc683340c49e126386759ed9e4027b1415/trace.cast.log/workspace/session/artifacts/collector/seed/1/0xbf5b2d22fa88965ddfc6e6d685fc7cfc683340c49e126386759ed9e4027b1415/balance_diff.json