Description
A Solana challenge. You are given a raw on-chain BPF program (written against the bare solana_program interface, not Anchor) plus a harness that loads the target, funds accounts, and runs your own solver program against it. End with more lamports than you started with and the harness prints the flag.
The bug is an account-sizing flaw: the program reads your 'balance' out of an account you create with space = 0, but the Solana runtime always pads deserialized account data by 10 KB. Indexing one slot past the legitimate balance array reads into adjacent account metadata, which makes the deposits >= withdraws check pass for lamports you never deposited.
Setup
Read the raw BPF program (process_instruction) and the harness/invoker that sets up accounts and runs your solver.
Note the balance array length (640 entries) and the clock-account check, then write a solver that creates a zero-space account and withdraws against an out-of-bounds balance index.
sh -c '$(curl -sSfL https://release.anza.xyz/stable/install)'cat src/lib.rs # the vulnerable programcat solve/src/lib.rs # the harness that invokes your solverSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the out-of-bounds balance read
ObservationThe program lets you create a balances account with zero space and still indexes into it with no bounds check. Solana's runtime pads accounts to a fixed 10 KB, so an out-of-bounds index lands in adjacent account metadata and slips past the deposits-versus-withdraws guard.The program stores per-user balances in an array of 640 entries kept in a writable account. It lets you create that account with space = 0, but the Solana runtime always grows deserialized account data by a fixed 10 KB (MAX_PERMITTED_DATA_INCREASE). Reading balance_index = 640 (one past the legitimate limit) lands in that padded region, which overlaps a neighboring account's metadata. Those bytes make the program believe your deposits exceed your withdraws, so the deposits >= withdraws guard passes without a real deposit.bash# In the program, locate the balances array (640 entries) and the deposit/withdraw handlers.bash# Confirm: create-account allows space = 0, and withdraw indexes balances[balance_index] with no bound.bashgrep -nE 'lamports|balance|640|MAX_PERMITTED|next_account_info' src/lib.rsWhat didn't work first
Tried: Try balance_index = 641 or 639 instead of 640 to trigger the OOB read.
Index 639 is the last valid entry, reading a genuine zero balance, so the guard fails and the withdrawal is denied. Index 641 reaches further into the padding, where the memory does not reliably satisfy the comparison. Index 640 is the offset that overlaps a neighbouring account's metadata, which happens to hold a non-zero value that passes. Aim at exactly that boundary.
Tried: Pass a normal 640-entry pre-allocated account to avoid the OOB access and attempt a deposit before withdrawing.
A properly sized account zeroes every balance slot, so a legitimate deposit records correctly and the withdrawal returns exactly what you put in, with no gain. The exploit needs zero space, which forces the runtime to pad the account, so the out-of-bounds index reads adjacent memory rather than your own balance data.
Learn more
Solana programs receive a list of accounts (each holding lamports and a data blob) and an instruction buffer. A raw program does its own deserialization and account validation, which is where this bug lives. The runtime zero-pads every account's data by 10 KB so programs can grow data in place; reading past your declared length therefore returns attacker-adjacent runtime memory rather than faulting.
The overlapped bytes (a neighboring account's flags/metadata) happen to satisfy the program's
deposits >= withdrawscheck, sohandle_withdrawtransfers vault lamports to you for a balance you never funded.Step 2Bypass the clock check
ObservationThe program validates the clock account by checking whether its base58 address contains a substring, not by comparing against the canonical sysvar key. A crafted address carrying that substring, with zero-filled data, clears the timestamp gate without touching time at all.The program checks the clock account by confirming its base58 address contains the literal substring C1ock, and then reads a timestamp from its (zero-padded) data. Supply a crafted address that contains C1ock; its data is all zeros, and zero passes the timestamp check, so the gate is free to clear.bash# Feed an account whose base58 address embeds the substring 'C1ock'.bash# Its data is zero-filled by the runtime, satisfying the timestamp comparison.What didn't work first
Tried: Pass the real Solana Clock sysvar address (SysvarC1ock11111111111111111111111111111111) as the clock account.
The program never checks against the canonical Clock sysvar key; it only asks whether the base58 address contains a particular substring. The real sysvar address does contain it, but its data holds the current timestamp, which may be non-zero and may fail the comparison on a given instance. A crafted address whose data the runtime zero-fills is reliable, because zero always satisfies the check.
Tried: Try to manipulate the slot clock by calling the system program to advance time before invoking the target.
On-chain programs cannot sleep or advance the clock; time belongs to the validator, not to user code. The bug is not in the timestamp value but in the address check: any address whose base58 form carries the expected substring passes, because the program trusts the shape of the key rather than the identity of the sysvar.
Learn more
This is a classic confused-sysvar check: instead of reading the canonical Clock sysvar, the program trusts an address shaped like the word it expects. Because the runtime hands out zero-initialized data for unknown addresses, a zero timestamp clears the check. No real time manipulation is needed.
Step 3Write the solver and drain the vault
ObservationThe harness loads your solver as an on-chain program and invokes it against the vulnerable target. So write a CPI-based solver that creates the zero-space balances account, then calls the withdraw handler at index 640, in the exact account order the target's process_instruction expects.Write a solver that (1) creates the balances account with space = 0 owned by the target, then (2) calls handle_withdraw with balance_index = 640 and the vault as the source, sending the C1ock account, system program, user, vault, and target program in the order the handler expects. The vault transfers its lamports (~50,000) to your account, leaving you net-positive.js// solve/src/lib.rs - account order and opcodes come from the target 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 { // 1) create the balances account with space = 0, owner = target // 2) withdraw with balance_index = 640 (OOB) so deposits >= withdraws passes let withdraw_ix = Instruction { program_id: *next_account_info(&mut accounts.iter())?.key, accounts: vec![ // C1ock (ro), system program, user (signer, writable), // vault (writable), target program, zero-space balances acct (writable) ], data: vec![/* withdraw opcode + balance_index = 640 */], }; invoke(&withdraw_ix, accounts)?; Ok(()) }What didn't work first
Tried: Provide accounts to the CPI call in the wrong order (for example, placing the vault before the user or omitting the C1ock account).
The target indexes accounts positionally through next_account_info, so shifting even one entry sends every later dereference to the wrong account. You get an ownership error, a missing signer error, or a silent arithmetic operation on the wrong lamport balance. Read the order straight from the target's process_instruction handler, matching each call in sequence.
Tried: Encode the withdraw opcode as a u8 literal guess (0, 1, or 2) without reading the actual dispatch table.
The target defines its own instruction discriminants rather than using a standard Anchor IDL, so the opcode byte for the withdraw handler is whatever its match arm says. Guess 0 or 1 and you land on deposit or initialize and drain nothing. Grep the instruction decode section for the exact byte assigned to the withdraw variant.
Learn more
The harness runs your solver as an on-chain program, so you trigger the bug through cross-program invocation (CPI): your program builds the target's instructions with the right account list and
invokes them. The account ordering and the withdraw opcode/amount encoding are the instance-specific pieces you read off the target program.Step 4Satisfy the win condition and read the flag
ObservationThe harness prints the flag when your account ends with more lamports than it started with. Draining the vault through the out-of-bounds index is therefore the last step.The harness checks final balances. Once your controlled account holds more lamports than you started with (the vault drained via the OOB index), the harness prints the flag.Learn more
The proper fix is to never read balances past the account's real length, to validate the Clock sysvar by its canonical address rather than a substring, and to bound every withdraw by a deposit record the program owns and verifies with checked arithmetic.
Interactive tools
- Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.
- Number Base ConverterConvert numbers between binary, octal, decimal, and hexadecimal instantly. Enter any value and see all four bases update in real time.
- Cyclic Pattern GeneratorGenerate de Bruijn cyclic patterns and find buffer overflow offsets. The browser equivalent of pwntools cyclic and cyclic_find.
Flag
Reveal flag
picoCTF{...}
Raw Solana BPF program. Create the balances account with space = 0 (the runtime pads data by 10 KB), then withdraw with balance_index = 640 so the OOB read of adjacent metadata makes deposits >= withdraws pass. Bypass the clock check with an address containing the substring C1ock. The vault drains to you and the harness prints the flag. Flag is instance-emitted.