solfire picoCTF 2022 Solution

Published: July 20, 2023

Description

A Solana challenge. You are given a raw on-chain BPF program (written against the bare solana_program interface, NOT the Anchor framework) plus a harness that loads the target program, funds accounts, and runs your own solver program against it. The goal is to end with the win/vault account holding more lamports than it should, which the harness rewards with the flag.

The bug is in the program's lamport accounting across accounts: it lets you withdraw (move lamports out of a vault account into one you control) without correctly checking how much you actually deposited, so you can withdraw more than you put in and drain the target.

Read the provided raw BPF program (process_instruction) and the harness/invoker that sets up accounts and runs your solver.

Find where the program credits and debits account lamports during deposit and withdraw, and where that accounting fails to balance.

Write a solver program/transaction that deposits a small amount (or none) and withdraws more than was deposited.

bash
sh -c '$(curl -sSfL https://release.solana.com/stable/install)'
bash
# Read the raw program (uses solana_program, not anchor):
bash
cat program/src/lib.rs   # or the provided .rs / .c source
bash
# Read the harness that links your solver against the target:
bash
cat solve/src/lib.rs
  1. Step 1Read the raw BPF program and find the lamport-accounting flaw
    On Solana, moving SOL means decrementing one account's lamports field and incrementing another's by the same amount, by hand. Read the deposit and withdraw handlers. The bug is that the program does not correctly tie how much you can withdraw to how much you actually deposited (for example, it tracks a balance in the wrong account, trusts an attacker-supplied account, or credits/debits mismatched accounts). That lets you withdraw lamports you never deposited and drain the target vault.
    bash
    # In the raw program, find the two lamport moves and compare them:
    bash
    #   deposit:  vault.lamports += amount;   from_user.lamports -= amount;
    bash
    #   withdraw: to_user.lamports += amount; vault.lamports     -= amount;
    bash
    # Look for: which account's data tracks 'how much this user deposited',
    bash
    # and whether withdraw actually checks it (or trusts an account you pass in).
    bash
    grep -nE 'lamports|borrow_mut|try_from_slice|next_account_info' program/src/lib.rs
    Learn more

    Solana programs are compiled to BPF and run on-chain. They receive a list of accounts (each holding lamports and a data blob) and an instruction byte buffer. A raw program (no Anchor) does its own deserialization and its own account validation, which is exactly where mistakes creep in.

    Lamports are conserved only if the program is careful: every increment of one account's lamports must be matched by an equal decrement elsewhere, and a withdraw must be bounded by a deposit record that cannot be forged. If the withdraw path lets the caller specify the amount or the source/destination accounts without verifying the deposit, you can pull more out than you put in. That is the flaw here - a cross-account lamport accounting bug, not an Anchor constraint mistake.

  2. Step 2Write a solver program that withdraws more than it deposits
    The challenge ships an invoker/harness that deploys the target program, funds the relevant accounts, and then loads and runs YOUR solver program. Write a solver that builds the deposit/withdraw instructions (using solana_program::instruction::Instruction and invoke/invoke_signed) so that the net effect is the vault losing lamports to an account you control - more than you deposited. The exact instruction layout and required accounts come from reading the target program.
    js
    // solve/src/lib.rs - sketch; fill in the real instruction layout from the program
    use solana_program::{
        account_info::{next_account_info, AccountInfo},
        entrypoint, entrypoint::ProgramResult,
        instruction::{AccountMeta, Instruction},
        program::invoke,
        pubkey::Pubkey,
    };
    
    entrypoint!(process);
    
    fn process(_pid: &Pubkey, accounts: &[AccountInfo], _data: &[u8]) -> ProgramResult {
        let it = &mut accounts.iter();
        let target = next_account_info(it)?;   // the vulnerable program
        let vault  = next_account_info(it)?;   // vault holding lamports
        let me     = next_account_info(it)?;   // account you control
    
        // 1) (optional) deposit a tiny amount so the program has state
        // 2) withdraw MORE than deposited by exploiting the accounting bug
        let withdraw_ix = Instruction {
            program_id: *target.key,
            accounts: vec![
                AccountMeta::new(*vault.key, false),
                AccountMeta::new(*me.key, true),
                // ...accounts the withdraw handler expects...
            ],
            data: vec![/* withdraw opcode + crafted amount */],
        };
        invoke(&withdraw_ix, accounts)?;
        Ok(())
    }
    Learn more

    Because the harness invokes your solver as an on-chain program, you exploit the bug through cross-program invocation (CPI): your program calls the target program's instructions just like any other client, but from inside the runtime. You build each Instruction with the account list the target expects, then invoke it.

    The crafted call (or sequence of calls) makes the vault's lamports drop by more than you deposited and credits the difference to an account you own. Read the target's withdraw handler to learn the exact opcode, amount encoding, and account ordering - those are the instance-specific pieces you must fill in.

  3. Step 3Satisfy the win condition and read the flag
    The harness checks the final lamport balances after running your solver. When the target vault has been drained beyond what you deposited (the win/balance condition the harness enforces), it prints the flag.
    Learn more

    After your solver runs, the harness inspects account balances. The win condition is a lamport-balance check: your controlled account ended up with more lamports than the program should ever have released. When it passes, the harness emits the flag.

    The proper fix for this class of bug is to record each depositor's balance in an account the program owns and cannot be spoofed, and to bound every withdraw by that recorded balance using checked arithmetic - never trusting a caller-supplied amount or account for the accounting.

Flag

picoCTF{...}

Raw (non-Anchor) Solana BPF program with a cross-account lamport accounting flaw: the withdraw path lets you take out more lamports than you deposited. Write a solver program the harness invokes via CPI to drain the vault, satisfying the harness's final balance check.

Want more picoCTF 2022 writeups?

Useful tools for Binary Exploitation

Related reading

What to try next