Reproduced Exploit
Blueberry: `withdraw()` equity check bypassed by a stale once-per-block precompile
Chain
Other
Category
untagged
Date
Jan 1970
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: Blueberry-security-review_2025-03-12. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes: vuln/theft · vuln/frozen-funds · vuln/logic
Reproduction: a faithful minimal reproduction of the vulnerable finding — the vulnerable
withdraw()equity check and_vaultEquity()precompile read are reproduced verbatim (marked@>) with faithful minimal doubles; local deploy, no fork.
Root cause#
In VaultEscrow, withdraw() guards the requested amount with require(assets_ <= _vaultEquity(), ...), and _vaultEquity() reads the vault's equity from the HyperCore vault-equity precompile at VAULT_EQUITY_PRECOMPILE_ADDRESS (0x…0802). Per the HyperLiquid docs that precompile is synced Core→EVM only once, at block construction — it is not updated per transaction, so multiple same-block withdrawals all read the same stale equity and each passes the check. The vulnerable lines, reproduced verbatim:
address public constant VAULT_EQUITY_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000802;
function withdraw(uint64 assets_) external override onlyVaultWrapper {
@> require(assets_ <= _vaultEquity(), Errors.INSUFFICIENT_VAULT_EQUITY());
uint256 amountPerp = (_perpDecimals > _evmSpotDecimals)
? assets_ * (10 ** (_perpDecimals - _evmSpotDecimals))
: assets_ / (10 ** (_evmSpotDecimals - _perpDecimals));
L1_WRITE_PRECOMPILE.sendVaultTransfer(_vault, false, uint64(amountPerp));
L1_WRITE_PRECOMPILE.sendUsdClassTransfer(uint64(amountPerp), false);
L1_WRITE_PRECOMPILE.sendSpot(HYPERLIQUID_SPOT_BRIDGE, _assetIndex, assets_);
}
function _vaultEquity() internal view returns (uint256) {
(bool success, bytes memory result) =
VAULT_EQUITY_PRECOMPILE_ADDRESS.staticcall(abi.encode(address(this), _vault));
require(success, "VaultEquity precompile call failed");
UserVaultEquity memory userVaultEquity = abi.decode(result, (UserVaultEquity));
uint256 equityInSpot = (_perpDecimals > _evmSpotDecimals)
? userVaultEquity.equity / (10 ** (_perpDecimals - _evmSpotDecimals))
: userVaultEquity.equity * (10 ** (_evmSpotDecimals - _perpDecimals));
return equityInSpot;
}
The equity the check trusts is a block-level snapshot, not live per-transaction state. Nothing decrements it when a withdrawal is paid out, so the guard is only correct for the first withdrawal in a block.
Why it's exploitable here#
Following the finding's worked example, with the attacker holding a real vault equity of 1000 USDC and an honest depositor's 1000 USDC pooled in the same reserve:
- Block-start sync writes the escrow's equity (
1000) into the precompile. It is not resynced between transactions in that block. - Withdrawal #1:
assets_ = 1000 <= _vaultEquity() = 1000passes;sendSpotpays out1000 USDCfrom the pooled reserve. - Withdrawal #2, same block: the precompile still reports
1000(stale — it was never reduced by #1), so the check passes again and another1000 USDCis paid out. - The attacker walks away with
2000 USDCon a real equity of1000— a1000 USDCdirect drain of the honest depositor's pooled funds. - The pooled reserve is now empty, so the honest depositor's later withdrawal reverts on the spot delivery — their funds are frozen (the finding's "Bob's transaction will be reverted").
Attack path#
Marked-line walkthrough (Playground)#
The EVM Playground pins each step to the exact executed source line in 0xbd4fd5a3…:
- L154 — Store EVM spot decimals: Setup: The escrow records the EVM spot decimals used to scale the HyperCore vault equity into the withdrawable spot amount the check compares against.
- L168 — Wire the spot-bridge payout: Setup: The constructor wires the HyperLiquid spot-bridge address that routes withdrawn funds out of the pooled reserve to the caller.
- L173 — Bind the queried core vault: Setup: The constructor binds the core vault whose equity the withdraw check later queries from the HyperCore precompile.
- L181 — Stale precompile equity check: Root cause: withdraw() gates assets_ against _vaultEquity(), a stale once-per-block precompile read, so every same-block withdrawal passes the check.
- L190 — Send spot from pooled reserve: The bypassed check lets sendSpot deliver the full requested assets from the pooled reserve to the caller on each same-block withdrawal.
- L193 — Query vault-equity precompile: _vaultEquity() staticcalls the HyperCore vault-equity precompile at 0x...0802, the sole equity source the withdraw check trusts.
- L201 — Scale and return stale equity: It scales the precompile's equity into spot units and returns it; that value never drops mid-block, so back-to-back withdrawals see the same cap.
PoC#
Registry (Foundry, local deploy — verbatim vulnerable source + harm-asserting test):
cd 61455-h-02-withdraw-check-can-be-bypassed-pashov-audit-group-none_exp && forge test -vvv
The browser Playground replays the same synthetic opcode-for-opcode and measures the harm: two same-block withdrawals both pass the stale-equity check, paying out 2x the attacker's real equity, draining the pooled reserve and freezing the honest depositor's withdrawal. Both gates are green (registry forge test PASS + Playground _verify-poc VERDICT: PASS).
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: Blueberry-security-review_2025-03-12.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Blueberry:
withdraw()equity check bypassed by a stale once-per-block precompile". - Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
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.