Halborn Logo

// Blog

Solana Token-Ception: Token 2022 Bugfix Review


profile

Michael Smith

April 6th, 2023


Halborn completed an audit of Solana Labs's Token 2022 program in November of 2022 and discovered two critical vulnerabilities. These vulnerabilities would allow a user to avoid paying transfer fees when transferring tokens and to transfer non-transferable tokens. In this blog post we'll review the findings and discuss their impact.

Disclaimer: This article assumes you are familiar with Solana's account model and the token program. The aforementioned audit was done on pre-production code and all findings have been fixed by the Solana Labs team. 

Intro to Solana’s Token-2022 Program

In 2020, Solana Labs released the Token program, an ERC-20-like token program providing most of the functionality needed for fungible and non-fungible tokens. Due to this, it's become one of the most ubiquitous programs in the ecosystem, so much so that it's rare not to see it being used in an audit.

As successful as Solana’s Token program has been, it's beginning to show its age, and, even though it's only three years old, in crypto years that's forever. Since its original deployment, many developers have forked the program and added additional functionality. This has created issues with trust, security, and adoption. 

Thus, the Solana Labs team decided to develop the Token-2022 program.

The new program would be a superset of the original token program, implementing the same instruction layout but allowing for new functionality to be included with the use of an extension model. You can read more on how extensions work here, but, in this blog post, we'll be focusing on the confidential transfer, transfer fee, and non-transferable extension as that's where the two vulnerabilities were found.

Confidential Transfer

In both token programs, user tokens are stored in accounts owned by the respective token programs. These accounts have data fields where the program will store an Account struct which adds the user's public key as the owner. For the remainder of this blog post, we'll refer to the Account struct as the Token account and the user’s account owned by the token program as their token wallet.

{language=rust caption=sdk/src/account.rs firstnumber=27 hlines=27}
pub struct Account {
    /// lamports in the account
    pub lamports: u64,
    /// data held in this account
    #[serde(with = "serde_bytes")]
    pub data: Vec<u8>,
    /// the program that owns this account. If executable, the program that loads this account.
    pub owner: Pubkey,
    /// this account's data contains a loaded program (and is now read-only)
    pub executable: bool,
    /// the epoch at which this account will next owe rent
    pub rent_epoch: Epoch,
}

Token Wallet struct

{language=rust caption=token/program-2022/src/state.rs firstnumber=92 hlines=92}
pub struct Account {
    /// The mint associated with this account
    pub mint: Pubkey,
    /// The owner of this account.
    pub owner: Pubkey,
    /// The amount of tokens this account holds.
    pub amount: u64,
    /// If delegate is Some then delegated_amount represents
    /// the amount authorized by the delegate
    pub delegate: COption<Pubkey>,
    /// The account's state
    pub state: AccountState,
    /// If is_some, this is a native token, and the value logs the rent-exempt reserve. An Account
    /// is required to be rent-exempt, so the value is used by the Processor to ensure that wrapped
    /// SOL accounts do not drop below this threshold.
    pub is_native: COption<u64>,
    /// The amount delegated
    pub delegated_amount: u64,
    /// Optional authority to close the account.
    pub close_authority: COption<Pubkey>,
}

Token Account Struct

In Token-2022, the confidential transfer extension was added, allowing users to confidentially transfer tokens between one another. You can read more here.

If the confidential transfer extension is enabled, a ConfidentialTransferAccount struct is stored in the data field in addition to the Account struct. We'll refer to this as the confidential token account.

{language=rust caption=token/program-2022/src/extension/confidential_transfer/mod.rs firstnumber=82 hlines=82}
pub struct ConfidentialTransferAccount {
    /// true if this account has been approved for use. All confidential transfer operations for
    /// the account will fail until approval is granted.
    pub approved: PodBool,

    /// The public key associated with ElGamal encryption
    pub encryption_pubkey: EncryptionPubkey,

    /// The low 16 bits of the pending balance (encrypted by encryption_pubkey)
    pub pending_balance_lo: EncryptedBalance,

    /// The high 48 bits of the pending balance (encrypted by encryption_pubkey)
    pub pending_balance_hi: EncryptedBalance,

    /// The available balance (encrypted by encrypiton_pubkey)
    pub available_balance: EncryptedBalance,

    /// The decryptable available balance
    pub decryptable_available_balance: DecryptableBalance,

    /// pending_balance may only be credited by Deposit or Transfer instructions if true
    pub allow_balance_credits: PodBool,

    /// The total number of Deposit and Transfer instructions that have credited
    /// pending_balance
    pub pending_balance_credit_counter: PodU64,

    /// The maximum number of Deposit and Transfer instructions that can credit
    /// pending_balance before the ApplyPendingBalance instruction is executed
    pub maximum_pending_balance_credit_counter: PodU64,

    /// The expected_pending_balance_credit_counter value that was included in the last
    /// ApplyPendingBalance instruction
    pub expected_pending_balance_credit_counter: PodU64,

    /// The actual pending_balance_credit_counter when the last ApplyPendingBalance instruction
    /// was executed
    pub actual_pending_balance_credit_counter: PodU64,

    /// The withheld amount of fees. This will always be zero if fees are never enabled.
    pub withheld_amount: EncryptedWithheldAmount,
}

You can compare the token wallet to your debit card, the token account to your checking account, and the confidential token account to your savings account. Confusing? I know, I personally call this token-ception, accounts in an account.

Transfer Fees

The transfer fee extension allows mint authorities to charge users a fee whenever users transfer tokens between one another. Similar to the Confidential Transfer extension, if the transfer fee extension is enabled, a TransferFeeAmount struct is added to the data field. This extension stores the fees you've paid and can be collected by the withdraw_withheld_authority.

{language=rust caption=token/program-2022/src/extension/transfer_fee/mod.rs firstnumber=90 hlines=90}
pub struct TransferFeeAmount {
    /// Amount withheld during transfers, to be harvested to the mint
    pub withheld_amount: PodU64,
}
{language=rust caption=token/program-2022/src/extension/transfer_fee/mod.rs firstnumber=25 hlines=25}
pub struct TransferFee {
    /// First epoch where the transfer fee takes effect
    pub epoch: PodU64, // Epoch,
    /// Maximum fee assessed on transfers, expressed as an amount of tokens
    pub maximum_fee: PodU64,
    /// Amount of transfer collected as fees, expressed as basis points of the
    /// transfer amount, ie. increments of 0.01%
    pub transfer_fee_basis_points: PodU16,
}

Non-Transferable

Finally if the non-transferable extension is enabled, a NonTransferable extension is added to the mint's account (not the token wallet). When you try to transfer a token that is set as non-transferable, the instruction should check the mint and if the extension is enabled the transfer should fail.

Vulnerability #1 - Circumventing Transfer Fees

Let's take a look at the confidential transfer extension’s code. We'll see a lot of new instructions were added, but let's take a closer look at deposit and withdraw. These instructions are meant to allow a user to deposit tokens from their token account to their confidential token account, with the withdraw instruction being the inverse. Neither instruction charges a transfer fee when a user executes the instruction, and this makes sense. Why pay a fee if you're moving tokens around within the same token wallet?

But what the code should do and what it actually does can often be completely different, and in this case, the program doesn't validate that the source/destination for deposits and withdrawals are the same token wallet. 

A user can provide a different token wallet for the source and destination turning these instructions into a pseudo transfer instruction allowing the user to circumvent transfer fees.

Vulnerability Fix

The Solana Labs team resolved this issue in commit  3ddb3c7404d23146a390150e241831e116c5cc8d:  The destination_token account has been removed from the withdraw and deposit instruction, deposits & withdrawals are restricted to the same token wallet.

Vulnerability #2 - Non-Transferable Tokens Can Be Transferred

This vulnerability is a bit simpler. If we look at the new transfer instruction in the confidential transfer extension, we can see there is no check to see if the mint has the non-transferable extension enabled. Due to this, users can deposit non-transferable tokens into their confidential token account and transfer them to another user.

Vulnerability Fix

The Solana Labs team resolved this issue in commits:

- [6a102589ef8ae268cc07666b9ea45739c55fe9f0]

- [92a8e6b2d0499d8812b4e74c8f1f01a2a362dc4b]

- [b973e474683ede678ddf3789f80c7060ef136afc]

The program now prevents confidential transfers, withdrawals and deposits of non-transferable tokens.


© Halborn 2024. All rights reserved.