JokInTheBoxStaking unstake replay bug drains staked JOK repeatedly
Exploit Transactions
0xe8277ef6ba8611bd12dc5a6e7ca4b984423bc0b3828159f83b466fdcf4fe054fVictim Addresses
0xa6447f6156effd23ec3b57d5edd978349e4e192dEthereumLoss Breakdown
Similar Incidents
StakingRewards withdraw underflow drains all staked Uniswap V2 LP
36%SorraV2 staking withdraw bug enables repeated SOR reward drain
33%SilicaPools decimal-manipulation bug drains WBTC flashloan collateral
32%WIFStaking claimEarned bug enables repeated WIF reward extraction
32%Access-control bug draining 5 ETH from token contract
32%Pool16 lend/redeem accounting bug drains USDC without HOME backing
31%Root Cause Analysis
JokInTheBoxStaking unstake replay bug drains staked JOK repeatedly
1. Incident Overview TL;DR
On Ethereum mainnet, an adversary-controlled helper contract repeatedly called JokInTheBoxStaking::unstake(0) within a single transaction, draining more JOK tokens from the JokInTheBoxStaking contract than were originally staked at that index. The exploit used a single adversary-crafted transaction from EOA 0xfcd4acbc55df53fbc4c9d275e3495b490635f113 to helper contract 0x9d3425d45df30183fda059c586543dcdeb5993e6, which in turn invoked JokInTheBoxStaking at 0xa6447f6156effd23ec3b57d5edd978349e4e192d many times.
JokInTheBoxStaking::unstake(uint256) fails to enforce that each stake index can be unstaked at most once, allowing an unprivileged staker (or its helper contract) to repeatedly call unstake(i) after lock expiry and withdraw the staked JOK amount multiple times from the same stake.
2. Key Background
JokInTheBoxStaking is a staking contract for the Jok ERC20 token (JOK) where users call stake(amount, lockPeriod) to create stakes indexed per address and later call unstake(stakeIndex) after a lock period to retrieve their staked JOK. Stakes are stored in stakes[address] as an array of Stake structs containing unstaked, amountStaked, lockPeriod, stakedDay, and unstakedDay fields; the contract tracks totalStaked across all users. The helper contract at 0x9d3425d45df30183fda059c586543dcdeb5993e6 is controlled by EOA 0xfcd4acbc55df53fbc4c9d275e3495b490635f113 and exposes a function (identified by selector 0xb61705a0) that, according to decompilation and traces, loops over calls to JokInTheBoxStaking::unstake(uint256).
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an unstake-invariant violation in JokInTheBoxStaking::unstake(uint256) that allows a single stake index to be cashed out multiple times after the lock period, enabling over-withdrawal of JOK from the staking pool. The root cause is that unstake(uint256) only checks stakeIndex bounds and lock-period expiry, then transfers amountStaked without enforcing that the stake has not already been unstaked. An adversary-controlled helper contract wraps this primitive to execute many unstake(0) calls in a single transaction, repeatedly withdrawing the same staked amount. This creates an ACT-style attack opportunity for any unprivileged staker with a matured position at a given index.
4. Detailed Root Cause Analysis
The intended invariant is that for every address a and stake index i, the total JOK withdrawn via unstake(i) after lock expiry must be less than or equal to the original stakes[a][i].amountStaked, and each stake index should be effectively unstaked at most once. JokInTheBoxStaking implements stakes as a mapping from address to an array of Stake structs, each tracking unstaked, amountStaked, lockPeriod, stakedDay, and unstakedDay.
The critical function in the verified JokInTheBoxStaking source is:
function unstake(uint256 stakeIndex) external {
require(stakeIndex < stakes[msg.sender].length, "Invalid stake index!");
Stake memory currentStake = stakes[msg.sender][stakeIndex];
uint256 currentDay = getCurrentDay();
require(currentDay > currentStake.stakedDay + currentStake.lockPeriod, "Lock period has not finalized!");
stakes[msg.sender][stakeIndex].unstaked = true;
stakes[msg.sender][stakeIndex].unstakedDay = currentDay;
totalStaked -= currentStake.amountStaked;
require(jokToken.transfer(msg.sender, currentStake.amountStaked), "Token transfer failed!");
emit Unstake(msg.sender, currentStake.amountStaked, block.timestamp, currentStake.lockPeriod, stakeIndex);
}
This implementation never checks the existing stakes[msg.sender][stakeIndex].unstaked flag before executing. Because currentStake is loaded from storage into memory before unstaked is set to true, a caller that repeatedly invokes unstake(stakeIndex) after the lock period will satisfy the same checks every time and receive amountStaked on each call, as long as the contract still holds enough JOK. The code-level breakpoint is therefore the missing guard "require(!stakes[msg.sender][stakeIndex].unstaked, 'Already unstaked');" (or an equivalent state transition) immediately before transferring tokens.
The decompiled helper contract at 0x9d3425d45df30183fda059c586543dcdeb5993e6 contains a function with selector 0xb61705a0 that is gated by an owner-like address store_a and then calls JokInTheBoxStaking at 0xa6447f6156effd23ec3b57d5edd978349e4e192d via selector 0x2e17de78 (unstake(uint256)) in a loop. A representative excerpt is shown below (comments added for clarity):
function Unresolved_b61705a0(uint256 arg0) public {
require(msg.sender == address(store_a), "invalid caller");
require(!(0 < arg0)); // arg0 == 0 in the incident
uint256 stakeIndex = 0;
require(address(0xa6447f6156effd23ec3b57d5edd978349e4e192d).code.length > 0);
// Repeated calls to JokInTheBoxStaking::unstake(0)
(bool success, ) = address(0xa6447f6156effd23ec3b57d5edd978349e4e192d).call(abi.encodeWithSelector(0x2e17de78, stakeIndex));
require(success);
}
The incident trace for transaction 0xe8277ef6ba8611bd12dc5a6e7ca4b984423bc0b3828159f83b466fdcf4fe054f confirms that EOA 0xfcd4acbc55df53fbc4c9d275e3495b490635f113 calls this helper with selector 0xb61705a0 and arg0 = 0x15e, and that within the same transaction the helper repeatedly executes JokInTheBoxStaking::unstake(0). The trace shows many Unstake events with staker = 0x9d3425d45df30183fda059c586543dcdeb5993e6 and stakeIndex = 0, each paired with a Jok::transfer from JokInTheBoxStaking to the helper for 366060210111013959647533876 JOK.
The ERC20 balance diff for JOK (0xa728aa2de568766e2fa4544ec7a77f79c0bf9f97) for this transaction shows JokInTheBoxStaking decreasing by 109085942613082159974965095048 units and the helper contract increasing by the same amount, proving that the helper withdrew the same stake value multiple times and that the stake-level invariant was broken on-chain.
5. Adversary Flow Analysis
The adversary-related cluster consists of EOA 0xfcd4acbc55df53fbc4c9d275e3495b490635f113 and helper contract 0x9d3425d45df30183fda059c586543dcdeb5993e6. The helper enforces that only store_a may call its core methods, and the successful execution of b61705a0 in the incident transaction implies that store_a is equal to the EOA.
In block 20064929, transaction 0xe8277e...054f is sent from the EOA to the helper with 0 ETH and calldata 0xb61705a0000000000000000000000000000000000000000000000000000000000000015e. Inside this call, the helper validates msg.sender, then repeatedly calls JokInTheBoxStaking::unstake(0) on 0xa6447f6156effd23ec3b57d5edd978349e4e192d. Each unstake call transfers 366060210111013959647533876 JOK from the staking contract to the helper and emits an Unstake event. The EOA pays the gas (losing about 0.0477 ETH), while an unrelated EOA 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97 receives around 0.01135 ETH, likely as infrastructure payment, but does not participate in the control flow of the exploit.
This flow is fully realizable under the ACT model by any unprivileged staker that controls a matured stake at a given index. Such a staker can deploy an equivalent helper that loops over JokInTheBoxStaking::unstake(i) and submit a public transaction invoking it; no privileged roles or off-chain secrets are required beyond control of the staker address.
6. Impact & Losses
Total loss: 109085942613082159974965095048 JOK withdrawn from JokInTheBoxStaking. During the exploit transaction, JokInTheBoxStaking lost 109085942613082159974965095048 JOK (as shown by the ERC20 balance diff), which was entirely credited to the adversary helper contract 0x9d3425d45df30183fda059c586543dcdeb5993e6. The staking contract's accounting invariants for individual stakes were violated, demonstrating that any staker with a matured position could repeatedly unstake the same index and drain JOK from the pool.
7. References
[1] Seed transaction metadata and trace for 0xe8277e...054f — artifacts/root_cause/seed/1/0xe8277ef6ba8611bd12dc5a6e7ca4b984423bc0b3828159f83b466fdcf4fe054f/ [2] JokInTheBoxStaking verified source and ABI — artifacts/root_cause/data_collector/iter_1/contract/1/0xa6447f6156effd23ec3b57d5edd978349e4e192d/source/ [3] Helper contract 0x9d34..3e6 decompiled code and control checks — artifacts/root_cause/data_collector/iter_1/contract/1/0x9d3425d45df30183fda059c586543dcdeb5993e6/decompile/ [4] JOK ERC20 balance diff for incident transaction — artifacts/root_cause/seed/1/0xe8277ef6ba8611bd12dc5a6e7ca4b984423bc0b3828159f83b466fdcf4fe054f/balance_diff.json