lingoToken

Cantina Security Report

Organization

@lingo

Engagement Type

Cantina Reviews

Period

-

Repositories

N/A

Researchers


Findings

Critical Risk

3 findings

3 fixed

0 acknowledged

Medium Risk

1 findings

1 fixed

0 acknowledged

Informational

7 findings

7 fixed

0 acknowledged


Critical Risk3 findings

  1. Attacker can become the authority of the config account

    Severity

    Severity: Critical

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The initialize_extra_account_meta_list instruction doesn't check if payer is the current authority of an existing config account. It directly sets payer as the new authority. This lets anyone become the authority by calling this instruction with a new token mint.

    Recommendation

    Only set Config::authority at the time of initialization and ensure that payer is authority. Replace:

    ctx.accounts.config.authority = ctx.accounts.payer.key();

    With:

    if ctx.accounts.config.authority == Pubkey::default() {
    // only set at initialization
    ctx.accounts.config.authority = ctx.accounts.payer.key();
    }
    require_keys_eq!(ctx.accounts.config.authority, ctx.accounts.payer.key());

    Lingo

    Fixed in f47caa7cab121c8a527054d523295fecb602fc50.

    Cantina

    Fix ok.

  2. Insufficient validations allow attackers to steal by increasing refundable fee amount without any transfer fees

    Severity

    Severity: Critical

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The transfer_hook instruction is intended to be called by the Token2022 program on token transfers of supported mints. The instruction only checks that the transferring flag of the source account is true. This ensures that the Token2022 has called the transfer hook program corresponding to the source_token account's mint.

    However, this does not guarantee that the caller of transfer_hook instruction is Token2022. An attacker can create a new token and have the transfer hook program of the new token call the transfer_hook with their own accounts and parameters. As a result, attacker can increase the AccountWhitelist::refundable_amount without transferring any Lingo tokens and later claim the refundable_amount amount of Lingo tokens.

    Proof of Concept

    Eve, an attacker can do the following:

    1. Eve deploys an Exploit program supporting transfer execute interface.
    2. Eve creates a new token P with Transfer Fee and Transfer Hook extensions.
    3. Eve sets the transfer hook program of P to her exploit program.
    4. Eve mints P tokens to her accounts A and B.
    5. Eve calls Token2022 to transfer tokens from A to B.
      • Token2022 calls P transfer hook: the Exploit program.
        • Token2022 sets transferring field of A and B to true.
      • Exploit calls LingoToken's transfer_hook instruction i.e execute
        • source_token: A
        • mint: P
        • destination_token: B
        • owner: Eve
        • extra_account_meta_list: uninitialized PDA for the seeds
        • whitelist_account: Eve's whitelist account for the Lingo Token
        • token_program: Token2022
        • _amount argument: u64::MAX
      • The check_is_transferring function returns Ok(()) as A transferring flag is true.
      • The transfer_hook function will compute the fee and add it to the Whitelist::refundable_amount.
    6. Eve gets refundable_amount fee in LingoToken by transferring her own tokens.

    Eve can set transfer fee and transfer amounts to large values to steal all accumulated fees.

    Recommendation

    Check that the mint is a supported token i.e. the transfer_hook_program_id of the mint is LingoToken program id. This, along with source_token.transferring flag and source_token.mint == mint, guarantees that the caller is Token2022.

    Additionally,

    1. Add PDA address check for whitelist_account using #[account(mut, seeds = [...])]
    2. Ensure extra_account_meta_list is initialized: assert u64(extra_account_meta_list.data[0:8]) != 0

    Lingo

    Fixed in 441f68e3772131e9b33c7ff9e69c32068608a181.

    Cantina

    Fix ok.

  3. Missing instruction to collect transfer fees accumulated in delegate_token_account

    Severity

    Severity: Critical

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    All transfer fees for the Lingo token are collected into delegate_token_account, owned by the delegate PDA signer. While only the program can transfer tokens from delegate_token_account, there is no instruction to transfer these fees to the Lingo team account. As a result, collected fees become permanently stuck.

    Recommendation

    Add an instruction, only callable by Config::authority, that transfers tokens from delegate_token_account to team's account.

    Lingo

    Fixed in a8084d98c99da0374097fbc939cb8e65d210d9c5.

    Cantina

    Partially fixed. A new withdraw_accumulated_fees instruction is added that allows config authority to transfer fees from both delegate_token_account and mint to a provided account.

    The fix could be improved by allowing partial withdrawals to maintain sufficient balance for fee_claim_back operations.

Medium Risk1 finding

  1. Any token account owned by delegate can be used

    Severity

    Severity: Medium

    Submitted by

    undefined avatar image

    zigtur


    Description

    Different delegate_token_account can be used here as long as the mint is expected and the delegate is the owner of the account.

    One can change ownership of their token account to set the delegate and use this account with Lingo's program. This ends up with fees being collected in multiple different token accounts (liquidity fragmentation).

    Recommendation

    Prefer using the associated token account for the delegate, as only one token account is expected to be used here.

    diff --git a/programs/transfer_hook/src/context/fee_claim_back.rs b/programs/transfer_hook/src/context/fee_claim_back.rs
    index 706711a..0800543 100644
    --- a/programs/transfer_hook/src/context/fee_claim_back.rs
    +++ b/programs/transfer_hook/src/context/fee_claim_back.rs
    @@ -35,8 +35,8 @@ pub struct FeeClaimBack<'info> {
    pub delegate: SystemAccount<'info>,
    #[account(
    mut,
    - token::mint = mint,
    - token::authority = delegate,
    + associated_token::mint = mint,
    + associated_token::authority = delegate,
    )]
    pub delegate_token_account: InterfaceAccount<'info, TokenAccount>:

    Lingo

    Fixed in commit b02fbd8.

    Cantina

    Fixed. Only the delegate associated token account is usable.

Informational7 findings

  1. Error-prone implementation of TransferHook::transfer_hook function

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The TransferHook::transfer_hook function has an error-prone implementation when handling whitelist_account.data. Instead of returning an Err for non-empty but uninitialized data, it sets amount to None. There should only be two valid cases:

    1. Whitelisted: whitelist_account is initialized with correct discriminator and try_deserialize returns Ok(...)
    2. Not whitelisted: whitelist_account.data is empty

    Uninitialized non-empty whitelist_account.data suggests malicious behavior and should be handled as an error. While the current if let Some(...) = amount {} makes this non-exploitable by making it a no-op, future updates could introduce vulnerabilities.

    Recommendation

    Replace:

    let amount = if self.whitelist_account.data_is_empty() {
    None
    } else {
    let mut data_slice: &[u8] = &self.whitelist_account.data.borrow();
    match AccountWhitelist::try_deserialize(&mut data_slice) {
    Ok(account_whitelist) => {
    msg!("Recipient is whitelisted");
    Some(account_whitelist.refundable_amount)
    }
    Err(_) => {
    msg!("Account not whitelisted");
    None
    }
    }
    };

    With:

    if self.whitelist_account.data_is_empty() {
    // non-whitelisted account. no-op
    return Ok(())
    }
    // destination_token is whitelisted account
    // check owner and initialization
    require_keys_eq!(crate::id(), *self.whitelist_account.owner);
    let mut data_slice: &[u8] = &self.whitelist_account.data.borrow();
    let amount = AccountWhitelist::try_deserialize(&mut data_slice)?.refundable_amount;

    Lingo

    Fixed in 996b32d23fbe58c855e81dfcdf8f4211aad90f3f.

    Cantina

    Fix ok.

  2. Unnecessary writable account requirements

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The LingoToken program requires certain accounts to be writable when they are not modified, unnecessarily increasing complexity for integrators.

    The following accounts do not need writable permissions:

    1. AddToWhitelist
      • config
    2. RemoveFromWhitelist
      • config
    3. SetFee
      • config
      • delegate
      • signer
    4. FeeClaimBack
      • delegate
      • signer

    Recommendation

    Remove mut from the #[account()] attribute for these accounts.

    Lingo

    Fixed in dec05875a1103ed30a4f5e697bedc9c2883227cf.

    Cantina

    Fix is ok.

  3. Missing check allows accidentally closing whitelist account with non-zero refundable amount

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The remove_from_whitelist instruction closes the whitelist account without ensuring that the refundable_amount is zero, allowing to close an account with pending refund amount.

    Recommendation

    Add a check to ensure refundable_amount is 0 to prevent accidentally removing whitelist.

    Lingo

    Fixed in 73ea2cf7a6c90217ed2ec7baa15fd96acd692480.

    Cantina

    Fix is ok. Note that fee_claim_back should be called before remove_whitelist in the same transaction, as a malicious whitelisted address could make small transfers after claiming fees to maintain non-zero refundable_amount, preventing account closure.

  4. Missing mut attribute for beneficiary_token_account account

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The beneficiary_token_account is modified in the fee_claim_back instruction but lacks the mut attribute marking it as writable.

    Recommendation

    Add #[account(mut)] attribute to the beneficiary_token_account.

    Lingo

    Fixed in e5147f556229a45b1353aa076bec1efcb062bc0a.

    Cantina

    Fix is ok.

  5. Improved implementation of fee_claim_back instruction

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    S3v3ru5


    Description

    The fee_claim_back instruction can be improved in two areas:

    1. Transfer Amount Calculation:
    • Current: Manual calculation of pre_fee_amount for refundable_amount doesn't handle max_fee_amount or overflow checks
    • Better: Use TransferFee::calculate_pre_fee_amount library function which handles these edge cases
    1. Delegate Balance Protection: When mint.withheld_amount is zero, the net change in delegate_token_account balance will be negative:
    • First receives refundable_amount (from withheld harvest)
    • Then deducts refundable_amount + fee_amount (for transfer)
    • No mechanism to recover the deducted fee_amount held in beneficiary_token_account

    Recommendation

    1. Replace get_fee function with:
    fn get_pre_fee_amount(&self, refundable_amount: u64) -> Result<u64> {
    let mint_account = &self.mint.to_account_info();
    let mint_data = mint_account.try_borrow_data()?;
    let mint = PodStateWithExtensions::<PodMint>::unpack(&mint_data)?;
    let pre_fee_amount = mint
    .get_extension::<TransferFeeConfig>()
    .unwrap()
    .get_epoch_fee(Clock::get().unwrap().epoch)
    .calculate_pre_fee_amount(refundable_amount)
    .unwrap_or(refundable_amount); // should never reach. None is returned if & only if the refundable_amount is very large: `refundable_amount / (1 - fee) >= u64::MAX`
    Ok(pre_fee_amount)
    }
    1. Call WithdrawWithheldTokensFromAccounts instruction of Token2022 after transfer.

    Lingo

    Fixed in 4c83d8f863a12f446dab89305ae680746b7db0de.

    Cantina

    Fix is ok. The fix replaces get_fee with get_pre_fee_amount and calls HarvestWithheldTokensToMint instruction after transfer. While the net balance change remains negative, this difference is recovered in the next fee_claim_back call through the harvest operation.

  6. Adding to whitelist allows new_account that is not a token account

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    zigtur


    Description

    The add_to_whitelist instruction allows passing an account that is not a token account as the new_account.

    Note: remove_from_whitelist is also impacted.

    Recommendation

    Prefer ensuring the new_account is a token account.

    Lingo

    Fixed in commit ca8bc66.

    Cantina

    Partially fixed. It could be ensured that the token account is for the expected mint and that it is owned by the correct Token22 program.

    However, such misconfiguration would be an authority mistake and would not have any impact. This can remains as is.

  7. mint account can be owned by the Token program

    Severity

    Severity: Informational

    Submitted by

    undefined avatar image

    zigtur


    Description

    Lingo expects to use the Token22 program. However, as the anchor_spl::token_interface::Mint structure is used, the program accepts mint accounts owned by the Token program.

    Recommendation

    Add a constraint on mint to ensure that it is owned by the Token22 program.

    Lingo

    Fixed in commit f46bce9.

    Cantina

    Fixed. mint accounts must now be owned by the Token22 program.