lingoToken
Cantina Security Report
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
Attacker can become the authority of the config account
Severity
- Severity: Critical
Submitted by
S3v3ru5
Description
The
initialize_extra_account_meta_listinstruction doesn't check ifpayeris the current authority of an existing config account. It directly setspayeras the new authority. This lets anyone become the authority by calling this instruction with a new token mint.Recommendation
Only set
Config::authorityat the time of initialization and ensure thatpayerisauthority. 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.
Insufficient validations allow attackers to steal by increasing refundable fee amount without any transfer fees
Severity
- Severity: Critical
Submitted by
S3v3ru5
Description
The
transfer_hookinstruction is intended to be called by the Token2022 program on token transfers of supported mints. The instruction only checks that thetransferringflag of the source account is true. This ensures that the Token2022 has called the transfer hook program corresponding to thesource_tokenaccount's mint.However, this does not guarantee that the caller of
transfer_hookinstruction is Token2022. An attacker can create a new token and have the transfer hook program of the new token call thetransfer_hookwith their own accounts and parameters. As a result, attacker can increase theAccountWhitelist::refundable_amountwithout transferring any Lingo tokens and later claim therefundable_amountamount of Lingo tokens.Proof of Concept
Eve, an attacker can do the following:
- Eve deploys an
Exploitprogram supporting transferexecuteinterface. - Eve creates a new token
PwithTransfer FeeandTransfer Hookextensions. - Eve sets the transfer hook program of
Pto her exploit program. - Eve mints
Ptokens to her accountsAandB. - Eve calls Token2022 to transfer tokens from
AtoB.- Token2022 calls
Ptransfer hook: theExploitprogram.- Token2022 sets
transferringfield ofAandBtotrue.
- Token2022 sets
Exploitcalls LingoToken'stransfer_hookinstruction i.eexecute- 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
_amountargument: u64::MAX
- source_token:
- The
check_is_transferringfunction returnsOk(())asAtransferring flag istrue. - The
transfer_hookfunction will compute the fee and add it to theWhitelist::refundable_amount.
- Token2022 calls
- Eve gets
refundable_amountfee 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
mintis a supported token i.e. thetransfer_hook_program_idof the mint is LingoToken program id. This, along withsource_token.transferringflag andsource_token.mint == mint, guarantees that the caller is Token2022.Additionally,
- Add PDA address check for
whitelist_accountusing#[account(mut, seeds = [...])] - Ensure
extra_account_meta_listis initialized: assertu64(extra_account_meta_list.data[0:8]) != 0
Lingo
Fixed in 441f68e3772131e9b33c7ff9e69c32068608a181.
Cantina
Fix ok.
Missing instruction to collect transfer fees accumulated in delegate_token_account
Severity
- Severity: Critical
Submitted by
S3v3ru5
Description
All transfer fees for the Lingo token are collected into
delegate_token_account, owned by thedelegatePDA signer. While only the program can transfer tokens fromdelegate_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 fromdelegate_token_accountto team's account.Lingo
Fixed in a8084d98c99da0374097fbc939cb8e65d210d9c5.
Cantina
Partially fixed. A new
withdraw_accumulated_feesinstruction is added that allows config authority to transfer fees from bothdelegate_token_accountand mint to a provided account.The fix could be improved by allowing partial withdrawals to maintain sufficient balance for
fee_claim_backoperations.
Medium Risk1 finding
Any token account owned by delegate can be used
Severity
- Severity: Medium
Submitted by
zigtur
Description
Different
delegate_token_accountcan be used here as long as themintis expected and thedelegateis 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.rsindex 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
Error-prone implementation of TransferHook::transfer_hook function
Severity
- Severity: Informational
Submitted by
S3v3ru5
Description
The
TransferHook::transfer_hookfunction has an error-prone implementation when handlingwhitelist_account.data. Instead of returning anErrfor non-empty but uninitialized data, it setsamounttoNone. There should only be two valid cases:- Whitelisted:
whitelist_accountis initialized with correct discriminator andtry_deserializereturnsOk(...) - Not whitelisted:
whitelist_account.datais empty
Uninitialized non-empty
whitelist_account.datasuggests malicious behavior and should be handled as an error. While the currentif 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.
Unnecessary writable account requirements
Severity
- Severity: Informational
Submitted by
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:
- AddToWhitelist
config
- RemoveFromWhitelist
config
- SetFee
configdelegatesigner
- FeeClaimBack
delegatesigner
Recommendation
Remove
mutfrom the#[account()]attribute for these accounts.Lingo
Fixed in dec05875a1103ed30a4f5e697bedc9c2883227cf.
Cantina
Fix is ok.
Missing check allows accidentally closing whitelist account with non-zero refundable amount
Severity
- Severity: Informational
Submitted by
S3v3ru5
Description
The
remove_from_whitelistinstruction closes the whitelist account without ensuring that therefundable_amountis zero, allowing to close an account with pending refund amount.Recommendation
Add a check to ensure
refundable_amountis0to prevent accidentally removing whitelist.Lingo
Fixed in 73ea2cf7a6c90217ed2ec7baa15fd96acd692480.
Cantina
Fix is ok. Note that
fee_claim_backshould be called beforeremove_whitelistin the same transaction, as a malicious whitelisted address could make small transfers after claiming fees to maintain non-zerorefundable_amount, preventing account closure.Missing mut attribute for beneficiary_token_account account
Severity
- Severity: Informational
Submitted by
S3v3ru5
Description
The
beneficiary_token_accountis modified in thefee_claim_backinstruction but lacks themutattribute marking it as writable.Recommendation
Add
#[account(mut)]attribute to thebeneficiary_token_account.Lingo
Fixed in e5147f556229a45b1353aa076bec1efcb062bc0a.
Cantina
Fix is ok.
Improved implementation of fee_claim_back instruction
Severity
- Severity: Informational
Submitted by
S3v3ru5
Description
The
fee_claim_backinstruction can be improved in two areas:- Transfer Amount Calculation:
- Current: Manual calculation of
pre_fee_amountforrefundable_amountdoesn't handlemax_fee_amountor overflow checks - Better: Use
TransferFee::calculate_pre_fee_amountlibrary function which handles these edge cases
- Delegate Balance Protection:
When
mint.withheld_amountis zero, the net change indelegate_token_accountbalance 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_amountheld inbeneficiary_token_account
Recommendation
- Replace
get_feefunction 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) }- Call
WithdrawWithheldTokensFromAccountsinstruction of Token2022 after transfer.
Lingo
Fixed in 4c83d8f863a12f446dab89305ae680746b7db0de.
Cantina
Fix is ok. The fix replaces
get_feewithget_pre_fee_amountand callsHarvestWithheldTokensToMintinstruction after transfer. While the net balance change remains negative, this difference is recovered in the nextfee_claim_backcall through the harvest operation.Adding to whitelist allows new_account that is not a token account
Severity
- Severity: Informational
Submitted by
zigtur
Description
The
add_to_whitelistinstruction allows passing an account that is not a token account as thenew_account.Note:
remove_from_whitelistis also impacted.Recommendation
Prefer ensuring the
new_accountis 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.
mint account can be owned by the Token program
Severity
- Severity: Informational
Submitted by
zigtur
Description
Lingo expects to use the Token22 program. However, as the
anchor_spl::token_interface::Mintstructure is used, the program accepts mint accounts owned by the Token program.Recommendation
Add a constraint on
mintto ensure that it is owned by the Token22 program.Lingo
Fixed in commit f46bce9.
Cantina
Fixed.
mintaccounts must now be owned by the Token22 program.