Contents

SekaiCTF 2023 Solana Challenge The Bidding Write-up

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

SekaiCTF 2023 - The Bidding

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 implementation of our exploit contract and the its invocation.

The target contract functionalities

This contract establishes a straightforward bidding platform, enabling a product owner to initiate auctions, with other users placing bids in lamports to contend for the product. This functionality is achieved through a set of 5 key instructions: CreateProduct, CreateAuction, Bid, EndAuction, and RecoverBid. In a nutshell, the owner’s sequence of actions involves invoking CreateProduct to generate a product account, followed by the use of CreateAuction to commence an auction account for said product. Subsequently, users can engage by executing Bid to submit bids for the product, with the highest bidder being temporarily designated as the leader. Ultimately, the product owner can opt to trigger EndAuction to confirm the victor. The final instruction, RecoverBid, doesn’t seem to have practical utility and relevance to vulnerability and exploitation considerations. As such, this write-up will disregard its significance for its intended scope.

The instructions themselves are quite simple in functionality. However, they have a very strict set of constraints, both seed constraints and raw constraints, to mitigate any potential account-related attacks. Let’s start with CreateProduct, the most notable thing about this instruction is that the seeds for the product account come directly from the 2 arguments provided to it: product_name and product_id. This characteristic will be important to our exploitation later. The instruction initializes the owner field of the product account to the signer, and the is_auctioning field to false, simply because there is no auction account created for the product yet.

pub fn create_product(ctx: Context<CreateProduct>, product_name: Vec<u8>, product_id: [u8; 32]) -> Result<()> {
    let product = &mut ctx.accounts.product;
    let user = &ctx.accounts.user;

    product.name = product_name;
    product.id = product_id;
    product.owner = user.key();
    product.is_auctioning = false;

    Ok(())
}

#[derive(Accounts)]
#[instruction(product_name: Vec<u8>, product_id: [u8; 32])]
pub struct CreateProduct<'info> {
    #[account(
        init,
        seeds = [ &product_name[..], &product_id ],
        bump,
        payer = user,
        space = ACCOUNT_SIZE,
    )]
    pub product: Account<'info, Product>,

    #[account(mut)]
    pub user: Signer<'info>,

    pub system_program: Program<'info, System>,
    pub rent: Sysvar<'info, Rent>,
}

The next instruction is CreateAuction, which is where the first set of constraints takes place:

  • The seed constraint for the auction account is seeds = [ product.key().as_ref(), &auction_name]. This constraint ensures that an auction account will be tied to a specific product account.
  • The raw constraints for the product account are constraint = product.owner == seller.key() and constraint = !product.is_auctioning. These constraints ensure that only the product owner can create an auction for it, and the product itself must also not be in any other auctions. Together with the previous constraint on the auction account, they make it so that there can only be one auction going for one specific product at any given time.

The instruction itself initializes several fields in the auction account, and then sets the is_auctioning field on the product account to true:

pub fn create_auction(ctx: Context<CreateAuction>, auction_name: Vec<u8>) -> Result<()> {
    let auction = &mut ctx.accounts.auction;
    let product = &mut ctx.accounts.product;
    let seller = &ctx.accounts.seller;

    auction.name = auction_name;
    auction.product = product.key();
    auction.owner = seller.key();

    auction.winning_bid_amount = 0;
    auction.has_ended = false;

    product.is_auctioning = true;

    Ok(())
}

The most important instruction in this contract is Bid, which also has its own set of constraints:

  • The seed constraint for the bid account is seeds = [ &auction.key().as_ref(), &bidder.key().as_ref() ]. The seeds contain both the pubkey of the auction account and the pubkey of the bidder account (which is the signer for this instruction), this ensures that one user can only bid a single time in an auction.
  • The first raw constraint for the auction account is constraint = !auction.has_ended, this is obviously to endure that users cannot bid on a finished auction.
  • The second raw constraint for the auction account is constraint = bid_amount > auction.winning_bid_amount. This is how the developer chose how to implement the bidding logic: a user can only successfully place their bid via the Bid instruction if they bid more than the previous highest bidder, otherwise the instruction invocation itself will fail due to this constraint.

Because the check for the bidding logic is already implemented in the constraints, only a winning bid can actually execute the body of the instruction. Therefore, the instruction’s job is only to set the fields to the winning bid, and transferring the bidding amount, without needing any additional checks:

pub fn bid(ctx: Context<Bid>, bid_amount: u64) -> Result<()> {
    let auction = &mut ctx.accounts.auction;
    let bid = &mut ctx.accounts.bid;
    let bidder = &mut ctx.accounts.bidder;

    bid.auction = auction.key();
    bid.bidder = bidder.key();
    bid.amount = bid_amount;

    // we have already confirmed that bid amount is higher than what is currently winning using constraints
    // therefore we can just overwrite the current bid without any other checks
    auction.winning_bid = bid.key();
    auction.winning_bid_amount = bid_amount;
    auction.winning_bid_owner = bid.bidder;

    let cpi_context = CpiContext::new(
        ctx.accounts.system_program.to_account_info(),
        system_program::Transfer {
            from: bidder.to_account_info(),
            to: bid.to_account_info(),
        }
    );
    system_program::transfer(cpi_context, bid_amount)?;

    Ok(())
}

Finally, EndAuction can be called to finalize the bid and decide the winner. Several contraints are put on the auction account here:

  • constraint = !auction.has_ended: only an ongoing auction can be ended.
  • constraint = auction.owner == seller.key(): only the product owner can end its auction.
  • constraint = auction.winning_bid_amount > 0: the auction needs to have at least 1 valid bidder before it can be ended.

After the auction ends, the product owner will change to the winning bidder:

pub fn end_auction(ctx: Context<EndAuction>) -> Result<()> {
    let auction = &mut ctx.accounts.auction;
    let product = &mut ctx.accounts.product;

    auction.has_ended = true;

    product.owner = auction.winning_bid_owner;
    product.is_auctioning = false;

    Ok(())
}

The server program

The server program for this challenge is a bit more complicated then the other challenges that I have done so far. These are the steps it follows:

  1. Create the following accounts: admin, user and rich_boi.
  2. Transfer 500 trillion lamport to the rich_boi account, and 1 billion lamports to the user account.
  3. The admin account invokes CreateProduct to create a product account, using the product name "sakura miku noodle stopper" and the product id which is a 32-bit hash of the number 727.
  4. The admin account invokes CreateAuction to create an auction for said product, using the auction name "fun auction".
  5. Run our exploit contract
  6. The rich_boi account invokes Bid with the bidding amount of 100 trillion lamports.
  7. The admin account invokes EndAuction.
  8. Check if the user account is the owner of the product. In other words, check if the user account is the winner of the auction. If they are, the flag is printed.

The vulnerabilities

During the duration of the CTF, I had attempted 3 different ways to win the auction, with only the final one being successful.

The first idea is the most obvious one: to win the auction in the intended way, by placing a higher bidding amount than rich_boi. I could quickly see that this idea would never work. Since we as the user account has a very small amount of lamport, and there aren’t any other sources of lamport that we can potentially steal from because we are the first person to bid.

However, our contract being executed before rich_boi placing their bid giving us a new advantage: if we can’t win by having a larger amount of money than our opponent, we can simply prevent them to place their bid in the first place. This can be done by causing the Bid instruction invoked by rich_boi to fail. We can look back at the constraints on this instruction to give us a lead on how to do it. My first attempt on this was to try and make the constraint = !auction.has_ended to fail. In order to do so, I need to somehow call EndAuction on the auction account. However, I was obviously restricted by the strict set of constraints:

  • I can only call EndAuction on an auction account that I own -> I need to call CreateAuction on the product.
  • I can only call CreateAuction on a product that I own and the product is_auctioning field must be false -> I cannot create an auction on the target product, I can only call CreateProduct to create my own.
  • Each product is unique because of how their seeds are chosen -> creating my own product doesn’t do anything meaningful, because the product on auction that I need to win is another one.

So this idea failed as well. I spent a lot of time trying to bypass the above constraints to no avail, they are simply as good as they can be to prevent such attack. Then after a few hours, I realized that there is another constraint that can be abused to cause a Bid instruction invocation to fail: the seed contrainst seeds = [ &auction.key().as_ref(), &bidder.key().as_ref() ]. The seeds for a BidInfo account is created from the pubkey of the auction account, and the pubkey of the bidder. Moreover, in the Bid instruction, the BidInfo account will be initialized with init attribute. Therefore, if I can initialize a fake account using a PDA with seeds being the pubkey of the auction account and the pubkey of the rich_boi account, then rich_boi won’t be able to ever invoke Bid anymore, because the PDA for their BidInfo account will collide with the fake account that I already initialized, causing the init attribute to fail due to re-initialization. The problem here is how to initialize one such fake account. I obviously cannot invoke Bid with the bidder being rich_boi, because bidder must be the signer for the instruction. However, this fake account doesn’t need to be a BidInfo account. Our only goal is to make the PDAs to collide, we don’t care what type of account it is. Fortunately for us, there is one type of account that we can create for our own, with the seeds of our choosing, the Product account. As I have said in the previous section in the CreateProduct instruction, the seeds for the product account come directly from the 2 arguments provided to it: product_name and product_id. Therefore, if we set product_name to the pubkey of the auction account, and the product_id to the pubkey of rich_boi, we can create a fake product account whose PDA will collide with the BidInfo account to be initialized by rich_boi, causing their bid to fail.

This sneaky vulnerablity, which took me several hours to realize, can be exploited in 2 simple steps:

  • Making a bid of any amount to set myself as the winning bid account.
  • Creating a fake product whose product_name is the pubkey of the auction account, and product_id is the pubkey of rich_boi.
// framework-solve/src/main.rs
let rich_boi_bid = Pubkey::find_program_address(&[auction.as_ref(), rich_boi.as_ref()], &chall_id).0;
let product_name: [u8; 32] = auction.as_ref().try_into()?;
let product_id: [u8; 32] = rich_boi.as_ref().try_into()?;

let ix = solve::instruction::Initialize { product_name, product_id };
let data = ix.data();

let ix_accounts = solve::accounts::Initialize {
    admin,
    rich_boi,
    user,
    auction,
    product,
    bid,
    rich_boi_bid,
    system_program: system_program::ID,
    chall: chall_id,
    rent: solana_program::sysvar::rent::ID,
};

// framework-solve/solve/src/lib.rs
pub fn initialize(ctx: Context<Initialize>, product_name: [u8; 32], product_id: [u8; 32]) -> Result<()> {
    // Bid any amount
    let cpi_accounts = chall::cpi::accounts::Bid {
        bid: ctx.accounts.bid.to_account_info(),
        auction: ctx.accounts.auction.to_account_info(),
        product: ctx.accounts.product.to_account_info(),
        bidder: ctx.accounts.user.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::bid(cpi_ctx, 1234)?;
    
    // Create a fake product that collides with rich_boi_bid PDA
    let cpi_accounts = chall::cpi::accounts::CreateProduct {
        product: ctx.accounts.rich_boi_bid.to_account_info(),
        user: ctx.accounts.user.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::create_product(cpi_ctx, product_name.to_vec(), product_id)?;

    Ok(())
}

The flag for this challenge is SEKAI{w0nt_th3se_g3ntlem3n_suff1ce}

Appendix

  • The challenge’s source and solution can be found here.