SekaiCTF 2023 Solana Challenge The Bidding Write-up
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
andframework-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()
andconstraint = !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 theBid
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:
- Create the following accounts:
admin
,user
andrich_boi
. - Transfer 500 trillion lamport to the
rich_boi
account, and 1 billion lamports to theuser
account. - The
admin
account invokesCreateProduct
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. - The
admin
account invokesCreateAuction
to create an auction for said product, using the auction name"fun auction"
. - Run our exploit contract
- The
rich_boi
account invokesBid
with the bidding amount of 100 trillion lamports. - The
admin
account invokesEndAuction
. - Check if the
user
account is the owner of the product. In other words, check if theuser
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 callCreateAuction
on the product. - I can only call
CreateAuction
on a product that I own and the productis_auctioning
field must befalse
-> I cannot create an auction on the target product, I can only callCreateProduct
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, andproduct_id
is the pubkey ofrich_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.