Halborn Logo

NTF Store - Phantasia Sports


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: January 16th, 2022 - January 25th, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

8

Critical

0

High

1

Medium

1

Low

2

Informational

4


1. INTRODUCTION

Phantasia Sports is a Fantasy Sports platform that is built on top of the Solana Blockchain. On Phantasia Sports, anyone can earn tokens by contributing to the ecosystem through skilled gameplay. Players can join public or private tournaments to play fantasy sports against other users.

Phantasia Sports engaged Halborn to conduct a security assessment on their NTF Store program beginning on January 16th, 2022 and ending January 25th, 2022. This security assessment was scoped to the phantasia-nft-store-program repository and an audit of the security risk and implications regarding the changes introduced by the development team at Phantasia Sports prior to its production release shortly following the assessment's deadline.

2. AUDIT SUMMARY

The team at Halborn was provided two weeks for the engagement and assigned one full-time security engineer to audit the security of the program. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.

The purpose of this audit to achieve the following:

    • Ensure that program functions are intended.

    • Identify potential security issues with the program.

In summary, Halborn identified some security risks that were mostly addressed by the Phantasia Sports team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual view of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the program audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of programs and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the audit:

    • Research into architecture, purpose, and use of the platform.

    • Manual code read and walkthrough.

    • Manual Assessment of use and safety for the critical Rust variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Fuzz testing. (Halborn custom fuzzing tool).

    • Checking the test coverage. (cargo tarpaulin)

    • Scanning of Rust files for vulnerabilities.(cargo audit).

4. SCOPE

This review was scoped to the NFT Store Solana program.

\begin{enumerate} \item NFT Store program \begin{enumerate} \item Repository: \href{https://github.com/Phantasia-Sports/phantasia-nft-store-program}{phantasia-nft-store-program} \item Commit ID: \href{https://github.com/Phantasia-Sports/phantasia-nft-store-program/commit/59c18ce9cfb4fa58ecf503c6bdbfb7e22996c9b9}{59c18ce9cfb4fa58ecf503c6bdbfb7e22996c9b9} \end{enumerate} \end{enumerate}

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

1

Medium

1

Low

2

Informational

4

Impact x Likelihood

HAL-01

HAL-02

HAL-03

HAL-04

HAL-05

HAL-06

HAL-07

HAL-08

Security analysisRisk levelRemediation Date
ANONYMOUS SELL ORDER CANCELLINGHighSolved - 01/17/2022
HARDCODED VAULT ADDRESSMediumAcknowledged
NFT ORDER TYPE MISMATCHLowSolved - 01/29/2022
FANT AMOUNT CASTING OVERFLOWLowSolved - 01/29/2022
EDITION SELL ORDER PARAMETER SANITY CHECK MISSINGInformationalSolved - 01/27/2022
POSSIBLE MISUSE OF HELPER METHODSInformationalSolved - 12/28/2021
BUYING NFTS AS A DELEGATEInformationalAcknowledged
ALL NFT EDITION SALE PROFITS ARE TRANSFERRED TO THE FEE VAULTInformationalAcknowledged

8. Findings & Tech Details

8.1 ANONYMOUS SELL ORDER CANCELLING

// High

Description

Owners can place sell orders on their NFTs by sending either a ListNftForSale instruction or a ListEditionForSale instruction to the program. Either instruction handler transfers the token's authority to an account address derived from the program's ID and a static seed and creates an order account. This account stores the ask price, NFT mint and several other parameters.

Sellers may choose to cancel their orders before they're filled. To accomplish that, they can send either a CancelNftSale or a CancelEditionSale. Either instruction handler transfers the NFT's authority back to the seller (NFT owner) and closes the order account, sending the rent back to the seller.

However, because neither the CancelNftSale instruction handler nor the CancelEditionSale instruction handler verifies if the NFT owner is, in fact, a transaction signer, an anonymous attacker may cause a DoS of the program by cancelling all sell orders.

Code Location

processor/cancel_listing.rs

pub fn process_cancel_listing(accounts: &[AccountInfo], program_id: &Pubkey) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let seller_wallet_account = next_account_info(account_info_iter)?;
    let selling_nft_token_account = next_account_info(account_info_iter)?;
    let sell_order_data_storage_account = next_account_info(account_info_iter)?;
    let nft_store_signer_pda_account = next_account_info(account_info_iter)?;
    let token_program = next_account_info(account_info_iter)?;

The only check performed on the seller account by the instruction handler.

processor/cancel_listing.rs

if sell_order_data.seller_wallet != *seller_wallet_account.key {
    msg!("PhantasiaError::SellerMismatched");
    return Err(PhantasiaError::SellerMismatched.into());
}

processor/cancel_edition_listing.rs

pub fn process_cancel_edition_listing(
    accounts: &[AccountInfo],
    program_id: &Pubkey,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let seller_wallet_account = next_account_info(account_info_iter)?;
    let selling_nft_token_account = next_account_info(account_info_iter)?;
    let sell_order_data_storage_account = next_account_info(account_info_iter)?;
    let nft_store_signer_pda_account = next_account_info(account_info_iter)?;
    let token_program = next_account_info(account_info_iter)?;

The only check performed on the seller account by the instruction handler.

processor/cancel_edition_listing.rs

if sell_order_data.seller_wallet != *seller_wallet_account.key {
    msg!("PhantasiaError::SellerMismatched");
    return Err(PhantasiaError::SellerMismatched.into());
}
Score
Impact: 4
Likelihood: 5
Recommendation

SOLVED: The Phantasia Sports team fixed this issue in commit 5a1b332897736200f6d793852891ac179144c48d: the transaction signer public key is verified to match the seller_wallet account address saved in the sale order.

8.2 HARDCODED VAULT ADDRESS

// Medium

Description

Rust compiler requires the program developer to set the TREASURY_ATA_PUBKEY environment variable to build the program. The BuyNft and BuyEdition instruction handlers use the value of this variable to get the fee vault address. Since this address is hardcoded at compile time, it cannot be modified without redeploying the program if the account compromised.

Code Location

processor/buy_nft.rs

let transaction_fee_fant_ata_id = Pubkey::from_str(env!(
    "TREASURY_ATA_PUBKEY",
    "Must specify a treasury ATA account public key!"
))
.map_err(|_| PhantasiaError::StringToPubkeyConversionFailed)?;
if *transaction_fee_fant_ata.key != transaction_fee_fant_ata_id {
    msg!(
        "PhantasiaError::TransactionFeeFantAtaMismatched EXPECTED: {:?} ACTUAL: {:?}",
        transaction_fee_fant_ata_id,
        transaction_fee_fant_ata.key
    );
    return Err(PhantasiaError::TransactionFeeFantAtaMismatched.into());
}

processor/buy_edition.rs

let transaction_fee_fant_ata_id = Pubkey::from_str(env!(
    "TREASURY_ATA_PUBKEY",
    "Must specify a treasury ATA account public key!"
))
.map_err(|_| PhantasiaError::StringToPubkeyConversionFailed)?;
if *transaction_fee_fant_ata.key != transaction_fee_fant_ata_id {
    msg!(
        "PhantasiaError::TransactionFeeFantAtaMismatched EXPECTED: {:?} ACTUAL: {:?}",
        transaction_fee_fant_ata_id,
        transaction_fee_fant_ata.key
    );
    return Err(PhantasiaError::TransactionFeeFantAtaMismatched.into());
}
Score
Impact: 3
Likelihood: 3
Recommendation

ACKNOWLEDGED: The Phantasia Sports team accepts the risk of this finding.

8.3 NFT ORDER TYPE MISMATCH

// Low

Description

The program creates two types of NFT sell orders: SellOrder and LimitedEditionSale. NFTs may be either single- or multiple edition. Edition ID is stored in NFT metadata as edition_nonce. Editions are identified with u8 integer, but for single edition NFTs this property is set to None.

Neither the ListNftForSale nor the ListEditionForSale instruction handler checks the value of the edition_nonce property, which means the NFT owner may place a SellOrder order for a multiple edition NFT and a LimitedEditionSale order for a single edition NFT.

Code Location

processor/list_nft.rs

if !rent.is_exempt(
    selling_nft_token_account.lamports(),
    selling_nft_token_account.data_len(),
) {
    msg!("PhantasiaError::NotRentExempt");
    return Err(PhantasiaError::NotRentExempt.into());
}

validate_nft(
    selling_nft_mint_account,
    selling_nft_token_account,
    selling_nft_metadata_account,
    seller_wallet_account.key,
)?;

processor/list_nft.rs

pub fn validate_nft(
    nft_mint: &AccountInfo,
    nft_token_account: &AccountInfo,
    metadata_info: &AccountInfo,
    seller_wallet_pubkey: &Pubkey,
) -> ProgramResult {
    let verified_creators: [Pubkey; NUM_VERIFIED_CREATORS] = [
        vc1_id::id(), // This is our main wallet
        vc2_id::id(),
        vc3_id::id(),
        vc4_id::id(),
        vc5_id::id(),
        vc6_id::id(), // Phanbot creator
    ];
    validate_metadata_account(metadata_info, nft_mint.key)?;

    if *nft_token_account.owner != spl_token::id() {
        msg!("PhantasiaError::AccountOwnerShouldBeTokenProgram");
        return Err(PhantasiaError::AccountOwnerShouldBeTokenProgram.into());
    }

processor/list_nft.rs

pub fn validate_nft(
    nft_mint: &AccountInfo,
    nft_token_account: &AccountInfo,
    metadata_info: &AccountInfo,
    seller_wallet_pubkey: &Pubkey,
) -> ProgramResult {
    let verified_creators: [Pubkey; NUM_VERIFIED_CREATORS] = [
        vc1_id::id(), // This is our main wallet
        vc2_id::id(),
        vc3_id::id(),
        vc4_id::id(),
        vc5_id::id(),
        vc6_id::id(), // Phanbot creator
    ];
    validate_metadata_account(metadata_info, nft_mint.key)?;

    if *nft_token_account.owner != spl_token::id() {
        msg!("PhantasiaError::AccountOwnerShouldBeTokenProgram");
        return Err(PhantasiaError::AccountOwnerShouldBeTokenProgram.into());
    }

processor/list_nft.rs

pub fn validate_metadata_account(metadata_info: &AccountInfo, nft_mint: &Pubkey) -> ProgramResult {
    verify_metadata_account_owner(metadata_info.owner)?;
    let metadata_program_id = metadata_program_id::id();
    let metadata_seeds = &[
        metaplex_state::PREFIX.as_bytes(),
        metadata_program_id.as_ref(),
        nft_mint.as_ref(),
    ];
    let (metadata_address, _) =
        Pubkey::find_program_address(metadata_seeds, &metadata_program_id::id());

    if metadata_address != *metadata_info.key {
        msg!("PhantasiaError::MetadataAccountMismatched");
        return Err(PhantasiaError::MetadataAccountMismatched.into());
    }
    Ok(())
}
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Phantasia Sports team fixed this issue in commit 6b36c56025ae1ed8bfc869dc7015413a25e1b8b3: the value of edition_nonce is checked in the validate_nft function, used by ListNftForSale and ListEditionForSale, to verify that it is expected sales order type.

8.4 FANT AMOUNT CASTING OVERFLOW

// Low

Description

The program calculates the FANT amount the seller receives with the get_fants_to_receive_from_basis_points function. This function takes a u64 selling_price and u16 basis_points and performs some arithmetic operations on them. The result is cast to u64 to match the type of the amount field of SPL Token's Account struct.

Before casting however the result is not verified to not exceed the maximum value allowed by the u64 type which can lead to integer overflow.

Code Location

processor/buy_nft.rs

pub fn get_fants_to_receive_from_basis_points(
    selling_price: u64,
    basis_points: u16,
) -> Result<u64, ProgramError> {
    let amount_to_receive = (selling_price as u128)
        .checked_mul(basis_points as u128)
        .ok_or(PhantasiaError::MathOverflow)?
        .checked_div(10000u128)
        .ok_or(PhantasiaError::MathOverflow)? as u64;
    return Ok(amount_to_receive);
}
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Phantasia Sports team fixed this issue in commit 6b36c56025ae1ed8bfc869dc7015413a25e1b8b3: the amount_to_receive is verified to not exceed the maximum value allowed by the u64 type to avoid an integer overflow.

8.5 EDITION SELL ORDER PARAMETER SANITY CHECK MISSING

// Informational

Description

The ListEditionForSale instruction handler requires the NFT owner to provide some parameters, including number_to_sell and num_sold. The former denotes the total number of NTFs available, and the latter stores the number of NFTs sold to date.

The handler does not verify if num_sold is lower than number_to_sell which means it is possible to place a sell order on an NFT edition where the number of sold tokens exceeds the number of tokens available for sale.

Code Location

processor/list_edition.rs

pub fn process_list_edition_nft(
    accounts: &[AccountInfo],
    program_id: &Pubkey,
    sale_price: u64,
    number_to_sell: u16,
    num_sold: u16
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let seller_wallet_account = next_account_info(account_info_iter)?;

processor/list_edition.rs

let sell_order_data: LimitedEditionSale = LimitedEditionSale {
    acc_type: AccTypesWithVersion::LimitedEditionSaleDataV1 as u8,
    seller_wallet: *seller_wallet_account.key,
    nft_token: *selling_nft_token_account.key,
    selling_price: sale_price,
    number_purchased: num_sold,
    number_to_sell: number_to_sell,
    nonce: bump_seed,
};
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Phantasia Sports team fixed this issue in commit 32ff199c83cdedf061aaeef706c75c9a4c7ed510: the num_sold is verified not to exceed the number of available tokens, number_to_sell.

8.6 POSSIBLE MISUSE OF HELPER METHODS

// Informational

Description

The intention and use of helper methods in Rust, like unwrap, is very useful for testing environments because a value is forcibly demanded to get an error (aka panic!) if the Option the methods is called on doesn't have Some value or Result. Nevertheless, leaving unwrap functions in production environments is a bad practice because not only will this cause the program to crash out, or panic!. In addition, no helpful messages are shown to help the user solve, or understand the reason of the error.

Code Location

processor/list_nft.rs

let metadata = Metadata::from_account_info(metadata_info)?;
let nft_creators = metadata.data.creators.unwrap();
let mut is_fake_nft = true;
for creator in nft_creators {
    if creator.verified && is_verified_creator(&creator.address, &verified_creators)? {
        is_fake_nft = false;
        break;
    }
}
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Phantasia Sports team fixed this issue in commit 68463a47fdd1a9be18b14ead9891806cb96be9d9: any use of the unwrap function has been removed and replaced by more secure methods such as error propagation.

8.7 BUYING NFTS AS A DELEGATE

// Informational

Description

Both BuyNft and BuyEdition instruction handlers expect the transaction sender to provide the buyer_nft_fant_ata FANT token account to charge and buyer_wallet_account to authorize the transfer. If it is successful, the NFT's authority is transferred to buyer_wallet_account.

In the SPL Token program, an account delegate is defined as an account authorized to transfer an owner-approved share of tokens out of the account.

Neither the BuyNft nor BuyEdition instruction handler verifies if buyer_wallet_account is the owner of the buyer_nft_fant_ata account rather than just a delegate which means if the transaction is signed by a delegate, the NFT's authority will be transferred to the delegate instead of the buyer_nft_fant_ata account owner.

Code Location

processor/buy_nft.rs

pub fn process_buy_nft(accounts: &[AccountInfo], program_id: &Pubkey) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();
    let buyer_wallet_account = next_account_info(account_info_iter)?;
    let buyer_nft_fant_ata = next_account_info(account_info_iter)?;

processor/buy_nft.rs

if !buyer_wallet_account.is_signer {
        msg!("ProgramError::MissingRequiredSignature");
        return Err(ProgramError::MissingRequiredSignature);
    }

the only check on buyer_wallet_account

processor/buy_nft.rs

if !buyer_wallet_account.is_signer {
        msg!("ProgramError::MissingRequiredSignature");
        return Err(ProgramError::MissingRequiredSignature);
    }

processor/buy_edition.rs

let mint_authority_info = next_account_info(account_info_iter)?;
let buyer_wallet_account = next_account_info(account_info_iter)?;
let nft_store_signer_pda_account = next_account_info(account_info_iter)?;
let master_edition_token_account_info = next_account_info(account_info_iter)?;
let update_authority_info = next_account_info(account_info_iter)?;
let master_metadata_account_info = next_account_info(account_info_iter)?;
let token_program_account_info = next_account_info(account_info_iter)?;
let system_account_info = next_account_info(account_info_iter)?;
let rent_info = next_account_info(account_info_iter)?;
let metadata_program_id_account = next_account_info(account_info_iter)?;
let metadata_mint_info = next_account_info(account_info_iter)?;

let buyer_nft_fant_ata = next_account_info(account_info_iter)?;

the only check on buyer_wallet_account

processor/buy_edition.rs

if !buyer_wallet_account.is_signer {
        msg!("ProgramError::MissingRequiredSignature");
        return Err(ProgramError::MissingRequiredSignature);
    }
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Phantasia Sports team do not believe that any of the platform users are going to ever use delegation to purchase NFTs from the store.

8.8 ALL NFT EDITION SALE PROFITS ARE TRANSFERRED TO THE FEE VAULT

// Informational

Description

NFT owners can post sell orders on NFTs or NFT editions. When a user buys a single-edition NFT, a fixed percentage is deducted from the ask price and transferred to the fee vault. When a user buys an NFT edition, however, 100% of the ask price is transferred to the fee vault and the NFT owner receives nothing.

Code Location

processor/buy_edition.rs

let seller_to_receive_fants = sell_order_data.selling_price;

msg!("Calling the token program to transfer FANT to Treasury...");
invoke(
    &spl_token::instruction::transfer(
        token_program_account_info.key,
        buyer_nft_fant_ata.key,
        transaction_fee_fant_ata.key,
        buyer_wallet_account.key,
        &[],
        seller_to_receive_fants,
    )?,
    &[
        buyer_nft_fant_ata.clone(),
        transaction_fee_fant_ata.clone(),
        buyer_wallet_account.clone(),
        token_program_account_info.clone(),
    ],
)?;
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Phantasia Sports team states that the idea is that edition sales will be only performed by the team and would like all proceeds to be sent directly to the treasury.

9. Automated Testing

AUTOMATED ANALYSIS

Description

Halborn used automated security scanners to assist with detection of well-known security issues and vulnerabilities. Among the tools used was cargo audit, a security scanner for vulnerabilities reported to the RustSec Advisory Database. All vulnerabilities published in https://crates.io are stored in a repository named The RustSec Advisory Database. cargo audit is a human-readable version of the advisory database which performs a scanning on Cargo.lock. Security Detections are only in scope. All vulnerabilities shown here were already disclosed in the above report. However, to better assist the developers maintaining this code, the auditors are including the output with the dependencies tree, and this is included in the cargo audit output to better know the dependencies affected by unmaintained and vulnerable crates.

Results

\begin{center} \begin{tabular}{|l|p{2cm}|p{9cm}|} \hline \textbf{ID} & \textbf{package} & \textbf{Short Description} \ \hline \href{https://github.com/chronotope/chrono/issues/499}{RUSTSEC-2020-0159} & chrono & Potential segfault in localtime\textunderscore r invocations \ \hline \end{tabular} \end{center}

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.