Reproduced Exploit

Mystic — validator unstake burns frxETH and never records the withdrawal

unstakeFromValidator burns the caller's frxETH, then takes the validator branch because currentWithheldETH is 0. That branch releases the ETH into the minter and returns amountUnstaked. The assignment of withdrawalRequests[msg.sender] sits after the branch, so it never runs.

Jun 2025•Other•logic•3 min read

Chain

Other

Category

logic

Date

Jun 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: 57714-h-02-user-funds-burned-with-no-eth-returned-due-to-missing-w. Standalone Foundry PoC and full write-up: 57714-h-02-user-funds-burned-with-no-eth-returned-due-to-missing-w_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/incorrect-state-transition · vuln/dos/frozen-funds

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/57714-h-02-user-funds-burned-with-no-eth-returned-due-to-missing-w_exp.sol.


Key info#

ImpactHIGH — 10 frxETH is burned and 10 WETH is stuck in the minter
ProtocolMystic stPlumeMinter
Vulnerable codereturn amountUnstaked before withdrawalRequests[msg.sender] is written
Bug classValidator fallback returns the unstaked amount and skips the request
FindingKann — Mystic Finance RWA, 6 June 2025 · id 57714
ReportKann Audits, June 2025
SourceAuditVault finding
Compilersolc 0.8.27, cancun, via IR

TL;DR#

unstakeFromValidator burns the caller's frxETH, then takes the validator branch because currentWithheldETH is 0. That branch releases the ETH into the minter and returns amountUnstaked. The assignment of withdrawalRequests[msg.sender] sits after the branch, so it never runs.

withdraw reads that mapping. The amount is 0, so the user is paid nothing. 10 WETH stays in the minter. The user's frxETH balance is 0.

The vulnerable code#

SOLIDITY
if (_validatorId != 0) {
    amountUnstaked += remainingToUnstake;
    plume.release(address(this), remainingToUnstake);
    // @> VULN: return amountUnstaked before withdrawalRequests[msg.sender] is written
    return returned;
}
withdrawalRequests[msg.sender] = WithdrawalRequest({amount: amountUnstaked, timestamp: block.timestamp});

The audited _unstake returns on the explicit-validator path before the request is stored. withdraw then uses request.amount, which is still 0.

Root cause#

The burn happens before the branch. The validator path treats the returned value as the whole result and never writes the mapping the withdrawal reads.

Preconditions#

  • currentWithheldETH is below the unstake amount, here 0.
  • The caller passes a non-zero validator id.
  • The validator holds the 10 WETH the shares represent.

Attack walkthrough#

  1. The staker holds 10 frxETH. The validator holds 10 WETH.
  2. unstakeFromValidator(10 ether, 1) burns the frxETH and releases the WETH into the minter.
  3. The function returns 10 and skips the request write.
  4. withdraw reads amount 0 and transfers nothing.
  5. The minter still holds 10 WETH. The user holds neither token.

Control: the same call writes withdrawalRequests[msg.sender] = 10 ether before returning. withdraw then pays the 10 WETH and the minter balance is 0.

Diagrams#

flowchart TD A["Burn 10 frxETH"] --> B["Validator releases 10 WETH"] B --> C["Return before the request write"] C --> D["withdraw reads amount 0"] D --> E["10 WETH stays in the minter"]

Impact#

The staker loses 10 frxETH and receives 0 WETH. The 10 WETH is in the minter with no request that can pay it out. There is no second unstake: a non-zero request is required to be empty, but the user's shares are already burned.

Remediation#

Write withdrawalRequests[msg.sender] before returning from the validator branch, using the same amountUnstaked the function returns.

Sources#

Taxonomy#

  • vuln/logic/incorrect-state-transition · vuln/dos/frozen-funds

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.