Prepared by:
HALBORN
Last Updated 04/26/2024
Date of Engagement by: January 16th, 2022 - January 25th, 2022
100% of all REPORTED Findings have been addressed
All findings
8
Critical
0
High
1
Medium
1
Low
2
Informational
4
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.
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
.
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
).
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}
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 analysis | Risk level | Remediation Date |
---|---|---|
ANONYMOUS SELL ORDER CANCELLING | High | Solved - 01/17/2022 |
HARDCODED VAULT ADDRESS | Medium | Acknowledged |
NFT ORDER TYPE MISMATCH | Low | Solved - 01/29/2022 |
FANT AMOUNT CASTING OVERFLOW | Low | Solved - 01/29/2022 |
EDITION SELL ORDER PARAMETER SANITY CHECK MISSING | Informational | Solved - 01/27/2022 |
POSSIBLE MISUSE OF HELPER METHODS | Informational | Solved - 12/28/2021 |
BUYING NFTS AS A DELEGATE | Informational | Acknowledged |
ALL NFT EDITION SALE PROFITS ARE TRANSFERRED TO THE FEE VAULT | Informational | Acknowledged |
// High
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.
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.
if sell_order_data.seller_wallet != *seller_wallet_account.key {
msg!("PhantasiaError::SellerMismatched");
return Err(PhantasiaError::SellerMismatched.into());
}
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.
if sell_order_data.seller_wallet != *seller_wallet_account.key {
msg!("PhantasiaError::SellerMismatched");
return Err(PhantasiaError::SellerMismatched.into());
}
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.
// Medium
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.
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());
}
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());
}
ACKNOWLEDGED: The Phantasia Sports
team accepts the risk of this finding.
// Low
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.
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,
)?;
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());
}
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());
}
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(())
}
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.
// Low
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.
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);
}
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.
// Informational
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.
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)?;
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,
};
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
.
// Informational
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.
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;
}
}
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.
// Informational
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.
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)?;
if !buyer_wallet_account.is_signer {
msg!("ProgramError::MissingRequiredSignature");
return Err(ProgramError::MissingRequiredSignature);
}
the only check on buyer_wallet_account
if !buyer_wallet_account.is_signer {
msg!("ProgramError::MissingRequiredSignature");
return Err(ProgramError::MissingRequiredSignature);
}
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
if !buyer_wallet_account.is_signer {
msg!("ProgramError::MissingRequiredSignature");
return Err(ProgramError::MissingRequiredSignature);
}
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.
// Informational
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.
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(),
],
)?;
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.
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.
\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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed