Skip to main content

Staking with Time-Accumulation

This capture the flag style example allows you to analyze a staking contract, find the vulnerability in its time-tracking logic, and exploit it to claim a flag NFT. The contract requires 168 hours (1 week) of staking and a minimum of 1 SUI to claim, but a flaw in how receipts merge their hours_staked values lets you reach the threshold instantly.

When to use this pattern​

Study this challenge when you need to:

  • Audit Move contracts for logic vulnerabilities before deploying to production (see security best practices).

  • Understand how time-based access control can go wrong when the client controls time accumulation.

  • Learn how object consumption and reconstruction in Move can introduce state manipulation opportunities.

  • Practice building exploit transactions with the Sui TypeScript SDK against a live Testnet contract.

  • Teach or learn secure staking patterns by first understanding what makes an insecure one exploitable.

What you learn​

This example teaches:

  • Receipt-based staking: The contract tracks staking state in a StakeReceipt object with amount, hours_staked, and last_update_timestamp fields. The receipt is an owned object the staker controls.

  • Object destruction and reconstruction: The update_receipt and merge_receipts functions destroy the old receipt(s) and create a new one. This pattern is where the vulnerability lives.

  • Time manipulation through merging: merge_receipts adds the hours_staked values of 2 receipts together without validating that the hours represent real elapsed time. Merging N receipts that each have H hours produces a single receipt with N*H hours.

Architecture​

The challenge has 2 components: the deployed Move contract on Testnet and the TypeScript script you write locally to exploit it. The StakingPool is a shared object that holds all staked SUI balances. When you call stake, it returns a StakeReceipt owned by your address. The exploit creates multiple receipts, merges them to accumulate hours_staked values, and calls claim_flag with the merged receipt that meets the 168-hour threshold.

The diagram below traces the exploit flow.

The following steps walk through the exploit:

  1. You run the TypeScript script. It creates multiple stake calls, each returning a StakeReceipt with hours_staked: 0.

  2. The script calls update_receipt on each receipt. If any real time has passed since staking (even milliseconds), the receipt accumulates a small number of hours.

  3. The script calls merge_receipts repeatedly. Each merge adds the hours_staked of 2 receipts together into a new receipt. The total is the sum of all merged receipts' hours.

  4. After enough merges, the combined receipt has hours_staked >= 168 and amount >= 1_000_000_000 (1 SUI total across all merged stakes).

  5. The script calls claim_flag with the merged receipt. The contract verifies the threshold, mints a Flag NFT, and returns the staked SUI.

How the vulnerability works​

The vulnerability is in step 3: merge_receipts does not validate that the hours represent actual elapsed time. It sums them without validation.

The merge_receipts function destructs 2 StakeReceipt objects and creates a new one with hours_staked: hours1 + hours2. There is no check that the combined hours correspond to real wall-clock time. The function also resets last_update_timestamp to the current time, erasing any evidence of how the hours accumulated.

A secure implementation would either:

  • Enforce monotonic timestamps: Validate that each receipt's last_update_timestamp is earlier than the current time by at least the claimed hours.

  • Use onchain time only: Compute elapsed hours entirely from clock.timestamp_ms() at claim time, rather than storing a client-updatable hours_staked field.

  • Prevent receipt merging: Remove merge_receipts entirely, or restrict it so merged hours cannot exceed the time between the earliest stake and the current clock.

tip

Mitigation checklist for time-accumulation and receipt patterns:

  • Derive elapsed time from monotonic onchain sources (Clock/timestamps) rather than a client-updatable counter, and never let accounting move backward.
  • Make receipts non-mergeable, or key them by a canonical, unforgeable receipt ID so duplicates and merges cannot inflate accumulated state.
  • Add invariant tests that assert accumulated time and amounts can never exceed real elapsed time across stake, update, merge, and claim paths.
  • Emit events for stake, claim, and merge so the accounting is auditable and anomalies are detectable.

See Security best practices.

Prerequisites​

Setup​

Follow these steps to set up the challenge locally.

Step 1: Clone the repo​
$ git clone https://github.com/MystenLabs/CTF.git
$ cd CTF/scripts
Step 2: Install dependencies​
$ pnpm install
Step 3: Generate a key pair​
$ pnpm init-keypair

This creates a keypair.json file with a fresh Ed25519 key pair. Fund this new address with Testnet SUI.

Step 4: Write your exploit​

Open scripts/src/staking.ts. The file has a stub with the client and key pair already configured. Insert your exploit logic into the file.

The deployed contract address is 0x936313e502e9cbf6e7a04fe2aeb4c60bc0acd69729acc7a19921b33bebf72d03.

Run the example​

Run your exploit script:

$ pnpm staking

If successful, the script claims a Flag NFT and returns your staked SUI. Verify the flag in your wallet:

$ sui client objects YOUR_ADDRESS

You should see a flag::Flag object with source: "staking".

Key code highlights​

Loading code...

Common modifications​

  • Fix the vulnerability: Replace merge_receipts with a version that computes hours_staked from the earliest last_update_timestamp across the merged receipts rather than summing. Or remove merging entirely and compute elapsed time in claim_flag from the original stake timestamp.

  • Add a lock period: Store the original stake_timestamp (not just last_update_timestamp) on the receipt and enforce the 168-hour requirement against it in claim_flag. This prevents any time manipulation.

  • Make receipts non-mergeable: Remove the merge_receipts function. Each receipt stands alone and can only be claimed individually. This eliminates the accumulation vector.

  • Use a single receipt per address: Store the stake state on the StakingPool as a dynamic field keyed by the staker's address, rather than as a separate owned object. This prevents multiple-receipt attacks.

  • Add a reward rate: Instead of a flat flag, calculate rewards proportional to amount * hours_staked and distribute them from a reward pool. Apply the same timing fixes.

Troubleshooting​

The following sections address common issues with this example.

ENotEnoughStakingTime when claiming​

Symptom: The claim_flag transaction aborts with error code 0.

Cause: The merged receipt's hours_staked plus any hours since the last update does not reach 168.

Fix: Merge more receipts or let more time pass before claiming. Each update_receipt call captures elapsed hours, and each merge sums them.

EInsufficientStakeAmount when claiming​

Symptom: The claim_flag transaction aborts with error code 1.

Cause: The merged receipt's amount is below 1,000,000,000 (1 SUI).

Fix: Ensure the total staked across all merged receipts equals at least 1 SUI. Each stake call's coin value adds to the merged total.

Insufficient gas or SUI balance​

Symptom: The exploit script fails with an InsufficientGas or balance error.

Cause: The wallet does not have enough SUI for both the stake amount (minimum 1 SUI) and gas fees.

Fix: Request more Testnet SUI from the faucet. You need at least 2 SUI (1 for staking, 1 for gas across multiple transactions).

Key pair file not found​

Symptom: The script fails with a file-not-found error for keypair.json.

Cause: You did not run pnpm init-keypair before running the exploit.

Fix: Run pnpm init-keypair from the scripts/ directory. Fund the generated address before running the exploit.