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_list
instruction doesn't check ifpayer
is the current authority of an existing config account. It directly setspayer
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 thatpayer
isauthority
. Replace:ctx.accounts.config.authority = ctx.accounts.payer.key();With:
if ctx.accounts.config.authority == Pubkey::default() {// only set at initializationctx.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_hook
instruction is intended to be called by the Token2022 program on token transfers of supported mints. The instruction only checks that thetransferring
flag of the source account is true. This ensures that the Token2022 has called the transfer hook program corresponding to thesource_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 thetransfer_hook
with their own accounts and parameters. As a result, attacker can increase theAccountWhitelist::refundable_amount
without transferring any Lingo tokens and later claim therefundable_amount
amount of Lingo tokens.Proof of Concept
Eve, an attacker can do the following:
- Eve deploys an
Exploit
program supporting transferexecute
interface. - Eve creates a new token
P
withTransfer Fee
andTransfer Hook
extensions. - Eve sets the transfer hook program of
P
to her exploit program. - Eve mints
P
tokens to her accountsA
andB
. - Eve calls Token2022 to transfer tokens from
A
toB
.- Token2022 calls
P
transfer hook: theExploit
program.- Token2022 sets
transferring
field ofA
andB
totrue
.
- Token2022 sets
Exploit
calls LingoToken'stransfer_hook
instruction 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
_amount
argument: u64::MAX
- source_token:
- The
check_is_transferring
function returnsOk(())
asA
transferring flag istrue
. - The
transfer_hook
function will compute the fee and add it to theWhitelist::refundable_amount
.
- Token2022 calls
- 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. thetransfer_hook_program_id
of the mint is LingoToken program id. This, along withsource_token.transferring
flag andsource_token.mint == mint
, guarantees that the caller is Token2022.Additionally,
- Add PDA address check for
whitelist_account
using#[account(mut, seeds = [...])]
- Ensure
extra_account_meta_list
is 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 thedelegate
PDA 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_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 bothdelegate_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
Any token account owned by delegate can be used
Severity
- Severity: Medium
Submitted by
zigtur
Description
Different
delegate_token_account
can be used here as long as themint
is expected and thedelegate
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.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_hook
function has an error-prone implementation when handlingwhitelist_account.data
. Instead of returning anErr
for non-empty but uninitialized data, it setsamount
toNone
. There should only be two valid cases:- Whitelisted:
whitelist_account
is initialized with correct discriminator andtry_deserialize
returnsOk(...)
- 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 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-opreturn Ok(())}// destination_token is whitelisted account// check owner and initializationrequire_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
config
delegate
signer
- FeeClaimBack
delegate
signer
Recommendation
Remove
mut
from 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_whitelist
instruction closes the whitelist account without ensuring that therefundable_amount
is zero, allowing to close an account with pending refund amount.Recommendation
Add a check to ensure
refundable_amount
is0
to prevent accidentally removing whitelist.Lingo
Fixed in 73ea2cf7a6c90217ed2ec7baa15fd96acd692480.
Cantina
Fix is ok. Note that
fee_claim_back
should be called beforeremove_whitelist
in 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_account
is modified in thefee_claim_back
instruction but lacks themut
attribute 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_back
instruction can be improved in two areas:- Transfer Amount Calculation:
- Current: Manual calculation of
pre_fee_amount
forrefundable_amount
doesn't handlemax_fee_amount
or overflow checks - Better: Use
TransferFee::calculate_pre_fee_amount
library function which handles these edge cases
- Delegate Balance Protection:
When
mint.withheld_amount
is zero, the net change indelegate_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 inbeneficiary_token_account
Recommendation
- 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)}- Call
WithdrawWithheldTokensFromAccounts
instruction of Token2022 after transfer.
Lingo
Fixed in 4c83d8f863a12f446dab89305ae680746b7db0de.
Cantina
Fix is ok. The fix replaces
get_fee
withget_pre_fee_amount
and callsHarvestWithheldTokensToMint
instruction after transfer. While the net balance change remains negative, this difference is recovered in the nextfee_claim_back
call 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_whitelist
instruction allows passing an account that is not a token account as thenew_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.
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::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.