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.solana.com/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 1
Find the out-of-bounds balance readObservationI noticed the program lets you create a balances account with space = 0 but still reads balances[balance_index] with no bounds check, which suggested that the Solana runtime's fixed 10 KB padding would let an out-of-bounds index land in adjacent account metadata and bypass the deposits >= 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 and reads a real (zero) balance, so deposits >= withdraws fails and the withdraw is denied. Index 641 lands further into the padding but that memory may not satisfy the comparison reliably. Index 640 is the specific offset that overlaps a neighboring account's metadata bytes, which happen to hold a non-zero value that passes the guard - the correct approach targets 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 initializes all balance slots to zero, so a legitimate deposit would be recorded correctly and the withdrawal would only give back what you put in - no net gain. The exploit only works when the account has space = 0, forcing the runtime to pad it, so the out-of-bounds index reads into 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 2
Bypass the clock checkObservationI noticed the program validates the clock account by checking whether the base58 address contains the substring C1ock rather than comparing against the canonical sysvar key, which suggested that supplying a crafted address with that substring and zero-filled data would clear the timestamp gate without any real time manipulation.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 does not validate against the canonical Clock sysvar key - it checks whether the base58 address string contains the substring C1ock. The real sysvar address does contain that substring, but its data holds the actual current timestamp, which may not be zero and may fail the comparison depending on the instance. A crafted address whose data is entirely zero-filled by the runtime is the reliable path 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 call a sleep or advance-clock instruction - time is controlled by the validator, not by user programs. The bug here is not in the timestamp value itself but in the address-substring check: any address spelling C1ock in its base58 form bypasses validation entirely 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 3
Write the solver and drain the vaultObservationI noticed the harness loads my own solver as an on-chain program and invokes it against the vulnerable target, which suggested writing a CPI-based solver that creates the zero-space balances account and then calls handle_withdraw with balance_index = 640 in the exact account order defined by the target's process_instruction handler.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 program indexes accounts positionally with next_account_info calls - if you shift even one account in the list, every subsequent account_info dereference points to the wrong account. The result is either an account ownership error, a missing signer error, or a silent arithmetic operation on the wrong lamport balance. The correct account order must be read directly from the target's process_instruction handler, matching each next_account_info 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 program defines its own instruction discriminant encoding - it is not a standard Anchor IDL, so the opcode byte for handle_withdraw is whatever the raw match arm says. Guessing 0 or 1 will hit a different handler (likely deposit or initialize) and drain nothing. The correct approach is to grep the program's instruction decode section and map the exact byte value 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 4
Satisfy the win condition and read the flagObservationI noticed the harness description states it prints the flag when your account ends with more lamports than it started with, which confirmed that successfully draining the vault via the OOB index was the final step needed to trigger flag output.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
- Pwntools ForgeGenerate a complete pwntools exploit script from a template: ret2win, shellcode, ret2libc, ROP chain, format string, or blank scaffold. Fill the form, copy or download the .py file. Fully editable before saving.
- pwntools Payload BuilderPack integers into little-endian bytes (p32 / p64), unpack bytes back to integers, and build flat ROP payloads with offset-based insertion.
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.