Contents

DiceCTF 2023 Solana Challenges Write-up

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

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

  1. Create the following accounts: payer, userand state.
  2. Invoke the Init instruction with the payer as the owner account.
  3. Invoke the InitVirtualBalance instruction with the x amount being 1,000,000 and y amount being 1,000,001.
  4. Invoke the SetEnabled account, which means that the Swap instruction will be available for all users to use.
  5. Run our exploit contract
  6. Check if the x and y 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 and framework-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.