Prepared by:
HALBORN
Last Updated 06/06/2025
Date of Engagement: January 30th, 2025 - February 7th, 2025
100% of all REPORTED Findings have been addressed
All findings
4
Critical
0
High
0
Medium
0
Low
1
Informational
3
The Vault
engaged Halborn to conduct a security assessment on their Solana Validator program beginning on January 30th, 2025 and ending on February 7th, 2025. The security assessment was scoped to the smart contracts provided in the GitHub repository liquid-unstaker, commit hashes, and further details can be found in the Scope section of this report.
The Vault team
is releasing Liquid Unstaker, a protocol that relies on liquidity providers to supply SOL to a liquidity pool, enabling users to unstake instantly without waiting for the stake deactivation period. In return, liquidity providers earn fees for fronting liquidity and staking rewards.
Halborn was provided 7 days for the engagement and assigned one full-time security engineer to review the security of the Solana Programs in scope. The engineer is a blockchain and smart contract security expert with advanced smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the assessment is to:
Identify potential security issues within the codebase.
Verify that the funds of the liquidity providers are safe of any leakage
Validate that the platform is not vulnerable to arbitrage
Verify that the fees are properly calculated and distributed fairly
In summary, Halborn identified some improvements to reduce the likelihood and impact of multiple risks, which has been addressed by the The Vault team
. The main one was the following:
Consider the case where an staking account has less lamports than what the user is requiring to withdraw
Halborn performed a combination of manual review and security testing based on scripts to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Differences analysis using GitLens to have a proper view of the differences between the mentioned commits
Graphing out functionality and programs logic/connectivity/functions along with state changes
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
0
Low
1
Informational
3
Security analysis | Risk level | Remediation Date |
---|---|---|
Potential loss of funds when withdrawing from validator removal stake accounts | Low | Solved - 02/07/2025 |
Unstaking funds steal in case of manual funds approval | Informational | Solved - 02/07/2025 |
Incorrect cap validation in deposit_sol entry point | Informational | Solved - 02/10/2025 |
Unused is_active boolean in StakeAccountInfo | Informational | Solved - 02/07/2025 |
//
The liquid_unstake_lst
function processes unstaking of liquid staking tokens (LST) by interacting with the stake-pool program. The function handles burning LST tokens and withdrawing SOL from different types of stake accounts, including:
• Active stake accounts
• Transient stake accounts
• Validator removal stake accounts
A potential issue arises when withdrawing from a Validator Removal stake account. If the actual SOL balance in the stake account is lower than expected, the function still burns the full amount of LST tokens, even though the user receives less SOL.
The mention situation can be seen in the snippet below:
program/src/processor.rs
match withdraw_source {
StakeWithdrawSource::Active | StakeWithdrawSource::Transient => {
let remaining_lamports = stake_split_from
.lamports()
.saturating_sub(withdraw_lamports);
if remaining_lamports < required_lamports {
msg!("Attempting to withdraw {} lamports from validator account with {} stake lamports, {} must remain", withdraw_lamports, stake_split_from.lamports(), required_lamports);
return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into());
}
}
StakeWithdrawSource::ValidatorRemoval => {
let split_from_lamports = stake_split_from.lamports();
let upper_bound = split_from_lamports.saturating_add(lamports_per_pool_token);
if withdraw_lamports < split_from_lamports || withdraw_lamports > upper_bound {
msg!(
"Cannot withdraw a whole account worth {} lamports, \
must withdraw at least {} lamports worth of pool tokens \
with a margin of {} lamports",
withdraw_lamports,
split_from_lamports,
lamports_per_pool_token
);
return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into());
}
// truncate the lamports down to the amount in the account
withdraw_lamports = split_from_lamports;
}
}
Some((validator_stake_info, withdraw_source))
};
Self::token_burn(
token_program_info.clone(),
burn_from_pool_info.clone(),
pool_mint_info.clone(),
user_transfer_authority_info.clone(),
pool_tokens_burnt,
)?;
Self::stake_split(
stake_pool_info.key,
stake_split_from.clone(),
withdraw_authority_info.clone(),
AUTHORITY_WITHDRAW,
stake_pool.stake_withdraw_bump_seed,
withdraw_lamports,
stake_split_to.clone(),
)?;
For this issue to take place, the following conditions must be met:
A validator must be marked for removal from the stake pool. This happens when the stake pool manager decides to remove a validator due to low performance or other reasons.
The validator removal stake account must have a lower SOL balance than expected.
This can happen due to:
Stake rewards withdrawal before processing the unstake request.
Unclaimed or missing staking rewards.
A user attempts to unstake their LST by withdrawing from a Validator Removal stake account. This means the function will burn the full amount of LST as if the user was receiving the expected SOL balance.
The function does not verify the actual available SOL before burning LST. Instead of adjusting the burned LST amount based on the actual SOL withdrawn, it burns the full LST amount even if fewer SOL are withdrawn.
This results in users effectively losing the difference between their expected and actual withdrawal amount in terms of LST tokens because:
When withdrawing from a Validator Removal stake account, the function calculates the withdrawal amount based on the entire account balance.
The function does not verify whether the expected SOL balance matches the actual available SOL in the account.
If the actual SOL balance is lower than expected, the function still burns the full amount of LST tokens equivalent to the initially expected SOL withdrawal amount.
Users receive fewer SOL tokens than expected, resulting in an effective loss of their LST stake.
Consider calculating the expected SOL equivalent for the LST tokens being burned and compare it with the actual SOL balance in the stake account. If the stake account has less SOL than expected, return an error instead of proceeding with the withdrawal. For instance, considering that the function that calculates the corresponding SOLs for the LSTs burned is called calculate_expected_sol_for_lst
:
src/instructions/liquid_unstake_lst.rs
pub fn unstake<'a, 'b, 'c>(
ctx: Context<'a, 'b, 'c, 'info, LiquidUnstakeLst<'info>>,
lst_amounts: [u64;5]
) -> Result<()>
where 'c: 'info {
if ctx.remaining_accounts.len() % 3 != 0 {
return Err(error!(LiquidUnstakerErrorCode::InvalidRemainingAccounts));
}
let n_validator_stake_accounts = ctx.remaining_accounts.len() / 3;
let validator_stake_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[0..n_validator_stake_accounts];
let destination_stake_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[n_validator_stake_accounts..];
let stake_info_accounts: &'c [AccountInfo<'_>] = &ctx.remaining_accounts[n_validator_stake_accounts * 2..];
let mut total_amount_unstaked = 0u64;
for i in 0..n_validator_stake_accounts {
let expected_lamports = calculate_expected_sol_for_lst(
validator_stake_accounts[i],
lst_amounts[i],
)?;
let stake_balance = validator_stake_accounts[i].lamports();
if stake_balance < expected_lamports {
return Err(error!(LiquidUnstakerErrorCode::InsufficientStakeBalance));
}
// Withdraw from the SPL Stake Pool
let withdraw_instruction = stake_pool_instruction::withdraw_stake(
&ctx.accounts.stake_pool_program.key(),
&ctx.accounts.stake_pool.key(),
&ctx.accounts.stake_pool_validator_list.key(),
&ctx.accounts.stake_pool_withdraw_authority.key(),
&validator_stake_accounts[i].key(),
&destination_stake_accounts[i].key(),
&ctx.accounts.pool.key(),
&ctx.accounts.pool.key(),
&ctx.accounts.user_lst_account.key(),
&ctx.accounts.stake_pool_manager_fee_account.key(),
&ctx.accounts.stake_pool_mint.key(),
&ctx.accounts.token_program.key(),
lst_amounts[i],
);
SOLVED: The The Vault team solved the issue by adding the ability for users to provide a minimum lamports out. This amount is then checked in the calculate_and_transfer_unstake_fees
, and the function reverts in case that the mentioned amount is not met.
//
The liquid_unstake_lst
function is designed to allow users to burn Liquid Staking Tokens (LST) and unstake SOL from a stake pool without waiting for the corresponding locking period of the staking account.
In order to do this, the liquid_unstake_lst
entry point requires the user to first approve the pool to burn their LST tokens via the SPL Token approve instruction. This approval is currently being executed and granted on the client side (The Vault Web App) and persists until manually revoked or fully used.
However, the liquid_unstake_lst
function does not verify on chain that the caller is the owner of the user_lst_account, meaning that as long as an approval exists, anyone can execute the function on behalf of the user, burn their LST tokens, and withdraw unstaked SOL to an attacker-controlled address.
Since the function does not require a signer for user_lst_account
, an attacker can construct a transaction referencing a victim’s token account, and as long as the pool has an active approval, the attacker can unstake and redirect the SOL withdrawal without the victim’s consent. This issue arises due to the permissionless nature of the entry point, combined with the persistence of SPL Token approvals, which remain valid until explicitly revoked. While the impact is mitigated by the requirement for prior user approval, many users may unknowingly leave approvals active, exposing them to unauthorized unstaking and loss of funds.
The lack of checking of the signature of the owner of the user_lst_account
account can be seen in the snippet below:
src/instructions/liquid_unstake_lst.rs:
pub struct LiquidUnstakeLst<'info> {
#[account(
mut,
seeds = [b"pool", pool.authority.key().as_ref()],
bump = pool.bump,
has_one = sol_vault,
has_one = manager_fee_account
)]
pub pool: Account<'info, Pool>,
pub payer: Signer<'info>,
#[account(mut)]
pub user_lst_account: Account<'info, TokenAccount>,
/// CHECK: The account containing the naked SOL managed by the liquid unstake pool
#[account(mut,
seeds = [b"sol_vault", pool.key().as_ref()],
bump = pool.sol_vault_bump
)]
pub sol_vault: AccountInfo<'info>,
/// CHECK: The user's SOL account to receive the unstaked SOL
#[account(mut)]
pub user_sol_account: AccountInfo<'info>,
/// CHECK: The liquid unstake pool manager fee account that will receive the manager fee
#[account(mut)]
pub manager_fee_account: AccountInfo<'info>,
The permissionless nature of the liquid_unstake_lst
function, combined with the need for users to grant token approval to the pool, allows any caller to execute the unstake process on behalf of any user, provided an active approval exists. Since the function does not enforce a signer check on user_lst_account
, an attacker can submit a transaction specifying a victim’s LST token account for unstaking while redirecting the unstaked SOL to their own controlled account by supplying a user_sol_account
they own. The attacker effectively exploits the lack of ownership verification in the unstake process, leveraging the SPL Token approval mechanism to burn the victim’s LST tokens and withdraw the corresponding SOL to an address under their control, enabling unauthorized asset extraction without requiring access to the victim’s private key.
To prevent unauthorized unstaking, the function should introduce an unstaker_user account as a signer and enforce that:
• user_lst_account is owned by unstaker_user
• user_sol_account is owned by unstaker_user
This ensures that only the rightful owner of the LST tokens can initiate the unstake process, as shown in the snippet below:
src/instructions/liquid_unstake_lst.rs
pub struct LiquidUnstakeLst<'info> {
#[account(
mut,
seeds = [b"pool", pool.authority.key().as_ref()],
bump = pool.bump,
has_one = sol_vault,
has_one = manager_fee_account
)]
pub pool: Account<'info, Pool>,
#[account(signer)]
pub unstaker_user: Signer<'info>,
#[account(mut,
constraint = user_lst_account.owner == unstaker_user.key() @ LiquidUnstakerErrorCode::InvalidUserLstOwner
)]
pub user_lst_account: Account<'info, TokenAccount>,
/// CHECK: The account containing the naked SOL managed by the liquid unstake pool
#[account(mut,
seeds = [b"sol_vault", pool.key().as_ref()],
bump = pool.sol_vault_bump
)]
pub sol_vault: AccountInfo<'info>,
/// CHECK: The user's SOL account to receive the unstaked SOL
#[account(mut,
constraint = user_sol_account.owner == unstaker_user.key() @ LiquidUnstakerErrorCode::InvalidUserSolOwner
)]
pub user_sol_account: AccountInfo<'info>,
/// CHECK: The liquid unstake pool manager fee account that will receive the manager fee
#[account(mut)]
pub manager_fee_account: AccountInfo<'info>,
}
SOLVED: The The Vault team solved the issue by requiring the owner of he usr_lst_account
to be a signer of the instruction, and passing it as a user_transfer_authority
for the withdraw_stake
instruction.
//
The deposit_sol
entry point is responsible for allowing users to deposit SOL into the liquidity pool. In return, users receive LP tokens proportional to their deposit based on the current pool exchange rate.
This liquidity pool is designed to allow liquidity providers to supply SOL so that other users can unstake their Solana staking positions instantly without waiting for the normal unlock period, in exchange for a fee.
The function includes a validation to ensure that deposits do not exceed the vault’s SOL cap:
programs/liquid-unstaker/src/instructions/deposit_sol.rs
pub fn deposit(ctx: Context<DepositSol>, amount: u64) -> Result<()> {
let total_sol_in_vault_plus_pending =
ctx.accounts.pool.sol_vault_lamports
+ ctx.accounts.pool.total_deactivating_stake;
if total_sol_in_vault_plus_pending + amount >= ctx.accounts.pool.sol_vault_lamports_cap {
return Err(LiquidUnstakerErrorCode::SolVaultLamportsCapReached.into());
}
// Mint LP tokens to the user according to the current "exchange rate"
let lp_tokens_to_mint = lp_math::calculate_tokens_to_mint(
ctx.accounts.pool.total_lp_tokens,
amount,
total_sol_in_vault_plus_pending,
)?;
if lp_tokens_to_mint == 0 {
return Err(LiquidUnstakerErrorCode::LpTokensToMintIsZero.into());
}
// Transfer SOL from user to sol_vault
invoke(
&system_instruction::transfer(
&ctx.accounts.user.key(),
&ctx.accounts.sol_vault.key(),
amount,
),
&[
ctx.accounts.user.to_account_info(),
ctx.accounts.sol_vault.clone(),
ctx.accounts.system_program.to_account_info(),
],
)?;
// Mint LP tokens to user
let cpi_accounts = MintTo {
mint: ctx.accounts.lp_mint.to_account_info(),
to: ctx.accounts.user_lp_account.to_account_info(),
authority: ctx.accounts.pool.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
token::mint_to(
CpiContext::new_with_signer(cpi_program, cpi_accounts, pool_seeds!(ctx.accounts.pool.authority, ctx.accounts.pool.bump)),
lp_tokens_to_mint,
)?;
let pool = &mut ctx.accounts.pool;
// Update pool state
pool.deposit_liquidity_provider(amount)?;
pool.total_lp_tokens = pool
.total_lp_tokens
.checked_add(lp_tokens_to_mint)
.ok_or(LiquidUnstakerErrorCode::MathOverflow)?;
Ok(())
}
However, since the validation uses greater than or equal to (>=), the cap can never be fully reached, as any deposit that would bring the total exactly to the cap will be rejected.
Consider changing the validation to be strictly greater than, as shown in the snippet below:
if total_sol_in_vault_plus_pending + amount > ctx.accounts.pool.sol_vault_lamports_cap {
return Err(LiquidUnstakerErrorCode::SolVaultLamportsCapReached.into());
}
SOLVED: The The Vault team solved the issue by correcting the inequality to be strictly bigger than.
//
The StakeAccountInfo struct, which is used to store information about unstaked staking accounts, and it includes a boolean field is_active, as shown in the snippet below:
programs/liquid-unstaker/src/state.rs
#[account]
pub struct StakeAccountInfo {
// The stake account that has been unstaked and is now owned by the pool
pub stake_account: Pubkey,
// The pool that manages the stake account
pub pool: Pubkey,
// The amount of lamports in the stake account when it was unstaked
pub stake_lamports: u64,
// If the stake account is active or not
pub is_active: bool,
}
This boolean is never used in the program:
• When a StakeAccountInfo account is created, is_active
is always set to true.
• When the update function withdraws the stake, both the stake account and StakeAccountInfo are closed, making the boolean unnecessary.
• The update function includes a check for is_active
, but since the account is always closed after unstaking, this check has no effect.
Consider removing the is_active
field from the StakeAccountInfo, along with any references to it in the program to reduce unnecessary state tracking and code complexity.
SOLVED: The The Vault team solved the issue by deleting the mentioned code and references to the is_active
field.
Halborn used automated security scanners to assist with the 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 reviewers 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.
ID | package | Short Description |
---|---|---|
RUSTSEC-2022-0093 | ed25519-dalek | Double Public Key Signing Function Oracle Attack on |
RUSTSEC-2024-0344 | curve25519-dalek | Timing variability in |
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
Liquid Unstaker
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed