Reproduced Exploit

Cage locks 1,000 joined tokens with no way to exit

StakeJoin.cage writes live = 0. exit requires live == 1 and reverts CollateralJoin/not-live. The contract has no call that sets live back to 1. A user who joined 1,000 tokens cannot take them out. An identical join that is never caged pays the 1,000 back.

Mar 2023•Ethereum•dos•3 min read

Loss

1,000 tokens stay in StakeJoin after cage. Control exit returns the 1,000

Chain

Ethereum

Category

dos

Date

Mar 2023

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: 50695-user-funds-may-get-locked-in-join-contracts-halborn-damfinan. Standalone Foundry PoC and full write-up: 50695-user-funds-may-get-locked-in-join-contracts-halborn-damfinan_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dos/frozen-funds

Reproduction: self-contained synthetic Foundry reduction; see output.txt.

Key info#

FieldValue
Loss1,000 tokens stay in StakeJoin after cage. Control exit returns the 1,000
Vulnerable contractStakeJoin.cage / StakeJoin.exit
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2023-03
Compilersolc 0.8.27, cancun, via IR
Bug classcage sets live = 0 and nothing sets it back, so exit reverts

TL;DR#

StakeJoin.cage writes live = 0. exit requires live == 1 and reverts CollateralJoin/not-live. The contract has no call that sets live back to 1. A user who joined 1,000 tokens cannot take them out. An identical join that is never caged pays the 1,000 back.

Background#

Halborn reviewed Dam Finance LMCV part 2. RewardJoin and StakeJoin can be stopped by an authorized cage. The report shows a user joining 1,000 tokens, the admin caging the contract, and exit reverting. The same live flag gates RewardJoin.exit. This reduction is the StakeJoin path quoted in the finding.

The vulnerable code#

SOLIDITY
function cage() external {
    // @> VULN: cage clears live and no function sets it back to 1
    live = 0;
}

function exit(address usr, uint256 wad) external {
    require(live == 1, "CollateralJoin/not-live");
    staked[msg.sender] -= wad;
    require(token.transfer(usr, wad), "CollateralJoin/failed-transfer");
}

The control join has the same exit and is never caged, so live stays 1.

Root cause#

Stopping the join and unlocking it are not a pair. cage is a one-way write. Every later exit hits CollateralJoin/not-live and the tokens already pulled in by join stay on the contract.

Preconditions#

  • The user has joined 1,000 tokens. The join's token balance and the stake record are both 1,000.
  • cage runs in this transaction. No time warp is required.
  • The control join receives the same 1,000 and is not caged.

Attack walkthrough#

  1. Both joins take 1,000 tokens. cage sets live to 0. exit reverts. exitReverted is 1 (output.txt:9).
  2. The buggy join still holds 1000000000000000000000 tokens (output.txt:11).
  3. The uncaged join pays 1000000000000000000000 back (output.txt:13).
  4. [PASS] (output.txt:6).

Diagrams#

flowchart TD Join["User joins 1000 tokens"] --> Cage["cage writes live = 0"] Cage --> Blocked["exit reverts CollateralJoin/not-live"] Blocked --> Stuck["1000 tokens stay in the join"] Stuck --> Control["Uncaged exit pays the 1000 back"]

Impact#

The user who joined 1,000 tokens cannot withdraw them. The stake record is still 1,000 and the tokens are still on the join. The same exit on a join that was not caged returns the 1,000.

Remediation#

Let an authorized caller set live back to 1, or add an exit path that does not require live == 1 after the join has been caged. Do not leave cage as a one-way write.

DIFF
 function cage() external {
-    live = 0;
+    live = live == 1 ? 0 : 1;
 }

How to reproduce#

BASH
cd 50695-user-funds-may-get-locked-in-join-contracts-halborn-damfinan_exp
forge test -vv

Sources#


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.