Reproduced Exploit

Harmonix — writing the NAV replaces the fee-harvest timestamp

LAST_HARVEST_PERFORMANCE_FEE_TIME is defined as keccak256(abi.encode("NAV")), the same constant as NAV. setVaultState writes the harvest timestamp and then the NAV into that one slot. The second write wins.

May 2025•Other•logic•4 min read

Chain

Other

Category

logic

Date

May 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

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.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 57885-c-03-nav-value-overwrites-last-fee-harvest-time-shieldify-no. Standalone Foundry PoC and full write-up: 57885-c-03-nav-value-overwrites-last-fee-harvest-time-shieldify-no_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/state-update · vuln/logic/fee-calculation · vuln/logic/incorrect-state-transition

Reproduction: a self-contained Foundry PoC that compiles and runs offline with only forge-std — no fork and no RPC. Trace: output.txt. PoC: test/57885-c-03-nav-value-overwrites-last-fee-harvest-time-shieldify-no_exp.sol.


Key info#

ImpactHIGH — the performance-fee harvest reverts, and 100 ether of fees are skipped
ProtocolHarmonix VaultStore / fund harvest
Vulnerable codeLAST_HARVEST_PERFORMANCE_FEE_TIME is keccak256(abi.encode("NAV"))
Bug classThe NAV write and the harvest timestamp share one slot
FindingShieldify — Harmonix Finance · C-03 · id 57885
ReportHarmonixFinance-Hyperliquid-Security-Review.md
SourceAuditVault finding
StatusReproduced here as a local synthetic of the audited constants
Compilersolc 0.8.27, cancun, via IR

TL;DR#

LAST_HARVEST_PERFORMANCE_FEE_TIME is defined as keccak256(abi.encode("NAV")), the same constant as NAV. setVaultState writes the harvest timestamp and then the NAV into that one slot. The second write wins.

The timestamp that was requested is 0, which is already due. The NAV that replaces it is 1000000 ether, which is above block.timestamp. harvestPerformanceFee requires block.timestamp >= lastHarvest + 1 and reverts. feesCollected stays 0. The 100 ether fee token balance stays on the fund.

A distinct hash for the timestamp leaves the slot at 0 after the NAV write. The same harvest then pays the 100 ether.

The vulnerable code#

SOLIDITY
bytes32 internal constant NAV = keccak256(abi.encode("NAV"));
// @> VULN: this is keccak256(abi.encode("NAV")), the same constant as NAV.
bytes32 internal constant LAST_HARVEST_PERFORMANCE_FEE_TIME = keccak256(abi.encode("NAV"));
SOLIDITY
store[_timeKey()] = lastHarvest;
// @> VULN: the NAV key equals the harvest-time key, so this write replaces the timestamp.
store[_navKey()] = nav;

The audited constant is the one in lib/VaultStore.sol at commit f02157ab.

Root cause#

Two storage keys are built from the same id. Writing NAV is not a separate slot. It is an overwrite of the last harvest time, and the value written is a NAV, not a timestamp. The harvest check then treats that NAV as the last harvest and refuses to run.

Preconditions#

  • The fee due is 100 ether, already minted to the fund.
  • setVaultState is called with lastHarvest = 0 and nav = 1000000 ether.
  • The minimum interval is 1 second. block.timestamp is at least 1, so a preserved timestamp of 0 is already due. No warp is used.

Attack walkthrough#

  1. Compare the keys. They are equal.
  2. setVaultState(1000000 ether, 0) writes 0 and then overwrites it with the NAV.
  3. The slot read back is 1000000 ether, which is greater than block.timestamp.
  4. harvestPerformanceFee reverts with HARVEST_TOO_SOON. The fee token balance is still 100 ether.

Control: the time key is keccak256(abi.encode("LAST_HARVEST_PERFORMANCE_FEE_TIME")). After the same setVaultState, the time slot is still 0 and the NAV slot is 1000000 ether. harvestPerformanceFee pays 100 ether to the caller.

Diagrams#

flowchart TD A["Harvest timestamp and NAV share one hash"] --> B["setVaultState writes timestamp 0"] B --> C["The NAV write stores 1000000 ether in that same slot"] C --> D["Harvest reads the NAV as the last harvest time"] D --> E["The check reverts and the 100 ether fee is skipped"]

Impact#

The performance fee cannot be collected after a NAV update. 100 ether that was due stays on the fund, and feesCollected is 0. Distinct keys preserve the timestamp and the same harvest pays the 100 ether.

Remediation#

Define LAST_HARVEST_PERFORMANCE_FEE_TIME as keccak256(abi.encode("LAST_HARVEST_PERFORMANCE_FEE_TIME")), not as the NAV id.

Sources#

Taxonomy#

  • severity/high
  • vuln/logic/state-update · vuln/logic/fee-calculation · vuln/logic/incorrect-state-transition

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • 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.