Research · · 5 min read

Threat Contained: marginfi Flash Loan Vulnerability

A new instruction broke the flash loan logic, creating a way to borrow without repaying and putting $160M at risk. We explain the vulnerability, potential impact, and how it was fixed.

Threat Contained: marginfi Flash Loan Vulnerability

This post details a critical vulnerability and team-implemented solution in the flash loan functionality of marginfi, a popular Solana-based DeFi project. At the time, the platform managed over $160M in deposits. We disclosed the issue privately through marginfi’s bug bounty program, it was quickly patched by their team, and no funds were lost or remain at risk.

Marginfi’s core product is a borrowing and lending platform built on the marginfi-v2 smart contract. Users can supply funds to earn yield or take out loans. Like most modern DeFi protocols, marginfi offers flash loans, allowing users to borrow nearly all available liquidity, provided it is returned within the same transaction. A recent change, however, introduced a subtle vulnerability that would have let an attacker skip repayment entirely and keep the borrowed funds. Before diving into the technical details, let’s look at how flash loans on Solana are typically implemented.

Flash Loans on Solana

Unlike EVM-based blockchains, Solana’s SVM runtime does not allow cross-program reentrancy and limits the call depth of cross-program invocations to four. As a result, Solana programs can’t rely on nested CPI calls for flash loans. Instead, they use instruction introspection to ensure that a transaction only succeeds if the loan is fully repaid.

A Solana transaction consists of a sequence of instructions executed in order. Because transactions are atomic, any failed instruction causes the entire transaction to revert. Solana also provides the ability to introspect the instructions of the current transaction via the Instructions sysvar. This lets a lending protocol examine all instructions scheduled for execution. When a flash loan is requested, the protocol can verify that a repayment instruction appears later in the transaction before approving the loan.

The high-level flow of a flash loan in a Solana transaction typically looks like this:

  1. Instruction A: Initialize the flash loan and transfer funds to the borrower
  2. Instructions A+1 … A+N: Execute trades or other operations using the borrowed funds
  3. Instruction A+N: Repay the flash loan and associated fees

As we will see in the next section, marginfi uses the same approach but improves this basic flow by offering even more flexibility.

MarginFi Flash Loans

MarginFi uses a Solana account type called MarginfiAccount, which tracks a user’s assets and liabilities.

Under normal conditions, a MarginfiAccount must remain healthy, meaning that its assets exceed its liabilities. This invariant is enforced when a user borrows funds.

To initiate a flash loan, marginfi does not require the user to specify an exact amount in advance. Instead, it simply sets the ACCOUNT_IN_FLASHLOAN flag on the account for the duration of the loan:


// https://github.com/mrgnlabs/marginfi-v2/blob/4c7fa59d39ff339aa24796da2aaa7783af0492fd/programs/marginfi/src/instructions/marginfi_account/flashloan.rs#L14C1-L28C2
pub fn lending_account_start_flashloan(
    ctx: Context,
    end_index: u64,
) -> MarginfiResult<()> {
    check_flashloan_can_start(
        &ctx.accounts.marginfi_account,
        &ctx.accounts.ixs_sysvar,
        end_index as usize,
    )?;

    let mut marginfi_account = ctx.accounts.marginfi_account.load_mut()?;
    marginfi_account.set_flag(ACCOUNT_IN_FLASHLOAN);

    Ok(())
}
    

This flag's activation causes the check_account_init_health method to skip any health checks, allowing an account with an active flash loan to be uncollateralized (i.e. have more liabilities than assets until the repayment instruction):

// https://github.com/mrgnlabs/marginfi-v2/blob/8ec81a6b302c2c65d58e563cf5d1e45ce2ab0a6a/programs/marginfi/src/state/marginfi_account.rs#L548C5-L573C1
    /// Checks account is healthy after performing actions that increase risk (removing liquidity).
    ///
    /// `ACCOUNT_IN_FLASHLOAN` behavior.
    /// - Health check is skipped.
    /// - `remaining_ais` can be an empty vec.
    /// * Returns a Some(RiskEngine) if creating the engine didn't error, even if the risk check itself did error.
    pub fn check_account_init_health<'a>(
        marginfi_account: &'a MarginfiAccount,
        remaining_ais: &'info [AccountInfo<'info>],
        health_cache: &mut Option<&mut HealthCache>,
    ) -> (MarginfiResult, Option>) {
        if marginfi_account.get_flag(ACCOUNT_IN_FLASHLOAN) {
            // Note: All risk, including the health cache, is not applicable during flashloans
            return (Ok(()), None);
        }
      [...]
    }

So how does marginfi enforce that a flash loan is fully paid back at the end of a transaction?

First, the check_flashloan_can_start method is called during the start instruction to perform the following checks using the Instruction sysvar:


// https://github.com/mrgnlabs/marginfi-v2/blob/4c7fa59d39ff339aa24796da2aaa7783af0492fd/programs/marginfi/src/instructions/marginfi_account/flashloan.rs#L47C1-L55C38
/// Checklist
/// 1. `end_flashloan` ix index is after `start_flashloan` ix index
/// 2. Ixs has an `end_flashloan` ix present
/// 3. `end_flashloan` ix is for the marginfi program
/// 3. `end_flashloan` ix is for the same marginfi account
/// 4. Account is not disabled
/// 5. Account is not already in a flashloan
/// 6. Start flashloan ix is not in CPI
/// 7. End flashloan ix is not in CPI

This ensures that any call to lending_account_start_flashloan on a marginfi account is followed by a call to lending_account_end_flashloan on the same marginfi account later in the transaction.

Looking at lending_account_end_flashloan, we can see that the check at the end is very simple:


// https://github.com/mrgnlabs/marginfi-v2/blob/4c7fa59d39ff339aa24796da2aaa7783af0492fd/programs/marginfi/src/instructions/marginfi_account/flashloan.rs#L124C1-L143C1
pub fn lending_account_end_flashloan<'info>(
    ctx: Context<'_, '_, 'info, 'info, LendingAccountEndFlashloan<'info>>,
) -> MarginfiResult<()> {
    check!(
        get_stack_height() == TRANSACTION_LEVEL_STACK_HEIGHT,
        MarginfiError::IllegalFlashloan,
        "End flashloan ix should not be in CPI"
    );

    let mut marginfi_account = ctx.accounts.marginfi_account.load_mut()?;

    marginfi_account.unset_flag(ACCOUNT_IN_FLASHLOAN);

    let (risk_result, _engine) =
        RiskEngine::check_account_init_health(&marginfi_account, ctx.remaining_accounts, &mut None);
    risk_result?;

    Ok(())
}

At the end of a flash loan, the ACCOUNT_IN_FLASHLOAN flag is cleared, and a health check is run on the account to ensure no unbacked loans remain. If the check fails, the instruction reverts, and the entire transaction is rolled back.

This design is simple and flexible. As long as the account is healthy at the end of the loan, the protocol does not need to track the exact sequence of borrows and repayments that occurred.

The caveat is that any functionality touching a MarginfiAccount must account for its interaction with flash loans. For example, closing and reopening an account during a loan must not be allowed, since it could undermine the health check logic.

transfer_to_new_account

This is exactly what happened when a recent update introduced a new mechanism for migrating a MarginfiAccount to a fresh account (potentially under a new authority). The new transfer_to_new_account instruction handler is short and looks harmless at first glance:


// https://github.com/mrgnlabs/marginfi-v2/blob/f043cbd174f66eb5920c0bf42bf52039d4be4f02/programs/marginfi/src/instructions/marginfi_account/transfer_account.rs#L11C1-L55C2
pub fn transfer_to_new_account(ctx: Context) -> MarginfiResult {
    // Validate the global fee wallet and claim a nominal fee
    let group = ctx.accounts.group.load()?;
    check_eq!(
        ctx.accounts.global_fee_wallet.key(),
        group.fee_state_cache.global_fee_wallet,
        MarginfiError::InvalidFeeAta
    );
    anchor_lang::system_program::transfer(ctx.accounts.transfer_fee(), ACCOUNT_TRANSFER_FEE)?;

    let mut old_account = ctx.accounts.old_marginfi_account.load_mut()?;

    // Prevent multiple migrations from the same account
    check_eq!(
        old_account.migrated_to,
        Pubkey::default(),
        MarginfiError::AccountAlreadyMigrated
    );

    let mut new_account = ctx.accounts.new_marginfi_account.load_init()?;
    new_account.initialize(old_account.group, ctx.accounts.new_authority.key());
    new_account.lending_account = old_account.lending_account;
    new_account.emissions_destination_account = old_account.emissions_destination_account;
    new_account.account_flags = old_account.account_flags;
    new_account.migrated_from = ctx.accounts.old_marginfi_account.key();

    old_account.migrated_to = ctx.accounts.new_marginfi_account.key();

    old_account.lending_account = LendingAccount::zeroed();
    old_account.set_flag(ACCOUNT_DISABLED);

    [...]

    Ok(())
}

At a high level, the instruction creates a new MarginfiAccount with a different authority, copies over the lending_account and other key fields, and then zeroes out and disables the old account. Since the lending_account records all assets and liabilities, this effectively erases the balances of the old account.

The function fails to check whether the account being transferred is in the middle of a flash loan.

This issue opens the door to a straightforward attack that can be executed within a single transaction:

  1. Start a flash loan on a healthy MarginfiAccount A using lending_account_start_flashloan
  2. Borrow the maximum available funds and transfer them to an attacker-controlled address
  3. Call transfer_to_new_account to move the outstanding liabilities from A to a new account B
  4. End the flash loan on A with lending_account_end_flashloan
  5. Never repay the borrowed funds.

We reported the issue to the marginfi team, and the team quickly addressed the vulnerability to protect users. The patch ensures that account transfers cannot be performed while the source account is borrowing a flash loan and that disabled accounts cannot repay a flash loan.

Conclusion

Although many recent web3 incidents stem from operational mistakes, phishing, or supply chain compromises, smart contract security remains the foundation of every DeFi project.

This bug shows how a small and seemingly harmless change in a well-audited, carefully designed codebase can introduce a critical vulnerability. We would like to thank marginfi for their swift response to our report and their impressive turnaround in shipping a fix.

If you want to support our work on eliminating existential risks from the web3 ecosystem, you can delegate to our validator on Solana or reach out to explore focused security opportunities in your ecosystem.

Read next

Get The Latest

Subscribe to be notified whenever we publish new security research.

Great! Check your inbox and click the link.
Sorry, something went wrong. Please try again.